feat(auth): support oidc login

This commit is contained in:
dongmucat 2026-04-24 13:45:20 +08:00
parent c5c76d3bb0
commit bb9f8915e2
7 changed files with 312 additions and 11 deletions

View file

@ -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

View file

@ -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` 建立登录态,包括:

View file

@ -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 变更入口:

View file

@ -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 class SecurityConfig {
ObjectProvider<MockAuthFilter> mockAuthFilterProvider,
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
@ -112,7 +116,9 @@ public class SecurityConfig {
})
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService))
.successHandler(successHandler)
.failureHandler(failureHandler)
)

View file

@ -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<OidcUserRequest, OidcUser> {
private final OAuthLoginFlowService oauthLoginFlowService;
private final OAuth2UserService<OidcUserRequest, OidcUser> delegate;
@Autowired
public CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService) {
this(oauthLoginFlowService, new OidcUserService());
}
CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService,
OAuth2UserService<OidcUserRequest, OidcUser> 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<String, Object> 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<GrantedAuthority>(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<String, Object> 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;
}
}

View file

@ -55,6 +55,11 @@ public class OAuthLoginFlowService {
}
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 class OAuthLoginFlowService {
);
}
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
return new AuthenticatedLoginContext(upstreamUser, principal);
return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
}
public void rememberReturnTo(HttpServletRequest request) {

View file

@ -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<OidcUserRequest, OidcUser> 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<OAuthClaims> 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<String, Object> 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();
}
}