mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
merge: bring local auth from phase4-ops-polish
# Conflicts: # web/src/pages/login.tsx
This commit is contained in:
commit
7beda7cf54
24 changed files with 1066 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=密码至少需要包含三种字符类型
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
15
web/src/features/auth/use-local-auth.ts
Normal file
15
web/src/features/auth/use-local-auth.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,26 @@
|
|||
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 { 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-[70vh] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-8 animate-fade-up">
|
||||
|
|
@ -15,7 +35,58 @@ export function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div className="glass-strong p-8 rounded-2xl">
|
||||
<LoginButton />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
|
|
|
|||
83
web/src/pages/register.tsx
Normal file
83
web/src/pages/register.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue