diff --git a/.env.release.example b/.env.release.example index 3f020fc75..6e003e502 100644 --- a/.env.release.example +++ b/.env.release.example @@ -63,6 +63,18 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab +# 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= 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..fe631d80f 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -197,7 +197,47 @@ 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`,请保持稳定。 + +> **警告: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` 的 +`server` 容器环境变量或统一的配置管理系统中。 + +## 9 裸金属上线清单 推荐顺序: @@ -223,7 +263,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 立即修改管理员密码 - 如果后续完全走 OAuth,可将 `BOOTSTRAP_ADMIN_ENABLED=false` -## 9 可观测性 +## 10 可观测性 | 维度 | 方案 | |------|------| @@ -231,7 +271,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 +292,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..9dc967b5d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java @@ -0,0 +1,130 @@ +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.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; +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.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; +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 static final Logger log = LoggerFactory.getLogger(CustomOidcUserService.class); + + 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 { + String registrationId = request.getClientRegistration().getRegistrationId(); + log.debug("OIDC login initiated for registration '{}'", registrationId); + + OidcUser upstreamUser = delegate.loadUser(request); + OAuthClaims claims = toOAuthClaims(request, upstreamUser); + 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) { + 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")); + 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")); + if (!emailVerified) { + email = null; + } + 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..d32fc6ae9 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java @@ -0,0 +1,237 @@ +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.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; +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.assertj.core.api.Assertions.assertThatThrownBy; +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()).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); + 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(); + } + + @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( + "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(); + } +} 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..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 @@ -36,6 +36,13 @@ void emailDomainPolicy_deniesNullEmail() { assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); } + @Test + void emailDomainPolicy_allowsUnverifiedEmailFromMatchingDomain() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", "user@company.com", false, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + @Test void providerAllowlistPolicy_allowsMatchingProvider() { var policy = new ProviderAllowlistAccessPolicy(Set.of("github"));