feat(auth): add local username-password login

This commit is contained in:
vsxd 2026-03-12 21:27:01 +08:00
parent f61da15727
commit 05ec9bfbc2
24 changed files with 1076 additions and 11 deletions

View file

@ -0,0 +1,75 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuthMeResponse;
import com.iflytek.skillhub.dto.ChangePasswordRequest;
import com.iflytek.skillhub.dto.LocalLoginRequest;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.exception.UnauthorizedException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/auth/local")
public class LocalAuthController extends BaseApiController {
private final LocalAuthService localAuthService;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService) {
super(responseFactory);
this.localAuthService = localAuthService;
}
@PostMapping("/register")
public ApiResponse<AuthMeResponse> register(@Valid @RequestBody LocalRegisterRequest request,
HttpServletRequest httpRequest) {
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
establishSession(principal, httpRequest);
return ok("response.success.created", AuthMeResponse.from(principal));
}
@PostMapping("/login")
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
HttpServletRequest httpRequest) {
PlatformPrincipal principal = localAuthService.login(request.username(), request.password());
establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
}
@PostMapping("/change-password")
public ApiResponse<Void> changePassword(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody ChangePasswordRequest request) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword());
return ok("response.success.updated", null);
}
private void establishSession(PlatformPrincipal principal, HttpServletRequest request) {
var authorities = principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
var authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute("platformPrincipal", principal);
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record ChangePasswordRequest(
@NotBlank(message = "当前密码不能为空")
String currentPassword,
@NotBlank(message = "新密码不能为空")
String newPassword
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record LocalLoginRequest(
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(message = "密码不能为空")
String password
) {}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LocalRegisterRequest(
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(message = "密码不能为空")
String password,
@Email(message = "邮箱格式不正确")
String email
) {}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
@ -31,6 +32,13 @@ public class GlobalExceptionHandler {
apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(AuthFlowException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex) {
HttpStatus status = ex.getStatus();
return ResponseEntity.status(status).body(
apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs()));
}
@ExceptionHandler(DomainBadRequestException.class)
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
return ResponseEntity.badRequest().body(

View file

@ -0,0 +1,32 @@
CREATE TABLE local_credential (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_local_credential_username ON local_credential (username);
CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential (user_id);
CREATE TABLE account_merge_request (
id BIGSERIAL PRIMARY KEY,
primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
verification_token VARCHAR(255),
token_expires_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_merge_primary_status ON account_merge_request (primary_user_id, status);
CREATE UNIQUE INDEX idx_merge_secondary_pending
ON account_merge_request (secondary_user_id)
WHERE status = 'PENDING';
CREATE INDEX idx_merge_token_pending
ON account_merge_request (verification_token)
WHERE status = 'PENDING';

View file

@ -71,3 +71,15 @@ error.deviceAuth.userCode.invalid=Invalid or expired user code
error.deviceAuth.deviceCode.expired=Device code expired
error.deviceAuth.deviceCode.invalid=Device code expired or invalid
error.deviceAuth.deviceCode.used=Device code has already been used
error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores
error.auth.local.username.exists=Username already exists
error.auth.local.email.exists=Email already exists
error.auth.local.invalidCredentials=Invalid username or password
error.auth.local.accountDisabled=Account has been disabled
error.auth.local.accountPending=Account is pending approval
error.auth.local.accountMerged=Account has been merged into another account
error.auth.local.locked=Account is locked. Try again in {0} minute(s)
error.auth.local.notEnabled=Password login is not enabled for this account
error.auth.local.password.tooShort=Password must be at least 8 characters
error.auth.local.password.tooLong=Password must not exceed 128 characters
error.auth.local.password.tooWeak=Password must contain at least three character types

View file

@ -71,3 +71,15 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码
error.deviceAuth.deviceCode.expired=设备验证码已过期
error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期
error.deviceAuth.deviceCode.used=设备验证码已被使用
error.auth.local.username.invalid=用户名长度必须为 3 到 64 个字符,且只能包含字母、数字或下划线
error.auth.local.username.exists=用户名已存在
error.auth.local.email.exists=邮箱已存在
error.auth.local.invalidCredentials=用户名或密码错误
error.auth.local.accountDisabled=账号已被禁用
error.auth.local.accountPending=账号仍在审核中
error.auth.local.accountMerged=账号已合并到其他账号
error.auth.local.locked=账号已锁定,请 {0} 分钟后重试
error.auth.local.notEnabled=当前账号未启用密码登录
error.auth.local.password.tooShort=密码长度至少为 8 位
error.auth.local.password.tooLong=密码长度不能超过 128 位
error.auth.local.password.tooWeak=密码至少需要包含三种字符类型

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.controller;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class LocalAuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private LocalAuthService localAuthService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
"alice",
"alice@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
given(localAuthService.login("alice", "Abcd123!")).willReturn(principal);
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"Abcd123!"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_1"))
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
}
@Test
void register_returnsCreatedEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_2",
"bob",
"bob@example.com",
"",
"local",
Set.of()
);
given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal);
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"bob","password":"Abcd123!","email":"bob@example.com"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.displayName").value("bob"));
}
@Test
void changePassword_requiresAuthentication() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isUnauthorized());
}
@Test
void changePassword_withAuthentication_returnsUpdated() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_3",
"carol",
"carol@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
}

