Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions docs/03-authentication-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,23 +485,20 @@ Session 中存储以下字段:
"code": 0,
"msg": "获取成功",
"data": {
"userId": 42,
"userId": "usr_42",
"displayName": "zhangsan",
"email": "zhangsan@company.com",
"avatarUrl": "https://...",
"oauthProvider": "github",
"platformRoles": ["SKILL_ADMIN", "AUDITOR"],
"namespaces": [
{ "slug": "ai-team", "role": "ADMIN" },
{ "slug": "global", "role": "MEMBER" }
]
"oauthProvider": "local",
"canChangePassword": true,
"platformRoles": ["SKILL_ADMIN", "AUDITOR"]
},
"timestamp": "2026-03-12T06:00:00Z",
"requestId": "req-123"
}
```

前端权限判定基于 `platformRoles` + `namespaces[].role`,后端通过 `role_permission` 表查询权限码。
前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`。后端通过 `role_permission` 表查询权限码。

统一约束:
- `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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;
Expand Down Expand Up @@ -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,
Expand All @@ -64,7 +66,8 @@ public AuthController(ApiResponseFactory responseFactory,
AuthFailureThrottleService authFailureThrottleService,
UserRoleBindingRepository userRoleBindingRepository,
PlatformSessionService platformSessionService,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
AuthMeResponseAssembler authMeResponseAssembler) {
super(responseFactory);
this.authMethodCatalog = authMethodCatalog;
this.sessionBootstrapService = sessionBootstrapService;
Expand All @@ -73,6 +76,7 @@ public AuthController(ApiResponseFactory responseFactory,
this.userRoleBindingRepository = userRoleBindingRepository;
this.platformSessionService = platformSessionService;
this.userAccountRepository = userAccountRepository;
this.authMeResponseAssembler = authMeResponseAssembler;
}

/**
Expand Down Expand Up @@ -111,7 +115,7 @@ public ApiResponse<AuthMeResponse> me(@AuthenticationPrincipal PlatformPrincipal
freshRoles);
platformSessionService.establishSession(principal, request, false);
}
return ok("response.success.read", AuthMeResponse.from(principal));
return ok("response.success.read", authMeResponseAssembler.from(principal));
}

/**
Expand Down Expand Up @@ -146,7 +150,7 @@ public ApiResponse<AuthMeResponse> bootstrapSession(@Valid @RequestBody SessionB
HttpServletRequest httpRequest) {
return ok(
"response.success.read",
AuthMeResponse.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
authMeResponseAssembler.from(sessionBootstrapService.bootstrap(request.provider(), httpRequest))
);
}

Expand Down Expand Up @@ -178,7 +182,7 @@ public ApiResponse<AuthMeResponse> directLogin(@Valid @RequestBody DirectLoginRe
authFailureThrottleService.resetIdentifier(category, request.username());
return ok(
"response.success.read",
AuthMeResponse.from(principal)
authMeResponseAssembler.from(principal)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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;
Expand All @@ -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")
Expand All @@ -60,7 +64,7 @@ public ApiResponse<AuthMeResponse> register(@Valid @RequestBody LocalRegisterReq
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")
Expand All @@ -84,7 +88,7 @@ public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest r
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"))
Expand All @@ -77,6 +81,7 @@ void meShouldReturnCurrentPrincipal() throws Exception {
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",
Expand All @@ -102,6 +107,7 @@ void meShouldReturnCurrentPrincipal() throws Exception {
.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());
Expand All @@ -115,6 +121,7 @@ void meShouldRefreshSessionWhenDisplayNameChanges() throws Exception {
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",
Expand All @@ -134,7 +141,8 @@ void meShouldRefreshSessionWhenDisplayNameChanges() throws Exception {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand Down Expand Up @@ -52,6 +53,9 @@ class DirectAuthControllerTest {
@MockBean
private UserRoleBindingRepository userRoleBindingRepository;

@MockBean
private LocalCredentialRepository localCredentialRepository;

@Test
void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
Expand All @@ -67,6 +71,7 @@ void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
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())
Expand All @@ -77,14 +82,16 @@ void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception {
.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);

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

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;
Expand Down Expand Up @@ -55,6 +56,9 @@ class LocalAuthControllerTest {
@MockBean
private PasswordResetService passwordResetService;

@MockBean
private LocalCredentialRepository localCredentialRepository;

@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
Expand All @@ -66,6 +70,7 @@ void login_returnsCurrentUserEnvelope() throws Exception {
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())
Expand All @@ -76,7 +81,8 @@ void login_returnsCurrentUserEnvelope() throws Exception {
.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");
Expand All @@ -93,6 +99,7 @@ void register_returnsCreatedEnvelope() throws Exception {
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())
Expand All @@ -102,7 +109,8 @@ void register_returnsCreatedEnvelope() throws Exception {
"""))
.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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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())
Expand All @@ -65,6 +70,7 @@ void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Ex
.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);
Expand All @@ -73,7 +79,8 @@ void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Ex
.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
Optional<LocalCredential> findByUserId(String userId);

boolean existsByUsernameIgnoreCase(String username);

boolean existsByUserId(String userId);
}
Loading
Loading