Skip to content

Commit f70c1c6

Browse files
authored
Merge pull request #348 from iflytek/feature/oidc-login
feat(auth): support OIDC login
2 parents f41723e + d945c46 commit f70c1c6

8 files changed

Lines changed: 462 additions & 11 deletions

File tree

.env.release.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ OAUTH2_GITLAB_CLIENT_SECRET=
6565
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
6666
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
6767

68+
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
69+
# Replace "OIDC" in variable names with your registration id (uppercase).
70+
# The registration id becomes identity_binding.provider_code — keep it stable.
71+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID=
72+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET=
73+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_PROVIDER=oidc
74+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_AUTHORIZATION_GRANT_TYPE=authorization_code
75+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_REDIRECT_URI={baseUrl}/login/oauth2/code/{registrationId}
76+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
77+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
78+
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
79+
6880
# SMTP configuration for password reset verification emails.
6981
SPRING_MAIL_HOST=
7082
SPRING_MAIL_PORT=587

docs/03-authentication-design.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
1515
1616
┌─────────────────────────────┐
17-
│ Layer 1: OAuth2 Login │ Spring Security OAuth2 Client
18-
│ (一期 GitHub可扩展) │ 授权码模式 (Authorization Code)
17+
│ Layer 1: OAuth2/OIDC Login │ Spring Security OAuth2 Client
18+
│ (GitHub/GitLab/OIDC 可扩展) │ 授权码模式 (Authorization Code)
1919
│ Layer 1b: Session Bootstrap│ 显式被动会话引导(默认关闭)
2020
└─────────────┬───────────────┘
2121
│ OAuth2User
@@ -100,7 +100,7 @@ astron:
100100

101101
后续新增 OAuth Provider(Google、GitLab、微信)时,准入策略与 Provider 无关,统一在 AccessPolicy 层判定,不需要重做入驻逻辑。
102102

103-
## 3. Web 认证流程(OAuth2 Authorization Code)
103+
## 3. Web 认证流程(OAuth2 / OIDC Authorization Code)
104104

105105
```
106106
浏览器点击"登录"
@@ -124,7 +124,7 @@ Spring Security 自动完成:
124124
③ 触发自定义 OAuth2UserService
125125
126126
127-
CustomOAuth2UserService:
127+
CustomOAuth2UserService / CustomOidcUserService:
128128
① 从 OAuth2User 提取 provider + externalId → 构建 OAuthClaims
129129
② AccessPolicy.evaluate(claims) → 准入判定
130130
@@ -142,6 +142,21 @@ AuthenticationSuccessHandler:
142142
② 重定向到前端页面 (可配置的 redirect_uri)
143143
```
144144
145+
OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserService`
146+
分支处理。`CustomOidcUserService` 会把标准 OIDC claims 映射为
147+
`OAuthClaims`:
148+
149+
- `provider`:Spring OAuth2 client registration id,例如 `okta`、`keycloak`
150+
或 `oidc`
151+
- `subject`:OIDC `sub`
152+
- `email` / `emailVerified`:`email` 与 `email_verified`
153+
- `providerLogin`:优先 `preferred_username`,其次 `name`、`email`、`sub`
154+
- `picture` 会同步为 `avatar_url`,供现有头像同步逻辑复用
155+
156+
因此 OIDC 不需要新增数据库表;现有 `identity_binding(provider_code,
157+
subject)` 可以保存任意 OIDC issuer 下的稳定用户标识。不同 IdP 应使用不同
158+
registration id,避免多个 issuer 的 `sub` 值空间混用。
159+
145160
### 3.1 统一 Session 建立约束
146161
147162
所有 Web 登录入口都必须通过统一的 `PlatformSessionService` 建立登录态,包括:

docs/09-deployment.md

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,47 @@ docker compose --env-file .env.release -f compose.release.yml up -d
197197
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
198198
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`
199199

200-
## 8 裸金属上线清单
200+
## 8 OIDC 登录配置
201+
202+
SkillHub 复用 Spring Security OAuth2 Client 的 OIDC 支持。前端不需要单独
203+
配置回调页;登录页会从 `/api/v1/auth/methods` 读取后端暴露的
204+
`OAUTH_REDIRECT` 方法并跳转到 `/oauth2/authorization/{registrationId}`
205+
206+
生产环境接入 OIDC 时,为后端增加一组 OAuth2 client registration 配置即可。
207+
下面以 `oidc` 作为 registration id:
208+
209+
```bash
210+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID=replace-me
211+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET=replace-me
212+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_PROVIDER=oidc
213+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_AUTHORIZATION_GRANT_TYPE=authorization_code
214+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_REDIRECT_URI={baseUrl}/login/oauth2/code/{registrationId}
215+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
216+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
217+
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=https://idp.example.com/realms/skillhub
218+
```
219+
220+
要接入多个 OIDC IdP,使用不同 registration id,例如 `okta``keycloak`
221+
并把上面的环境变量中的 `OIDC` 替换为对应大写 id。registration id 会作为
222+
`identity_binding.provider_code`,请保持稳定。
223+
224+
> **警告:Registration ID 冲突**
225+
>
226+
> 每个 OIDC 提供商必须使用唯一的 registration ID。Registration ID 作为
227+
> `identity_binding.provider_code` 存储在数据库中,用于将外部身份映射到平台
228+
> 用户。如果两个不同的 IdP 使用了相同的 registration ID(例如都使用 `oidc`),
229+
> 会导致不同 IdP 的用户 `sub` 值空间混用,可能出现身份绑定错误或账户冲突。
230+
>
231+
> 建议使用有意义的 registration ID,例如 `okta``keycloak``azure-ad`
232+
> 而不是通用的 `oidc`。一旦投入使用,不要更改 registration ID,否则现有用户
233+
> 将无法登录。
234+
235+
Docker Compose 发布模板默认只透传常用变量。若使用 OIDC,请通过 compose
236+
override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `server`
237+
容器。Kubernetes 部署同理,将这些变量放入 `backend-deployment.yaml`
238+
`server` 容器环境变量或统一的配置管理系统中。
239+
240+
## 9 裸金属上线清单
201241

202242
推荐顺序:
203243

@@ -223,15 +263,15 @@ docker compose --env-file .env.release -f compose.release.yml up -d
223263
- 立即修改管理员密码
224264
- 如果后续完全走 OAuth,可将 `BOOTSTRAP_ADMIN_ENABLED=false`
225265

226-
## 9 可观测性
266+
## 10 可观测性
227267

228268
| 维度 | 方案 |
229269
|------|------|
230270
| 健康检查 | `web/nginx-health``server/actuator/health` |
231271
| 日志 | 容器 stdout / stderr |
232272
| 指标 | Spring Boot Actuator,后续可接 Prometheus |
233273

234-
## 10 安全扫描服务
274+
## 11 安全扫描服务
235275

236276
如果要启用 `skill-scanner` 后端链路,当前仓库建议按下面的方式部署:
237277

@@ -252,7 +292,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
252292
- `scripts/verify-scanner.sh`
253293
- `docs/security-scanning.md`
254294

255-
## 11 数据迁移
295+
## 12 数据迁移
256296

257297
Flyway 仍是唯一 schema 变更入口:
258298

server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.iflytek.skillhub.auth.config;
22

33
import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
4+
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
45
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
56
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
67
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
@@ -51,6 +52,7 @@ public class SecurityConfig {
5152
"form-action 'self'");
5253

5354
private final CustomOAuth2UserService customOAuth2UserService;
55+
private final CustomOidcUserService customOidcUserService;
5456
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
5557
private final OAuth2LoginSuccessHandler successHandler;
5658
private final OAuth2LoginFailureHandler failureHandler;
@@ -62,6 +64,7 @@ public class SecurityConfig {
6264
private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry;
6365

6466
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
67+
CustomOidcUserService customOidcUserService,
6568
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
6669
OAuth2LoginSuccessHandler successHandler,
6770
OAuth2LoginFailureHandler failureHandler,
@@ -72,6 +75,7 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
7275
ObjectProvider<MockAuthFilter> mockAuthFilterProvider,
7376
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
7477
this.customOAuth2UserService = customOAuth2UserService;
78+
this.customOidcUserService = customOidcUserService;
7579
this.authorizationRequestResolver = authorizationRequestResolver;
7680
this.successHandler = successHandler;
7781
this.failureHandler = failureHandler;
@@ -112,7 +116,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
112116
})
113117
.oauth2Login(oauth2 -> oauth2
114118
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
115-
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
119+
.userInfoEndpoint(userInfo -> userInfo
120+
.userService(customOAuth2UserService)
121+
.oidcUserService(customOidcUserService))
116122
.successHandler(successHandler)
117123
.failureHandler(failureHandler)
118124
)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.iflytek.skillhub.auth.oauth;
2+
3+
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
4+
import java.util.HashMap;
5+
import java.util.LinkedHashSet;
6+
import java.util.Map;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
import org.springframework.security.core.GrantedAuthority;
11+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
12+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
13+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
14+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
15+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
16+
import org.springframework.security.oauth2.core.OAuth2Error;
17+
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
18+
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
19+
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
20+
import org.springframework.stereotype.Service;
21+
22+
/**
23+
* Spring Security OIDC user-service bridge that normalizes standard OIDC
24+
* claims and reuses the existing OAuth login policy and identity binding flow.
25+
*/
26+
@Service
27+
public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
28+
29+
private static final Logger log = LoggerFactory.getLogger(CustomOidcUserService.class);
30+
31+
private final OAuthLoginFlowService oauthLoginFlowService;
32+
private final OAuth2UserService<OidcUserRequest, OidcUser> delegate;
33+
34+
@Autowired
35+
public CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService) {
36+
this(oauthLoginFlowService, new OidcUserService());
37+
}
38+
39+
CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService,
40+
OAuth2UserService<OidcUserRequest, OidcUser> delegate) {
41+
this.oauthLoginFlowService = oauthLoginFlowService;
42+
this.delegate = delegate;
43+
}
44+
45+
@Override
46+
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
47+
String registrationId = request.getClientRegistration().getRegistrationId();
48+
log.debug("OIDC login initiated for registration '{}'", registrationId);
49+
50+
OidcUser upstreamUser = delegate.loadUser(request);
51+
OAuthClaims claims = toOAuthClaims(request, upstreamUser);
52+
log.debug("OIDC claims extracted - provider: {}, subject: {}, email present: {}, emailVerified: {}",
53+
claims.provider(), claims.subject(), claims.email() != null, claims.emailVerified());
54+
55+
PlatformPrincipal principal;
56+
try {
57+
principal = oauthLoginFlowService.authenticate(claims);
58+
} catch (OAuth2AuthenticationException e) {
59+
log.warn("OIDC authentication failed for registration '{}', subject '{}': {}",
60+
registrationId, claims.subject(), e.getMessage(), e);
61+
throw e;
62+
}
63+
log.debug("OIDC authentication succeeded - userId: {}, roles: {}",
64+
principal.userId(), principal.platformRoles());
65+
66+
Map<String, Object> userInfoClaims = new HashMap<>(upstreamUser.getClaims());
67+
if (upstreamUser.getUserInfo() != null) {
68+
userInfoClaims.putAll(upstreamUser.getUserInfo().getClaims());
69+
}
70+
userInfoClaims.put("platformPrincipal", principal);
71+
userInfoClaims.put("providerLogin", principal.userId());
72+
73+
var authorities = new LinkedHashSet<GrantedAuthority>(upstreamUser.getAuthorities());
74+
principal.platformRoles().stream()
75+
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
76+
.forEach(authorities::add);
77+
78+
return new DefaultOidcUser(
79+
authorities,
80+
upstreamUser.getIdToken(),
81+
new OidcUserInfo(userInfoClaims),
82+
"providerLogin"
83+
);
84+
}
85+
86+
static OAuthClaims toOAuthClaims(OidcUserRequest request, OidcUser oidcUser) {
87+
Map<String, Object> claims = new HashMap<>(oidcUser.getClaims());
88+
String subject = asString(claims.get("sub"));
89+
if (subject == null || subject.isBlank()) {
90+
throw new OAuth2AuthenticationException(
91+
new OAuth2Error("missing_sub", "OIDC sub claim is required", null));
92+
}
93+
String email = asString(claims.get("email"));
94+
boolean emailVerified = Boolean.TRUE.equals(claims.get("email_verified"));
95+
if (!emailVerified) {
96+
email = null;
97+
}
98+
String providerLogin = firstPresent(
99+
asString(claims.get("preferred_username")),
100+
asString(claims.get("name")),
101+
email,
102+
subject
103+
);
104+
if (claims.get("picture") != null && claims.get("avatar_url") == null) {
105+
claims.put("avatar_url", claims.get("picture"));
106+
}
107+
108+
return new OAuthClaims(
109+
request.getClientRegistration().getRegistrationId(),
110+
subject,
111+
email,
112+
emailVerified,
113+
providerLogin,
114+
claims
115+
);
116+
}
117+
118+
private static String firstPresent(String... values) {
119+
for (String value : values) {
120+
if (value != null && !value.isBlank()) {
121+
return value;
122+
}
123+
}
124+
return null;
125+
}
126+
127+
private static String asString(Object value) {
128+
return value instanceof String str ? str : null;
129+
}
130+
}

server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
5555
}
5656

5757
OAuthClaims claims = extractor.extract(request, upstreamUser);
58+
PlatformPrincipal principal = authenticate(claims);
59+
return new AuthenticatedLoginContext(upstreamUser, principal);
60+
}
61+
62+
public PlatformPrincipal authenticate(OAuthClaims claims) {
5863
AccessDecision decision = accessPolicy.evaluate(claims);
5964

6065
if (decision == AccessDecision.PENDING_APPROVAL) {
@@ -67,8 +72,7 @@ public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
6772
);
6873
}
6974

70-
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
71-
return new AuthenticatedLoginContext(upstreamUser, principal);
75+
return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
7276
}
7377

7478
public void rememberReturnTo(HttpServletRequest request) {

0 commit comments

Comments
 (0)