View file

@ -10,6 +10,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpMethod;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -66,6 +68,7 @@ public class SecurityConfig {
"/api/v1/health",
"/api/v1/auth/providers",
"/api/v1/auth/me",
"/api/v1/auth/local/**",
"/api/v1/cli/auth/device/**",
"/api/v1/cli/check",
"/actuator/health",
@ -110,4 +113,9 @@ public class SecurityConfig {
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}

View file

@ -0,0 +1,29 @@
package com.iflytek.skillhub.auth.exception;
import org.springframework.http.HttpStatus;
public class AuthFlowException extends RuntimeException {
private final HttpStatus status;
private final String messageCode;
private final Object[] messageArgs;
public AuthFlowException(HttpStatus status, String messageCode, Object... messageArgs) {
super(messageCode);
this.status = status;
this.messageCode = messageCode;
this.messageArgs = messageArgs;
}
public HttpStatus getStatus() {
return status;
}
public String getMessageCode() {
return messageCode;
}
public Object[] getMessageArgs() {
return messageArgs;
}
}

View file

@ -0,0 +1,188 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class LocalAuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$");
private static final int MAX_FAILED_ATTEMPTS = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final LocalCredentialRepository credentialRepository;
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final PasswordPolicyValidator passwordPolicyValidator;
private final PasswordEncoder passwordEncoder;
public LocalAuthService(LocalCredentialRepository credentialRepository,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder) {
this.credentialRepository = credentialRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.passwordPolicyValidator = passwordPolicyValidator;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public PlatformPrincipal register(String username, String password, String email) {
String normalizedUsername = normalizeUsername(username);
validateUsername(normalizedUsername);
if (credentialRepository.existsByUsernameIgnoreCase(normalizedUsername)) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.username.exists");
}
String normalizedEmail = normalizeEmail(email);
if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists");
}
var passwordErrors = passwordPolicyValidator.validate(password);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
UserAccount user = new UserAccount(
"usr_" + UUID.randomUUID(),
normalizedUsername,
normalizedEmail,
null
);
user.setStatus(UserStatus.ACTIVE);
userAccountRepository.save(user);
credentialRepository.save(new LocalCredential(
user.getId(),
normalizedUsername,
passwordEncoder.encode(password)
));
return buildPrincipal(user);
}
@Transactional
public PlatformPrincipal login(String username, String password) {
String normalizedUsername = normalizeUsername(username);
LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
.orElseThrow(() -> invalidCredentials());
UserAccount user = userAccountRepository.findById(credential.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for local credential"));
ensureUserCanLogin(user);
ensureNotLocked(credential);
if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
throw invalidCredentials();
}
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
return buildPrincipal(user);
}
@Transactional
public void changePassword(String userId, String currentPassword, String newPassword) {
LocalCredential credential = credentialRepository.findByUserId(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.notEnabled"));
if (!passwordEncoder.matches(currentPassword, credential.getPasswordHash())) {
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials");
}
var passwordErrors = passwordPolicyValidator.validate(newPassword);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
credential.setPasswordHash(passwordEncoder.encode(newPassword));
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
}
private PlatformPrincipal buildPrincipal(UserAccount user) {
Set<String> roles = userRoleBindingRepository.findByUserId(user.getId()).stream()
.map(binding -> binding.getRole().getCode())
.collect(Collectors.toSet());
return new PlatformPrincipal(
user.getId(),
user.getDisplayName(),
user.getEmail(),
user.getAvatarUrl(),
"local",
roles
);
}
private void ensureUserCanLogin(UserAccount user) {
if (user.getStatus() == UserStatus.DISABLED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled");
}
if (user.getStatus() == UserStatus.PENDING) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending");
}
if (user.getStatus() == UserStatus.MERGED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged");
}
}
private void ensureNotLocked(LocalCredential credential) {
if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(LocalDateTime.now())) {
long minutes = Math.max(1, Duration.between(LocalDateTime.now(), credential.getLockedUntil()).toMinutes());
throw new AuthFlowException(HttpStatus.LOCKED, "error.auth.local.locked", minutes);
}
}
private void handleFailedLogin(LocalCredential credential) {
int failedAttempts = credential.getFailedAttempts() + 1;
credential.setFailedAttempts(failedAttempts);
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
credential.setLockedUntil(LocalDateTime.now().plus(LOCK_DURATION));
}
credentialRepository.save(credential);
}
private AuthFlowException invalidCredentials() {
return new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials");
}
private String normalizeUsername(String username) {
return username == null ? "" : username.trim().toLowerCase(Locale.ROOT);
}
private String normalizeEmail(String email) {
if (email == null || email.isBlank()) {
return null;
}
return email.trim().toLowerCase(Locale.ROOT);
}
private void validateUsername(String username) {
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid");
}
}
}

View file

@ -0,0 +1,97 @@
package com.iflytek.skillhub.auth.local;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "local_credential")
public class LocalCredential {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, length = 128, unique = true)
private String userId;
@Column(nullable = false, length = 64, unique = true)
private String username;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "failed_attempts", nullable = false)
private int failedAttempts;
@Column(name = "locked_until")
private LocalDateTime lockedUntil;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected LocalCredential() {}
public LocalCredential(String userId, String username, String passwordHash) {
this.userId = userId;
this.username = username;
this.passwordHash = passwordHash;
this.failedAttempts = 0;
}
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public String getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public int getFailedAttempts() {
return failedAttempts;
}
public void setFailedAttempts(int failedAttempts) {
this.failedAttempts = failedAttempts;
}
public LocalDateTime getLockedUntil() {
return lockedUntil;
}
public void setLockedUntil(LocalDateTime lockedUntil) {
this.lockedUntil = lockedUntil;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.auth.local;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LocalCredentialRepository extends JpaRepository<LocalCredential, Long> {
Optional<LocalCredential> findByUsernameIgnoreCase(String username);
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
}

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.auth.local;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class PasswordPolicyValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 128;
private static final int MIN_CHAR_TYPES = 3;
public List<String> validate(String password) {
List<String> errors = new ArrayList<>();
if (password == null || password.length() < MIN_LENGTH) {
errors.add("error.auth.local.password.tooShort");
return errors;
}
if (password.length() > MAX_LENGTH) {
errors.add("error.auth.local.password.tooLong");
return errors;
}
int typeCount = 0;
if (password.chars().anyMatch(Character::isLowerCase)) {
typeCount++;
}
if (password.chars().anyMatch(Character::isUpperCase)) {
typeCount++;
}
if (password.chars().anyMatch(Character::isDigit)) {
typeCount++;
}
if (password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch))) {
typeCount++;
}
if (typeCount < MIN_CHAR_TYPES) {
errors.add("error.auth.local.password.tooWeak");
}
return errors;
}
}

View file

@ -0,0 +1,128 @@
package com.iflytek.skillhub.auth.local;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class LocalAuthServiceTest {
@Mock
private LocalCredentialRepository credentialRepository;
@Mock
private UserAccountRepository userAccountRepository;
@Mock
private UserRoleBindingRepository userRoleBindingRepository;
@Mock
private PasswordEncoder passwordEncoder;
private LocalAuthService service;
@BeforeEach
void setUp() {
service = new LocalAuthService(
credentialRepository,
userAccountRepository,
userRoleBindingRepository,
new PasswordPolicyValidator(),
passwordEncoder
);
}
@Test
void register_createsUserAndCredential() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty());
given(passwordEncoder.encode("Abcd123!")).willReturn("encoded");
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0));
given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of());
var principal = service.register("Alice", "Abcd123!", "alice@example.com");
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userAccountRepository).save(userCaptor.capture());
assertThat(userCaptor.getValue().getDisplayName()).isEqualTo("alice");
assertThat(principal.displayName()).isEqualTo("alice");
assertThat(principal.email()).isEqualTo("alice@example.com");
verify(credentialRepository).save(any(LocalCredential.class));
}
@Test
void login_withValidPassword_resetsCounters() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
credential.setFailedAttempts(3);
credential.setLockedUntil(LocalDateTime.now().minusMinutes(1));
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
Role role = mock(Role.class);
given(role.getCode()).willReturn("USER_ADMIN");
UserRoleBinding binding = new UserRoleBinding("usr_1", role);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding));
var principal = service.login("alice", "Abcd123!");
assertThat(credential.getFailedAttempts()).isZero();
assertThat(credential.getLockedUntil()).isNull();
assertThat(principal.platformRoles()).containsExactly("USER_ADMIN");
}
@Test
void login_withInvalidPassword_incrementsCounter() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
assertThatThrownBy(() -> service.login("alice", "bad"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(credential.getFailedAttempts()).isEqualTo(1);
verify(credentialRepository).save(credential);
}
@Test
void login_withDisabledAccount_fails() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.DISABLED);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.accountDisabled");
}
}

View file

@ -0,0 +1,38 @@
package com.iflytek.skillhub.auth.local;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class PasswordPolicyValidatorTest {
private final PasswordPolicyValidator validator = new PasswordPolicyValidator();
@Test
void validPassword_passes() {
assertThat(validator.validate("Abcdef1!")).isEmpty();
}
@Test
void tooShort_fails() {
assertThat(validator.validate("Ab1!xyz")).containsExactly("error.auth.local.password.tooShort");
}
@Test
void tooLong_fails() {
assertThat(validator.validate("A".repeat(129))).containsExactly("error.auth.local.password.tooLong");
}
@Test
void twoCharTypes_fails() {
assertThat(validator.validate("abcdefgh1")).containsExactly("error.auth.local.password.tooWeak");
}
@ParameterizedTest
@ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"})
void threeCharTypes_pass(String password) {
assertThat(validator.validate(password)).isEmpty();
}
}

View file

@ -4,5 +4,6 @@ import java.util.Optional;
public interface UserAccountRepository {
Optional<UserAccount> findById(String id);
Optional<UserAccount> findByEmailIgnoreCase(String email);
UserAccount save(UserAccount user);
}

View file

