mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
feat(auth): add ISSUE-60 password capability field
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
dc185861d4
commit
665ee0499a
11 changed files with 97 additions and 13 deletions
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
|
|||
Optional<LocalCredential> findByUserId(String userId);
|
||||
|
||||
boolean existsByUsernameIgnoreCase(String username);
|
||||
|
||||
boolean existsByUserId(String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
1
web/src/api/generated/schema.d.ts
vendored
1
web/src/api/generated/schema.d.ts
vendored
|
|
@ -3846,6 +3846,7 @@ export interface components {
|
|||
email?: string;
|
||||
avatarUrl?: string;
|
||||
oauthProvider?: string;
|
||||
canChangePassword?: boolean;
|
||||
platformRoles?: string[];
|
||||
};
|
||||
LocalRegisterRequest: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue