feat(auth): add ISSUE-60 password capability field

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-22 12:36:47 +08:00
parent dc185861d4
commit 665ee0499a
11 changed files with 97 additions and 13 deletions

View file

@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.AuthProviderResponse;
import com.iflytek.skillhub.dto.DirectLoginRequest;
import com.iflytek.skillhub.dto.SessionBootstrapRequest;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import com.iflytek.skillhub.service.AuthMethodCatalog;
import com.iflytek.skillhub.service.DirectAuthService;
import com.iflytek.skillhub.service.SessionBootstrapService;
@ -56,6 +57,7 @@ public class AuthController extends BaseApiController {
private final UserRoleBindingRepository userRoleBindingRepository;
private final PlatformSessionService platformSessionService;
private final UserAccountRepository userAccountRepository;
private final AuthMeResponseAssembler authMeResponseAssembler;
public AuthController(ApiResponseFactory responseFactory,
AuthMethodCatalog authMethodCatalog,
@ -64,7 +66,8 @@ public class AuthController extends BaseApiController {
AuthFailureThrottleService authFailureThrottleService,
UserRoleBindingRepository userRoleBindingRepository,
PlatformSessionService platformSessionService,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
@ -73,6 +76,7 @@ public class AuthController extends BaseApiController {
this.userRoleBindingRepository = userRoleBindingRepository;
this.platformSessionService = platformSessionService;
this.userAccountRepository = userAccountRepository;
this.authMeResponseAssembler = authMeResponseAssembler;
}
/**
@ -111,7 +115,7 @@ public class AuthController extends BaseApiController {
freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
/**
@ -146,7 +150,7 @@ public class AuthController extends BaseApiController {
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
);
}
@ -178,7 +182,7 @@ public class AuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
authMeResponseAssembler.from(principal)
);
}

View file

@ -17,6 +17,7 @@ import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
@ -38,19 +39,22 @@ public class LocalAuthController extends BaseApiController {
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
private final AuthMeResponseAssembler authMeResponseAssembler;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService) {
PasswordResetService passwordResetService,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
this.authMeResponseAssembler = authMeResponseAssembler;
}
@PostMapping("/register")
@ -60,7 +64,7 @@ public class LocalAuthController extends BaseApiController {
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
skillHubMetrics.incrementUserRegister();
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.created", AuthMeResponse.from(principal));
return ok("response.success.created", authMeResponseAssembler.from(principal));
}
@PostMapping("/login")
@ -84,7 +88,7 @@ public class LocalAuthController extends BaseApiController {
authFailureThrottleService.resetIdentifier("local", request.username());
skillHubMetrics.recordLocalLogin(true);
platformSessionService.establishSession(principal, httpRequest);
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}
@PostMapping("/change-password")

View file

@ -10,15 +10,17 @@ public record AuthMeResponse(
String email,
String avatarUrl,
String oauthProvider,
boolean canChangePassword,
Set<String> platformRoles
) {
public static AuthMeResponse from(PlatformPrincipal principal) {
public static AuthMeResponse from(PlatformPrincipal principal, boolean canChangePassword) {
return new AuthMeResponse(
principal.userId(),
principal.displayName(),
principal.email() != null ? principal.email() : "",
principal.avatarUrl() != null ? principal.avatarUrl() : "",
principal.oauthProvider(),
canChangePassword,
principal.platformRoles()
);
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AuthMeResponse;
import org.springframework.stereotype.Service;
/**
* Builds the current-user API response with account capabilities derived from
* authoritative backend state.
*/
@Service
public class AuthMeResponseAssembler {
private final LocalCredentialRepository localCredentialRepository;
public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository) {
this.localCredentialRepository = localCredentialRepository;
}
public AuthMeResponse from(PlatformPrincipal principal) {
return AuthMeResponse.from(
principal,
localCredentialRepository.existsByUserId(principal.userId())
);
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
@ -64,6 +65,9 @@ class AuthControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/auth/me"))
@ -77,6 +81,7 @@ class AuthControllerTest {
given(userAccountRepository.findById("user-42"))
.willReturn(java.util.Optional.of(new UserAccount("user-42", "tester", "tester@example.com", "https://example.com/avatar.png")));
given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("user-42")).willReturn(false);
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
@ -102,6 +107,7 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.userId").value("user-42"))
.andExpect(jsonPath("$.data.displayName").value("tester"))
.andExpect(jsonPath("$.data.oauthProvider").value("github"))
.andExpect(jsonPath("$.data.canChangePassword").value(false))
.andExpect(jsonPath("$.data.platformRoles[0]").value("USER"))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
@ -115,6 +121,7 @@ class AuthControllerTest {
var user = new UserAccount("user-42", "UpdatedName", "tester@example.com", "https://example.com/avatar.png");
given(userAccountRepository.findById("user-42")).willReturn(java.util.Optional.of(user));
given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("user-42")).willReturn(true);
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
@ -134,7 +141,8 @@ class AuthControllerTest {
mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.displayName").value("UpdatedName")); // should return DB value
.andExpect(jsonPath("$.data.displayName").value("UpdatedName")) // should return DB value
.andExpect(jsonPath("$.data.canChangePassword").value(true));
}
@Test

View file

@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
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.LocalCredentialRepository;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
@ -52,6 +53,9 @@ class DirectAuthControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -67,6 +71,7 @@ class DirectAuthControllerTest {
given(userAccountRepository.findById("usr_direct_1"))
.willReturn(java.util.Optional.of(new UserAccount("usr_direct_1", "direct-user", null, null)));
given(userRoleBindingRepository.findByUserId("usr_direct_1")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("usr_direct_1")).willReturn(true);
MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login")
.with(csrf())
@ -77,6 +82,7 @@ class DirectAuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_direct_1"))
.andExpect(jsonPath("$.data.canChangePassword").value(true))
.andReturn()
.getRequest()
.getSession(false);
@ -84,7 +90,8 @@ class DirectAuthControllerTest {
mockMvc.perform(get("/api/v1/auth/me").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_direct_1"));
.andExpect(jsonPath("$.data.userId").value("usr_direct_1"))
.andExpect(jsonPath("$.data.canChangePassword").value(true));
}
@Test

View file

@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -55,6 +56,9 @@ class LocalAuthControllerTest {
@MockBean
private PasswordResetService passwordResetService;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -66,6 +70,7 @@ class LocalAuthControllerTest {
Set.of("SUPER_ADMIN")
);
given(localAuthService.login("alice", "Abcd123!")).willReturn(principal);
given(localCredentialRepository.existsByUserId("usr_1")).willReturn(true);
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
@ -76,7 +81,8 @@ class LocalAuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("usr_1"))
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
.andExpect(jsonPath("$.data.oauthProvider").value("local"))
.andExpect(jsonPath("$.data.canChangePassword").value(true));
verify(skillHubMetrics).recordLocalLogin(true);
verify(skillHubMetrics, never()).recordLocalLogin(false);
verify(authFailureThrottleService).resetIdentifier("local", "alice");
@ -93,6 +99,7 @@ class LocalAuthControllerTest {
Set.of()
);
given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal);
given(localCredentialRepository.existsByUserId("usr_2")).willReturn(true);
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
@ -102,7 +109,8 @@ class LocalAuthControllerTest {
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.displayName").value("bob"));
.andExpect(jsonPath("$.data.displayName").value("bob"))
.andExpect(jsonPath("$.data.canChangePassword").value(true));
verify(skillHubMetrics).incrementUserRegister();
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -48,12 +49,16 @@ class SessionBootstrapControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;
@MockBean
private LocalCredentialRepository localCredentialRepository;
@Test
void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception {
given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of());
given(userAccountRepository.findById("sso-user-1"))
.willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null)));
given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of());
given(localCredentialRepository.existsByUserId("sso-user-1")).willReturn(false);
MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap")
.with(csrf())
@ -65,6 +70,7 @@ class SessionBootstrapControllerTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("sso-user-1"))
.andExpect(jsonPath("$.data.displayName").value("Private SSO User"))
.andExpect(jsonPath("$.data.canChangePassword").value(false))
.andReturn()
.getRequest()
.getSession(false);
@ -73,7 +79,8 @@ class SessionBootstrapControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("sso-user-1"))
.andExpect(jsonPath("$.data.oauthProvider").value("private-sso"));
.andExpect(jsonPath("$.data.oauthProvider").value("private-sso"))
.andExpect(jsonPath("$.data.canChangePassword").value(false));
}
@Test

View file

@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
boolean existsByUserId(String userId);
}

View file

@ -230,6 +230,20 @@ class LocalAuthServiceTest {
assertThat(principal.platformRoles()).containsExactly("USER");
}
@Test
void changePassword_withoutLocalCredential_rejectsRequest() {
given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty());
assertThatThrownBy(() -> service.changePassword("oauth-only", "old", "Newpass123!"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.notEnabled")
.extracting("status")
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(passwordEncoder, never()).matches(any(), any());
verify(credentialRepository, never()).save(any(LocalCredential.class));
}
@Test
void register_rejectsInvalidEmailFormat() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);

View file

@ -3846,6 +3846,7 @@ export interface components {
email?: string;
avatarUrl?: string;
oauthProvider?: string;
canChangePassword?: boolean;
platformRoles?: string[];
};
LocalRegisterRequest: {