diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index 1552f6f2..4ba7b27b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -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) ); } 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 index 8442939d..17e54fbe 100644 --- 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 @@ -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") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java index 470b2fdb..d5447884 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AuthMeResponse.java @@ -10,15 +10,17 @@ public record AuthMeResponse( String email, String avatarUrl, String oauthProvider, + boolean canChangePassword, Set 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() ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java new file mode 100644 index 00000000..60b0066b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMeResponseAssembler.java @@ -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()) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index a25d3104..d79d368a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index 8878e286..97db51d4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -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 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 index 440fe4bf..2425acde 100644 --- 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 @@ -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(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index b842efbc..36fd80bf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -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 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 index 8346b9c2..a80d44ac 100644 --- 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 @@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository findByUserId(String userId); boolean existsByUsernameIgnoreCase(String username); + + boolean existsByUserId(String userId); } 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 index 6b9f4344..b6eaf5af 100644 --- 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 @@ -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); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a141fb20..68d7ceb9 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3846,6 +3846,7 @@ export interface components { email?: string; avatarUrl?: string; oauthProvider?: string; + canChangePassword?: boolean; platformRoles?: string[]; }; LocalRegisterRequest: {