@ -1,6 +1,14 @@
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import type { ApiToken, CreateTokenRequest, CreateTokenResponse, OAuthProvider, User } from './types'
import type {
ApiToken,
CreateTokenRequest,
CreateTokenResponse,
LocalLoginRequest,
LocalRegisterRequest,
OAuthProvider,
User,
} from './types'
const client = createClient<paths>({ baseUrl: '' })
@ -21,6 +29,13 @@ function withCsrf(headers?: HeadersInit): HeadersInit {
}
}
async function ensureCsrfHeaders(headers?: HeadersInit): Promise<HeadersInit> {
if (!getCsrfToken()) {
await client.GET('/api/v1/auth/providers')
}
return withCsrf(headers)
}
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value
}
@ -110,6 +125,26 @@ export const authApi = {
return unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers') as never)
},
async localLogin(request: LocalLoginRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/login', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async localRegister(request: LocalRegisterRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/register', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async logout(): Promise<void> {
const { response, error } = await client.POST('/api/v1/auth/logout', {
headers: withCsrf(),

View file

@ -6,6 +6,15 @@ export type ApiToken = components['schemas']['ApiToken']
export type CreateTokenRequest = components['schemas']['CreateTokenRequest']
export type CreateTokenResponse = components['schemas']['CreateTokenResponse']
export interface LocalLoginRequest {
username: string
password: string
}
export interface LocalRegisterRequest extends LocalLoginRequest {
email?: string
}
// Namespace types
export interface Namespace {
id: number

View file

@ -2,6 +2,7 @@ import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/
import { Layout } from './layout'
import { HomePage } from '@/pages/home'
import { LoginPage } from '@/pages/login'
import { RegisterPage } from '@/pages/register'
import { DashboardPage } from '@/pages/dashboard'
import { SearchPage } from '@/pages/search'
import { NamespacePage } from '@/pages/namespace'
@ -33,6 +34,12 @@ const loginRoute = createRoute({
component: LoginPage,
})
const registerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/register',
component: RegisterPage,
})
const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/search',
@ -190,6 +197,7 @@ const adminAuditLogRoute = createRoute({
const routeTree = rootRoute.addChildren([
homeRoute,
loginRoute,
registerRoute,
searchRoute,
namespaceRoute,
skillDetailRoute,

View file

@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import type { LocalLoginRequest, LocalRegisterRequest } from '@/api/types'
export function useLocalLogin() {
return useMutation({
mutationFn: (request: LocalLoginRequest) => authApi.localLogin(request),
})
}
export function useLocalRegister() {
return useMutation({
mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request),
})
}

View file

@ -1,17 +1,89 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { LoginButton } from '@/features/auth/login-button'
import { useLocalLogin } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
export function LoginPage() {
const loginMutation = useLocalLogin()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
await loginMutation.mutateAsync({ username, password })
window.location.href = '/dashboard'
} catch {
// mutation state drives the error UI
}
}
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="w-full max-w-sm space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold"> SkillHub</h1>
<p className="text-muted-foreground">
</p>
</div>
<LoginButton />
</div>
<div className="mx-auto flex min-h-[70vh] max-w-4xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle> SkillHub</CardTitle>
<CardDescription>使 GitHub OAuth</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="password" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="password"></TabsTrigger>
<TabsTrigger value="oauth">GitHub</TabsTrigger>
</TabsList>
<TabsContent value="password">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="username"></label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="输入用户名"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password"></label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</div>
{loginMutation.error ? (
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
) : null}
<Button className="w-full" disabled={loginMutation.isPending} type="submit">
{loginMutation.isPending ? '登录中...' : '登录'}
</Button>
<p className="text-center text-sm text-muted-foreground">
{' '}
<Link to="/register" className="font-medium text-primary hover:underline">
</Link>
</p>
</form>
</TabsContent>
<TabsContent value="oauth" className="space-y-4">
<p className="text-sm text-muted-foreground">
使 GitHub
</p>
<LoginButton />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,83 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useLocalRegister } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
export function RegisterPage() {
const registerMutation = useLocalRegister()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
await registerMutation.mutateAsync({ username, email, password })
window.location.href = '/dashboard'
} catch {
// mutation state drives the error UI
}
}
return (
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle></CardTitle>
<CardDescription> Dashboard</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-username"></label>
<Input
id="register-username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="3-64 位字母、数字或下划线"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-email"></label>
<Input
id="register-email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="可选,用于后续账号识别"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-password"></label>
<Input
id="register-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位,包含 3 种字符类型"
/>
</div>
{registerMutation.error ? (
<p className="text-sm text-red-600">{registerMutation.error.message}</p>
) : null}
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
{registerMutation.isPending ? '注册中...' : '注册并登录'}
</Button>
<p className="text-center text-sm text-muted-foreground">
{' '}
<Link to="/login" className="font-medium text-primary hover:underline">
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
)
}