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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.release.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions docs/03-authentication-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,7 +100,7 @@ astron:

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

## 3. Web 认证流程(OAuth2 Authorization Code)
## 3. Web 认证流程(OAuth2 / OIDC Authorization Code)

```
浏览器点击"登录"
Expand All @@ -124,7 +124,7 @@ Spring Security 自动完成:
③ 触发自定义 OAuth2UserService
CustomOAuth2UserService:
CustomOAuth2UserService / CustomOidcUserService:
① 从 OAuth2User 提取 provider + externalId → 构建 OAuthClaims
② AccessPolicy.evaluate(claims) → 准入判定
Expand All @@ -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` 建立登录态,包括:
Expand Down
48 changes: 44 additions & 4 deletions docs/09-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 裸金属上线清单

推荐顺序:

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

## 9 可观测性
## 10 可观测性

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

## 10 安全扫描服务
## 11 安全扫描服务

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -62,6 +64,7 @@ public class SecurityConfig {
private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry;

public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
CustomOidcUserService customOidcUserService,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
Expand All @@ -72,6 +75,7 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
ObjectProvider<MockAuthFilter> mockAuthFilterProvider,
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
Expand Down Expand Up @@ -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)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OidcUserRequest, OidcUser> {

private static final Logger log = LoggerFactory.getLogger(CustomOidcUserService.class);

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 {
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<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"));
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Loading
Loading