From bb9f8915e24b2eae9e824ca9ec55ff234d3e8bd5 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 24 Apr 2026 13:45:20 +0800 Subject: [PATCH 1/4] feat(auth): support oidc login --- .env.release.example | 3 + docs/03-authentication-design.md | 23 ++- docs/09-deployment.md | 37 ++++- .../skillhub/auth/config/SecurityConfig.java | 8 +- .../auth/oauth/CustomOidcUserService.java | 103 +++++++++++++ .../auth/oauth/OAuthLoginFlowService.java | 8 +- .../auth/oauth/CustomOidcUserServiceTest.java | 141 ++++++++++++++++++ 7 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java diff --git a/.env.release.example b/.env.release.example index 3f020fc75..ffd02231c 100644 --- a/.env.release.example +++ b/.env.release.example @@ -63,6 +63,9 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab +# Optional: OIDC providers are configured with Spring Security client registration variables. +# See docs/09-deployment.md for the full OIDC environment variable set and Compose override notes. + # SMTP configuration for password reset verification emails. SPRING_MAIL_HOST= SPRING_MAIL_PORT=587 diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index a97a69fe9..db7c8b22a 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -14,8 +14,8 @@ │ ▼ ┌─────────────────────────────┐ -│ Layer 1: OAuth2 Login │ Spring Security OAuth2 Client -│ (一期 GitHub,可扩展) │ 授权码模式 (Authorization Code) +│ Layer 1: OAuth2/OIDC Login │ Spring Security OAuth2 Client +│ (GitHub/GitLab/OIDC 可扩展) │ 授权码模式 (Authorization Code) │ Layer 1b: Session Bootstrap│ 显式被动会话引导(默认关闭) └─────────────┬───────────────┘ │ OAuth2User @@ -100,7 +100,7 @@ astron: 后续新增 OAuth Provider(Google、GitLab、微信)时,准入策略与 Provider 无关,统一在 AccessPolicy 层判定,不需要重做入驻逻辑。 -## 3. Web 认证流程(OAuth2 Authorization Code) +## 3. Web 认证流程(OAuth2 / OIDC Authorization Code) ``` 浏览器点击"登录" @@ -124,7 +124,7 @@ Spring Security 自动完成: ③ 触发自定义 OAuth2UserService │ ▼ -CustomOAuth2UserService: +CustomOAuth2UserService / CustomOidcUserService: ① 从 OAuth2User 提取 provider + externalId → 构建 OAuthClaims ② AccessPolicy.evaluate(claims) → 准入判定 │ @@ -142,6 +142,21 @@ AuthenticationSuccessHandler: ② 重定向到前端页面 (可配置的 redirect_uri) ``` +OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserService` +分支处理。`CustomOidcUserService` 会把标准 OIDC claims 映射为 +`OAuthClaims`: + +- `provider`:Spring OAuth2 client registration id,例如 `okta`、`keycloak` + 或 `oidc` +- `subject`:OIDC `sub` +- `email` / `emailVerified`:`email` 与 `email_verified` +- `providerLogin`:优先 `preferred_username`,其次 `name`、`email`、`sub` +- `picture` 会同步为 `avatar_url`,供现有头像同步逻辑复用 + +因此 OIDC 不需要新增数据库表;现有 `identity_binding(provider_code, +subject)` 可以保存任意 OIDC issuer 下的稳定用户标识。不同 IdP 应使用不同 +registration id,避免多个 issuer 的 `sub` 值空间混用。 + ### 3.1 统一 Session 建立约束 所有 Web 登录入口都必须通过统一的 `PlatformSessionService` 建立登录态,包括: diff --git a/docs/09-deployment.md b/docs/09-deployment.md index a8da42048..42c1520eb 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -197,7 +197,36 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` -## 8 裸金属上线清单 +## 8 OIDC 登录配置 + +SkillHub 复用 Spring Security OAuth2 Client 的 OIDC 支持。前端不需要单独 +配置回调页;登录页会从 `/api/v1/auth/methods` 读取后端暴露的 +`OAUTH_REDIRECT` 方法并跳转到 `/oauth2/authorization/{registrationId}`。 + +生产环境接入 OIDC 时,为后端增加一组 OAuth2 client registration 配置即可。 +下面以 `oidc` 作为 registration id: + +```bash +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID=replace-me +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET=replace-me +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_PROVIDER=oidc +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_AUTHORIZATION_GRANT_TYPE=authorization_code +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_REDIRECT_URI={baseUrl}/login/oauth2/code/{registrationId} +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC +SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=https://idp.example.com/realms/skillhub +``` + +要接入多个 OIDC IdP,使用不同 registration id,例如 `okta`、`keycloak`, +并把上面的环境变量中的 `OIDC` 替换为对应大写 id。registration id 会作为 +`identity_binding.provider_code`,请保持稳定。 + +Docker Compose 发布模板默认只透传常用变量。若使用 OIDC,请通过 compose +override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `server` +容器。Kubernetes 部署同理,将这些变量放入 `backend-deployment.yaml` 的 +`server` 容器环境变量或统一的配置管理系统中。 + +## 9 裸金属上线清单 推荐顺序: @@ -223,7 +252,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 立即修改管理员密码 - 如果后续完全走 OAuth,可将 `BOOTSTRAP_ADMIN_ENABLED=false` -## 9 可观测性 +## 10 可观测性 | 维度 | 方案 | |------|------| @@ -231,7 +260,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d | 日志 | 容器 stdout / stderr | | 指标 | Spring Boot Actuator,后续可接 Prometheus | -## 10 安全扫描服务 +## 11 安全扫描服务 如果要启用 `skill-scanner` 后端链路,当前仓库建议按下面的方式部署: @@ -252,7 +281,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - `scripts/verify-scanner.sh` - `docs/security-scanning.md` -## 11 数据迁移 +## 12 数据迁移 Flyway 仍是唯一 schema 变更入口: 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 276826ec0..085255ea0 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 @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.config; import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; +import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -51,6 +52,7 @@ public class SecurityConfig { "form-action 'self'"); private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOidcUserService customOidcUserService; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -62,6 +64,7 @@ public class SecurityConfig { private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + CustomOidcUserService customOidcUserService, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -72,6 +75,7 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, ObjectProvider mockAuthFilterProvider, RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.customOAuth2UserService = customOAuth2UserService; + this.customOidcUserService = customOidcUserService; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -112,7 +116,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) - .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + .oidcUserService(customOidcUserService)) .successHandler(successHandler) .failureHandler(failureHandler) ) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java new file mode 100644 index 000000000..eee9be060 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +/** + * Spring Security OIDC user-service bridge that normalizes standard OIDC + * claims and reuses the existing OAuth login policy and identity binding flow. + */ +@Service +public class CustomOidcUserService implements OAuth2UserService { + + private final OAuthLoginFlowService oauthLoginFlowService; + private final OAuth2UserService delegate; + + @Autowired + public CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService) { + this(oauthLoginFlowService, new OidcUserService()); + } + + CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService, + OAuth2UserService delegate) { + this.oauthLoginFlowService = oauthLoginFlowService; + this.delegate = delegate; + } + + @Override + public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException { + OidcUser upstreamUser = delegate.loadUser(request); + OAuthClaims claims = toOAuthClaims(request, upstreamUser); + PlatformPrincipal principal = oauthLoginFlowService.authenticate(claims); + + Map userInfoClaims = new HashMap<>(upstreamUser.getClaims()); + if (upstreamUser.getUserInfo() != null) { + userInfoClaims.putAll(upstreamUser.getUserInfo().getClaims()); + } + userInfoClaims.put("platformPrincipal", principal); + userInfoClaims.put("providerLogin", principal.userId()); + + var authorities = new LinkedHashSet(upstreamUser.getAuthorities()); + principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .forEach(authorities::add); + + return new DefaultOidcUser( + authorities, + upstreamUser.getIdToken(), + new OidcUserInfo(userInfoClaims), + "providerLogin" + ); + } + + static OAuthClaims toOAuthClaims(OidcUserRequest request, OidcUser oidcUser) { + Map claims = new HashMap<>(oidcUser.getClaims()); + String subject = asString(claims.get("sub")); + String email = asString(claims.get("email")); + boolean emailVerified = Boolean.TRUE.equals(claims.get("email_verified")); + String providerLogin = firstPresent( + asString(claims.get("preferred_username")), + asString(claims.get("name")), + email, + subject + ); + if (claims.get("picture") != null && claims.get("avatar_url") == null) { + claims.put("avatar_url", claims.get("picture")); + } + + return new OAuthClaims( + request.getClientRegistration().getRegistrationId(), + subject, + email, + emailVerified, + providerLogin, + claims + ); + } + + private static String firstPresent(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static String asString(Object value) { + return value instanceof String str ? str : null; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index 343649ccd..e5c7dc3de 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -55,6 +55,11 @@ public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { } OAuthClaims claims = extractor.extract(request, upstreamUser); + PlatformPrincipal principal = authenticate(claims); + return new AuthenticatedLoginContext(upstreamUser, principal); + } + + public PlatformPrincipal authenticate(OAuthClaims claims) { AccessDecision decision = accessPolicy.evaluate(claims); if (decision == AccessDecision.PENDING_APPROVAL) { @@ -67,8 +72,7 @@ public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { ); } - PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE); - return new AuthenticatedLoginContext(upstreamUser, principal); + return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE); } public void rememberReturnTo(HttpServletRequest request) { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java new file mode 100644 index 000000000..dfdf10e6c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java @@ -0,0 +1,141 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CustomOidcUserServiceTest { + + @Test + void loadUser_mapsOidcClaimsThroughExistingLoginFlow() { + OAuthLoginFlowService loginFlowService = mock(OAuthLoginFlowService.class); + OAuth2UserService delegate = mock(); + CustomOidcUserService service = new CustomOidcUserService(loginFlowService, delegate); + OidcUserRequest request = oidcRequest(); + OidcUser upstreamUser = oidcUser(Map.of( + IdTokenClaimNames.SUB, "oidc-sub-1", + "email", "user@example.com", + "email_verified", true, + "preferred_username", "preferred-user", + "name", "Display User", + "picture", "https://idp.example/avatar.png" + )); + PlatformPrincipal platformPrincipal = new PlatformPrincipal( + "usr_1", + "Preferred User", + "user@example.com", + "https://idp.example/avatar.png", + "okta", + Set.of("USER", "SUPER_ADMIN") + ); + when(delegate.loadUser(request)).thenReturn(upstreamUser); + when(loginFlowService.authenticate(any())).thenReturn(platformPrincipal); + + OidcUser loadedUser = service.loadUser(request); + + ArgumentCaptor claimsCaptor = ArgumentCaptor.forClass(OAuthClaims.class); + verify(loginFlowService).authenticate(claimsCaptor.capture()); + OAuthClaims claims = claimsCaptor.getValue(); + assertThat(claims.provider()).isEqualTo("okta"); + assertThat(claims.subject()).isEqualTo("oidc-sub-1"); + assertThat(claims.email()).isEqualTo("user@example.com"); + assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.providerLogin()).isEqualTo("preferred-user"); + assertThat(claims.extra()).containsEntry("avatar_url", "https://idp.example/avatar.png"); + + assertThat((Object) loadedUser.getAttribute("platformPrincipal")).isEqualTo(platformPrincipal); + assertThat((Object) loadedUser.getAttribute("providerLogin")).isEqualTo("usr_1"); + assertThat(loadedUser.getName()).isEqualTo("usr_1"); + assertThat(loadedUser.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .contains("ROLE_USER", "ROLE_SUPER_ADMIN"); + } + + @Test + void toOAuthClaims_fallsBackToNameWhenPreferredUsernameIsMissing() { + OAuthClaims claims = CustomOidcUserService.toOAuthClaims( + oidcRequest(), + oidcUser(Map.of( + IdTokenClaimNames.SUB, "subject-2", + "email", "fallback@example.com", + "email_verified", false, + "name", "Fallback Name" + )) + ); + + assertThat(claims.provider()).isEqualTo("okta"); + assertThat(claims.subject()).isEqualTo("subject-2"); + assertThat(claims.email()).isEqualTo("fallback@example.com"); + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("Fallback Name"); + } + + private static OidcUserRequest oidcRequest() { + Instant issuedAt = Instant.parse("2026-04-24T00:00:00Z"); + OidcIdToken idToken = new OidcIdToken( + "id-token", + issuedAt, + issuedAt.plusSeconds(300), + Map.of(IdTokenClaimNames.SUB, "request-sub") + ); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "access-token", + issuedAt, + issuedAt.plusSeconds(300) + ); + return new OidcUserRequest(clientRegistration(), accessToken, idToken); + } + + private static OidcUser oidcUser(Map claims) { + Instant issuedAt = Instant.parse("2026-04-24T00:00:00Z"); + OidcIdToken idToken = new OidcIdToken( + "id-token", + issuedAt, + issuedAt.plusSeconds(300), + claims + ); + return new DefaultOidcUser( + List.of(new SimpleGrantedAuthority("OIDC_USER")), + idToken, + new OidcUserInfo(claims) + ); + } + + private static ClientRegistration clientRegistration() { + return ClientRegistration.withRegistrationId("okta") + .clientId("client") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("https://skillhub.example/login/oauth2/code/okta") + .authorizationUri("https://idp.example/oauth2/v1/authorize") + .tokenUri("https://idp.example/oauth2/v1/token") + .jwkSetUri("https://idp.example/oauth2/v1/keys") + .userInfoUri("https://idp.example/oauth2/v1/userinfo") + .userNameAttributeName(IdTokenClaimNames.SUB) + .scope("openid", "profile", "email") + .build(); + } +} From 4f44ea39761628f1b1fe2823dbdbe42fbdd1ced5 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Apr 2026 13:49:26 +0800 Subject: [PATCH 2/4] fix(auth): add OIDC sub claim validation and complete env example - Add null/blank validation for OIDC sub claim in CustomOidcUserService - Throw OAuth2AuthenticationException when sub is missing or blank - Complete .env.release.example with all required OIDC environment variables - Add test cases for sub validation and providerLogin fallback scenarios - All 5 tests passing --- .env.release.example | 13 ++++++-- .../auth/oauth/CustomOidcUserService.java | 5 +++ .../auth/oauth/CustomOidcUserServiceTest.java | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.env.release.example b/.env.release.example index ffd02231c..6e003e502 100644 --- a/.env.release.example +++ b/.env.release.example @@ -63,8 +63,17 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab -# Optional: OIDC providers are configured with Spring Security client registration variables. -# See docs/09-deployment.md for the full OIDC environment variable set and Compose override notes. +# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD). +# Replace "OIDC" in variable names with your registration id (uppercase). +# The registration id becomes identity_binding.provider_code — keep it stable. +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID= +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET= +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_PROVIDER=oidc +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_AUTHORIZATION_GRANT_TYPE=authorization_code +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_REDIRECT_URI={baseUrl}/login/oauth2/code/{registrationId} +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email +SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC +SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI= # SMTP configuration for password reset verification emails. SPRING_MAIL_HOST= diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java index eee9be060..6d8d09a40 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java @@ -11,6 +11,7 @@ import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -66,6 +67,10 @@ public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationExc static OAuthClaims toOAuthClaims(OidcUserRequest request, OidcUser oidcUser) { Map claims = new HashMap<>(oidcUser.getClaims()); String subject = asString(claims.get("sub")); + if (subject == null || subject.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_sub", "OIDC sub claim is required", null)); + } String email = asString(claims.get("email")); boolean emailVerified = Boolean.TRUE.equals(claims.get("email_verified")); String providerLogin = firstPresent( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java index dfdf10e6c..1688ec671 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java @@ -14,6 +14,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; @@ -21,6 +22,7 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; 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.Mockito.mock; import static org.mockito.Mockito.verify; @@ -92,6 +94,37 @@ void toOAuthClaims_fallsBackToNameWhenPreferredUsernameIsMissing() { assertThat(claims.providerLogin()).isEqualTo("Fallback Name"); } + @Test + void toOAuthClaims_throwsWhenSubIsMissing() { + OidcUser user = mock(OidcUser.class); + when(user.getClaims()).thenReturn(Map.of("email", "no-sub@example.com")); + assertThatThrownBy(() -> CustomOidcUserService.toOAuthClaims(oidcRequest(), user)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("sub"); + } + + @Test + void toOAuthClaims_throwsWhenSubIsBlank() { + OidcUser user = mock(OidcUser.class); + when(user.getClaims()).thenReturn(Map.of(IdTokenClaimNames.SUB, " ")); + assertThatThrownBy(() -> CustomOidcUserService.toOAuthClaims(oidcRequest(), user)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("sub"); + } + + @Test + void toOAuthClaims_fallsBackToSubWhenAllOtherFieldsMissing() { + OAuthClaims claims = CustomOidcUserService.toOAuthClaims( + oidcRequest(), + oidcUser(Map.of(IdTokenClaimNames.SUB, "only-sub")) + ); + + assertThat(claims.subject()).isEqualTo("only-sub"); + assertThat(claims.providerLogin()).isEqualTo("only-sub"); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + } + private static OidcUserRequest oidcRequest() { Instant issuedAt = Instant.parse("2026-04-24T00:00:00Z"); OidcIdToken idToken = new OidcIdToken( From fbbd20a5a6dd929293a3a3ba322fe277b6ed5ed8 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 28 Apr 2026 16:31:52 +0800 Subject: [PATCH 3/4] fix(auth): require verified email for domain access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 变更摘要: - 修复 EMAIL_DOMAIN 准入策略,未验证邮箱不再因域名匹配被放行 - 新增回归测试,覆盖 OIDC 场景下 email_verified=false 的拒绝行为 - 保持修复范围收敛,仅调整策略判定与对应测试 关键文件: - server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java - server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java --- .../skillhub/auth/policy/EmailDomainAccessPolicy.java | 2 +- .../com/iflytek/skillhub/auth/policy/AccessPolicyTest.java | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java index d688f2a2e..b65a738d5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java @@ -15,7 +15,7 @@ public EmailDomainAccessPolicy(Set allowedDomains) { @Override public AccessDecision evaluate(OAuthClaims claims) { - if (claims.email() == null) return AccessDecision.DENY; + if (claims.email() == null || !claims.emailVerified()) return AccessDecision.DENY; String domain = claims.email().substring(claims.email().indexOf('@') + 1); return allowedDomains.contains(domain.toLowerCase()) ? AccessDecision.ALLOW : AccessDecision.DENY; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java index df7f1c52d..65f125286 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java @@ -36,6 +36,13 @@ void emailDomainPolicy_deniesNullEmail() { assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); } + @Test + void emailDomainPolicy_deniesUnverifiedEmail() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("oidc", "123", "user@company.com", false, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + @Test void providerAllowlistPolicy_allowsMatchingProvider() { var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); From d945c46785700f06cae15a33f35f0e5e36f51fdc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 29 Apr 2026 10:36:34 +0800 Subject: [PATCH 4/4] fix(auth): move OIDC email verification to service layer, add logging Revert emailVerified check in EmailDomainAccessPolicy to preserve backward compatibility with GitHub/GitLab OAuth users. Instead, null unverified emails in CustomOidcUserService.toOAuthClaims() so EmailDomainAccessPolicy naturally denies them via null email. Add SLF4J logging to CustomOidcUserService for OIDC authentication flow tracing and failure diagnostics. Add registration ID collision warning to deployment docs. --- docs/09-deployment.md | 11 ++++ .../auth/oauth/CustomOidcUserService.java | 24 ++++++- .../auth/policy/EmailDomainAccessPolicy.java | 2 +- .../auth/oauth/CustomOidcUserServiceTest.java | 65 ++++++++++++++++++- .../auth/policy/AccessPolicyTest.java | 6 +- 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 42c1520eb..fe631d80f 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -221,6 +221,17 @@ SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=https://idp.example.com/r 并把上面的环境变量中的 `OIDC` 替换为对应大写 id。registration id 会作为 `identity_binding.provider_code`,请保持稳定。 +> **警告:Registration ID 冲突** +> +> 每个 OIDC 提供商必须使用唯一的 registration ID。Registration ID 作为 +> `identity_binding.provider_code` 存储在数据库中,用于将外部身份映射到平台 +> 用户。如果两个不同的 IdP 使用了相同的 registration ID(例如都使用 `oidc`), +> 会导致不同 IdP 的用户 `sub` 值空间混用,可能出现身份绑定错误或账户冲突。 +> +> 建议使用有意义的 registration ID,例如 `okta`、`keycloak`、`azure-ad`, +> 而不是通用的 `oidc`。一旦投入使用,不要更改 registration ID,否则现有用户 +> 将无法登录。 + Docker Compose 发布模板默认只透传常用变量。若使用 OIDC,请通过 compose override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `server` 容器。Kubernetes 部署同理,将这些变量放入 `backend-deployment.yaml` 的 diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java index 6d8d09a40..9dc967b5d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java @@ -4,6 +4,8 @@ import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -24,6 +26,8 @@ @Service public class CustomOidcUserService implements OAuth2UserService { + private static final Logger log = LoggerFactory.getLogger(CustomOidcUserService.class); + private final OAuthLoginFlowService oauthLoginFlowService; private final OAuth2UserService delegate; @@ -40,9 +44,24 @@ public CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService) { @Override public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException { + String registrationId = request.getClientRegistration().getRegistrationId(); + log.debug("OIDC login initiated for registration '{}'", registrationId); + OidcUser upstreamUser = delegate.loadUser(request); OAuthClaims claims = toOAuthClaims(request, upstreamUser); - PlatformPrincipal principal = oauthLoginFlowService.authenticate(claims); + log.debug("OIDC claims extracted - provider: {}, subject: {}, email present: {}, emailVerified: {}", + claims.provider(), claims.subject(), claims.email() != null, claims.emailVerified()); + + PlatformPrincipal principal; + try { + principal = oauthLoginFlowService.authenticate(claims); + } catch (OAuth2AuthenticationException e) { + log.warn("OIDC authentication failed for registration '{}', subject '{}': {}", + registrationId, claims.subject(), e.getMessage(), e); + throw e; + } + log.debug("OIDC authentication succeeded - userId: {}, roles: {}", + principal.userId(), principal.platformRoles()); Map userInfoClaims = new HashMap<>(upstreamUser.getClaims()); if (upstreamUser.getUserInfo() != null) { @@ -73,6 +92,9 @@ static OAuthClaims toOAuthClaims(OidcUserRequest request, OidcUser oidcUser) { } String email = asString(claims.get("email")); boolean emailVerified = Boolean.TRUE.equals(claims.get("email_verified")); + if (!emailVerified) { + email = null; + } String providerLogin = firstPresent( asString(claims.get("preferred_username")), asString(claims.get("name")), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java index b65a738d5..d688f2a2e 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java @@ -15,7 +15,7 @@ public EmailDomainAccessPolicy(Set allowedDomains) { @Override public AccessDecision evaluate(OAuthClaims claims) { - if (claims.email() == null || !claims.emailVerified()) return AccessDecision.DENY; + if (claims.email() == null) return AccessDecision.DENY; String domain = claims.email().substring(claims.email().indexOf('@') + 1); return allowedDomains.contains(domain.toLowerCase()) ? AccessDecision.ALLOW : AccessDecision.DENY; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java index 1688ec671..d32fc6ae9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java @@ -89,11 +89,46 @@ void toOAuthClaims_fallsBackToNameWhenPreferredUsernameIsMissing() { assertThat(claims.provider()).isEqualTo("okta"); assertThat(claims.subject()).isEqualTo("subject-2"); - assertThat(claims.email()).isEqualTo("fallback@example.com"); + assertThat(claims.email()).isNull(); assertThat(claims.emailVerified()).isFalse(); assertThat(claims.providerLogin()).isEqualTo("Fallback Name"); } + @Test + void toOAuthClaims_nullsEmailWhenNotVerified() { + OAuthClaims claims = CustomOidcUserService.toOAuthClaims( + oidcRequest(), + oidcUser(Map.of( + IdTokenClaimNames.SUB, "subject-3", + "email", "unverified@example.com", + "email_verified", false, + "preferred_username", "unverified-user" + )) + ); + + assertThat(claims.provider()).isEqualTo("okta"); + assertThat(claims.subject()).isEqualTo("subject-3"); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("unverified-user"); + } + + @Test + void toOAuthClaims_nullsEmailWhenEmailVerifiedClaimIsAbsent() { + OAuthClaims claims = CustomOidcUserService.toOAuthClaims( + oidcRequest(), + oidcUser(Map.of( + IdTokenClaimNames.SUB, "subject-absent-verified", + "email", "maybe@example.com", + "preferred_username", "maybe-user" + )) + ); + + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("maybe-user"); + } + @Test void toOAuthClaims_throwsWhenSubIsMissing() { OidcUser user = mock(OidcUser.class); @@ -125,6 +160,34 @@ void toOAuthClaims_fallsBackToSubWhenAllOtherFieldsMissing() { assertThat(claims.emailVerified()).isFalse(); } + @Test + void loadUser_deniedWhenOidcEmailNotVerifiedAndPolicyChecksEmail() { + OAuthLoginFlowService loginFlowService = mock(OAuthLoginFlowService.class); + OAuth2UserService delegate = mock(); + CustomOidcUserService service = new CustomOidcUserService(loginFlowService, delegate); + OidcUserRequest request = oidcRequest(); + OidcUser upstreamUser = oidcUser(Map.of( + IdTokenClaimNames.SUB, "oidc-sub-unverified", + "email", "user@company.com", + "email_verified", false, + "preferred_username", "unverified-user" + )); + when(delegate.loadUser(request)).thenReturn(upstreamUser); + + ArgumentCaptor claimsCaptor = ArgumentCaptor.forClass(OAuthClaims.class); + when(loginFlowService.authenticate(claimsCaptor.capture())) + .thenThrow(new OAuth2AuthenticationException( + new org.springframework.security.oauth2.core.OAuth2Error("access_denied"))); + + assertThatThrownBy(() -> service.loadUser(request)) + .isInstanceOf(OAuth2AuthenticationException.class); + + OAuthClaims captured = claimsCaptor.getValue(); + assertThat(captured.email()).isNull(); + assertThat(captured.emailVerified()).isFalse(); + assertThat(captured.subject()).isEqualTo("oidc-sub-unverified"); + } + private static OidcUserRequest oidcRequest() { Instant issuedAt = Instant.parse("2026-04-24T00:00:00Z"); OidcIdToken idToken = new OidcIdToken( diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java index 65f125286..62a40abf5 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java @@ -37,10 +37,10 @@ void emailDomainPolicy_deniesNullEmail() { } @Test - void emailDomainPolicy_deniesUnverifiedEmail() { + void emailDomainPolicy_allowsUnverifiedEmailFromMatchingDomain() { var policy = new EmailDomainAccessPolicy(Set.of("company.com")); - var claims = new OAuthClaims("oidc", "123", "user@company.com", false, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + var claims = new OAuthClaims("github", "123", "user@company.com", false, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); } @Test