diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 98be3da2..74655dc6 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feature/project-init tags: - "v*.*.*" workflow_dispatch: 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 deleted file mode 100644 index 64e0fd29..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 2a80ad34..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ChangePasswordRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 62ba3b8d..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalLoginRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 1f560ea8..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -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/resources/db/migration/V5__phase4_auth_governance.sql b/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql deleted file mode 100644 index 4e746456..00000000 --- a/server/skillhub-app/src/main/resources/db/migration/V5__phase4_auth_governance.sql +++ /dev/null @@ -1,32 +0,0 @@ -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/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java deleted file mode 100644 index b2176a0c..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ /dev/null @@ -1,124 +0,0 @@ -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 23b4114f..a8c94865 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,8 +10,6 @@ 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; @@ -68,7 +66,6 @@ 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", @@ -113,9 +110,4 @@ 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 deleted file mode 100644 index b2127c80..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/exception/AuthFlowException.java +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index ab779e74..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ /dev/null @@ -1,188 +0,0 @@ -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 deleted file mode 100644 index 790acae0..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index ffb65668..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 1afcb86f..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidator.java +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 361083da..00000000 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 1b5eb8b7..00000000 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordPolicyValidatorTest.java +++ /dev/null @@ -1,38 +0,0 @@ -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 fdd8f21a..d9448334 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,6 +4,5 @@ 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 19c3cb8a..df1e06c0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,14 +1,6 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' -import type { - ApiToken, - CreateTokenRequest, - CreateTokenResponse, - LocalLoginRequest, - LocalRegisterRequest, - OAuthProvider, - User, -} from './types' +import type { ApiToken, CreateTokenRequest, CreateTokenResponse, OAuthProvider, User } from './types' const client = createClient({ baseUrl: '' }) @@ -29,13 +21,6 @@ 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 } @@ -125,26 +110,6 @@ 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 bd12c031..b3dcdc65 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -6,15 +6,6 @@ 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 87b2b1a8..819ac1d8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -2,7 +2,6 @@ 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' @@ -34,12 +33,6 @@ const loginRoute = createRoute({ component: LoginPage, }) -const registerRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/register', - component: RegisterPage, -}) - const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/search', @@ -197,7 +190,6 @@ 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 deleted file mode 100644 index c17c5bd6..00000000 --- a/web/src/features/auth/use-local-auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 8c639e1c..e9996839 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,26 +1,6 @@ -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) { - event.preventDefault() - try { - await loginMutation.mutateAsync({ username, password }) - window.location.href = '/dashboard' - } catch { - // mutation state drives the error UI - } - } - return (
@@ -35,58 +15,7 @@ export function LoginPage() {
- - - 账号密码 - 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 deleted file mode 100644 index ec881f15..00000000 --- a/web/src/pages/register.tsx +++ /dev/null @@ -1,83 +0,0 @@ -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} - -

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

-
-
-
-
- ) -}