From 05ec9bfbc2a73432718fcd3a7aa7df9feca43551 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 21:27:01 +0800 Subject: [PATCH] feat(auth): add local username-password login --- .../controller/LocalAuthController.java | 75 +++++++ .../skillhub/dto/ChangePasswordRequest.java | 10 + .../skillhub/dto/LocalLoginRequest.java | 10 + .../skillhub/dto/LocalRegisterRequest.java | 13 ++ .../exception/GlobalExceptionHandler.java | 8 + .../migration/V5__phase4_auth_governance.sql | 32 +++ .../src/main/resources/messages.properties | 12 ++ .../src/main/resources/messages_zh.properties | 12 ++ .../controller/LocalAuthControllerTest.java | 124 ++++++++++++ .../skillhub/auth/config/SecurityConfig.java | 8 + .../auth/exception/AuthFlowException.java | 29 +++ .../skillhub/auth/local/LocalAuthService.java | 188 ++++++++++++++++++ .../skillhub/auth/local/LocalCredential.java | 97 +++++++++ .../auth/local/LocalCredentialRepository.java | 15 ++ .../auth/local/PasswordPolicyValidator.java | 43 ++++ .../auth/local/LocalAuthServiceTest.java | 128 ++++++++++++ .../local/PasswordPolicyValidatorTest.java | 38 ++++ .../domain/user/UserAccountRepository.java | 1 + web/src/api/client.ts | 37 +++- web/src/api/types.ts | 9 + web/src/app/router.tsx | 8 + web/src/features/auth/use-local-auth.ts | 15 ++ web/src/pages/login.tsx | 92 ++++++++- web/src/pages/register.tsx | 83 ++++++++ 24 files changed, 1076 insertions(+), 11 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java create mode 100644 web/src/features/auth/use-local-auth.ts create mode 100644 web/src/pages/register.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java new file mode 100644 index 00000000..64e0fd29 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -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 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 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 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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java new file mode 100644 index 00000000..2a80ad34 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java new file mode 100644 index 00000000..62ba3b8d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java new file mode 100644 index 00000000..1f560ea8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index c1e91628..b754b4f4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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> 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> handleDomainBadRequest(DomainBadRequestException ex) { return ResponseEntity.badRequest().body( diff --git a/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql new file mode 100644 index 00000000..4e746456 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql @@ -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'; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 7cc2c460..3355754a 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 5192afd1..55f7f0f9 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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=密码至少需要包含三种字符类型 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java new file mode 100644 index 00000000..b2176a0c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -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)); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index a8c94865..23b4114f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -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); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java new file mode 100644 index 00000000..b2127c80 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java new file mode 100644 index 00000000..ab779e74 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -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 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"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java new file mode 100644 index 00000000..790acae0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java new file mode 100644 index 00000000..ffb65668 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -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 { + + Optional findByUsernameIgnoreCase(String username); + + Optional findByUserId(String userId); + + boolean existsByUsernameIgnoreCase(String username); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java new file mode 100644 index 00000000..1afcb86f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java @@ -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 validate(String password) { + List 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; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java new file mode 100644 index 00000000..361083da --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -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 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"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java new file mode 100644 index 00000000..1b5eb8b7 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java @@ -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(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index d9448334..fdd8f21a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -4,5 +4,6 @@ import java.util.Optional; public interface UserAccountRepository { Optional findById(String id); + Optional findByEmailIgnoreCase(String email); UserAccount save(UserAccount user); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index df1e06c0..19c3cb8a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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({ baseUrl: '' }) @@ -21,6 +29,13 @@ function withCsrf(headers?: HeadersInit): HeadersInit { } } +async function ensureCsrfHeaders(headers?: HeadersInit): Promise { + if (!getCsrfToken()) { + await client.GET('/api/v1/auth/providers') + } + return withCsrf(headers) +} + function isApiEnvelope(value: unknown): value is ApiEnvelope { return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value } @@ -110,6 +125,26 @@ export const authApi = { return unwrap(client.GET('/api/v1/auth/providers') as never) }, + async localLogin(request: LocalLoginRequest): Promise { + return fetchJson('/api/v1/auth/local/login', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async localRegister(request: LocalRegisterRequest): Promise { + return fetchJson('/api/v1/auth/local/register', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async logout(): Promise { const { response, error } = await client.POST('/api/v1/auth/logout', { headers: withCsrf(), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b3dcdc65..bd12c031 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 819ac1d8..87b2b1a8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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, diff --git a/web/src/features/auth/use-local-auth.ts b/web/src/features/auth/use-local-auth.ts new file mode 100644 index 00000000..c17c5bd6 --- /dev/null +++ b/web/src/features/auth/use-local-auth.ts @@ -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), + }) +} diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 0ec7add0..d88845c0 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -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) { + event.preventDefault() + try { + await loginMutation.mutateAsync({ username, password }) + window.location.href = '/dashboard' + } catch { + // mutation state drives the error UI + } + } + return ( -
-
-
-

登录 SkillHub

-

- 选择一个方式登录以继续 -

-
- -
+
+ + + 登录 SkillHub + 支持账号密码登录,也可以继续使用 GitHub OAuth。 + + + + + 账号密码 + GitHub + + + +
+
+ + setUsername(event.target.value)} + placeholder="输入用户名" + /> +
+
+ + setPassword(event.target.value)} + placeholder="输入密码" + /> +
+ {loginMutation.error ? ( +

{loginMutation.error.message}

+ ) : null} + +

+ 还没有账号? + {' '} + + 立即注册 + +

+
+
+ + +

+ 使用 GitHub 登录时,认证完成后会自动返回当前站点。 +

+ +
+
+
+
) } diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx new file mode 100644 index 00000000..ec881f15 --- /dev/null +++ b/web/src/pages/register.tsx @@ -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) { + event.preventDefault() + try { + await registerMutation.mutateAsync({ username, email, password }) + window.location.href = '/dashboard' + } catch { + // mutation state drives the error UI + } + } + + return ( +
+ + + 创建账号 + 注册后会自动建立本地会话,可继续进入 Dashboard。 + + +
+
+ + setUsername(event.target.value)} + placeholder="3-64 位字母、数字或下划线" + /> +
+
+ + setEmail(event.target.value)} + placeholder="可选,用于后续账号识别" + /> +
+
+ + setPassword(event.target.value)} + placeholder="至少 8 位,包含 3 种字符类型" + /> +
+ {registerMutation.error ? ( +

{registerMutation.error.message}

+ ) : null} + +

+ 已有账号? + {' '} + + 返回登录 + +

+
+
+
+
+ ) +}