# 第八章:OpenID Connect 身份认证
> "OAuth 2.0 告诉你'这个人授权了',OIDC 告诉你'这个人是谁'。"
```{mermaid}
mindmap
root((OpenID Connect))
核心概念
ID Token
UserInfo
Claims
流程
Authorization Code
Implicit
Hybrid
发现
Discovery
JWKS
提供商
Keycloak
Auth0
Okta
```
## 8.1 OIDC 与 OAuth 2.0 的关系
OIDC(OpenID Connect)是建立在 OAuth 2.0 之上的**身份认证层**:
```{mermaid}
block-beta
columns 1
block:oidc["OpenID Connect\n(身份认证 — 你是谁?)\nID Token · UserInfo · Claims"]
end
block:oauth["OAuth 2.0\n(授权 — 你能做什么?)\nAccess Token · Scope · Grant"]
end
block:http["HTTP / TLS"]
end
style oidc fill:#e0f0ff,stroke:#3388cc
style oauth fill:#fff3e0,stroke:#cc8833
style http fill:#f0f0f0,stroke:#999999
```
| 维度 | OAuth 2.0 | OIDC |
|------|-----------|------|
| 目的 | 授权(访问资源) | 认证(确认身份) |
| 核心 Token | Access Token | ID Token |
| 用户信息 | 不标准 | UserInfo Endpoint |
| 发现机制 | 无 | .well-known/openid-configuration |
| Scope | 自定义 | openid, profile, email |
## 8.2 ID Token
ID Token 是一个 JWT,包含用户身份信息:
```json
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "my-client-app",
"exp": 1709510400,
"iat": 1709506800,
"nonce": "abc123",
"name": "Walter Fan",
"email": "walter@example.com",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}
```
### 标准 Claims
| Claim | 说明 | 必须 |
|-------|------|------|
| iss | 签发者 | ✅ |
| sub | 用户唯一标识 | ✅ |
| aud | 受众(Client ID) | ✅ |
| exp | 过期时间 | ✅ |
| iat | 签发时间 | ✅ |
| nonce | 防重放攻击 | 条件必须 |
| name | 用户全名 | ❌ |
| email | 邮箱 | ❌ |
| picture | 头像 URL | ❌ |
### ID Token 验证流程
客户端收到 ID Token 后,**必须**按以下步骤验证,任何一步失败都应拒绝该 Token:
```{mermaid}
flowchart TD
A["收到 ID Token(JWT)"] --> B{"解码 JWT Header\n获取 kid 和 alg"}
B --> C{"从 JWKS 端点\n获取对应公钥"}
C --> D{"验证签名\n(RS256 / ES256)"}
D -->|失败| REJECT["❌ 拒绝 Token"]
D -->|成功| E{"检查 iss\n是否匹配预期签发者"}
E -->|不匹配| REJECT
E -->|匹配| F{"检查 aud\n是否包含本客户端 Client ID"}
F -->|不包含| REJECT
F -->|包含| G{"检查 exp\n是否已过期"}
G -->|已过期| REJECT
G -->|未过期| H{"检查 iat\n签发时间是否合理"}
H -->|异常| REJECT
H -->|合理| I{"检查 nonce\n是否匹配会话中存储的值"}
I -->|不匹配| REJECT
I -->|匹配| J["✅ Token 有效\n提取用户 Claims"]
style REJECT fill:#ffcccc,stroke:#cc3333
style J fill:#ccffcc,stroke:#33cc33
```
**验证要点说明:**
1. **签名验证**:从 `jwks_uri` 获取公钥集合,根据 JWT Header 中的 `kid` 选择对应公钥,验证签名完整性
2. **iss 检查**:必须与 OIDC Discovery 中的 `issuer` 完全一致(包括尾部斜杠)
3. **aud 检查**:`aud` 可以是字符串或数组,必须包含本客户端的 `client_id`;如果 `aud` 包含多个值,还应检查 `azp`(Authorized Party)
4. **exp 检查**:当前时间不得超过 `exp`,建议允许几秒的时钟偏差(clock skew)
5. **nonce 检查**:仅在认证请求中发送了 `nonce` 时必须检查,用于防止重放攻击
## 8.3 OIDC 授权码流程
```{mermaid}
sequenceDiagram
autonumber
participant U as 用户(浏览器)
participant C as Client(Web App)
participant IdP as OIDC Provider(IdP)
U->>C: 访问受保护资源
C->>U: 302 重定向到 IdP
authorize?response_type=code
&client_id=...&scope=openid profile email
&redirect_uri=...&state=...&nonce=...
U->>IdP: 跟随重定向,打开登录页
IdP->>U: 显示登录 + 授权同意页面
U->>IdP: 提交用户名/密码,同意授权
IdP->>U: 302 重定向回 Client
callback?code=AUTH_CODE&state=...
U->>C: 跟随重定向,携带 code 和 state
Note over C: 验证 state 参数防 CSRF
C->>IdP: POST /token
grant_type=authorization_code
&code=AUTH_CODE
&client_id=...&client_secret=...
IdP->>C: 返回 id_token + access_token + refresh_token
Note over C: 验证 id_token(签名、iss、aud、exp、nonce)
C->>IdP: GET /userinfo
Authorization: Bearer access_token
IdP->>C: 返回用户详细信息(name, email, picture...)
C->>U: 登录成功,建立会话
```
**关键安全参数:**
- **state**:随机值,绑定到用户会话,防止 CSRF 攻击
- **nonce**:随机值,嵌入 ID Token,防止重放攻击
- **PKCE(code_verifier / code_challenge)**:推荐用于所有客户端,防止授权码拦截攻击
## 8.4 OIDC Discovery
每个 OIDC 提供商都有一个发现端点(Discovery Endpoint),客户端可以通过它自动获取所有必要的配置信息:
```
GET https://auth.example.com/.well-known/openid-configuration
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/oauth/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"scopes_supported": ["openid", "profile", "email"],
"response_types_supported": ["code", "id_token", "token id_token"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"],
"subject_types_supported": ["public", "pairwise"]
}
```
### Discovery 完整流程
```{mermaid}
sequenceDiagram
autonumber
participant C as Client
participant D as Discovery Endpoint
participant J as JWKS Endpoint
C->>D: GET /.well-known/openid-configuration
D->>C: 返回 JSON 配置文档
Note over C: 解析并缓存配置:
authorization_endpoint
token_endpoint
userinfo_endpoint
jwks_uri
C->>J: GET /jwks.json(从 jwks_uri)
J->>C: 返回公钥集合(JWK Set)
Note over C: 缓存公钥,用于验证 ID Token 签名
定期刷新(建议缓存 24h)
```
**Discovery 文档关键字段说明:**
| 字段 | 说明 |
|------|------|
| `issuer` | OIDC 提供商的唯一标识,必须与 ID Token 的 `iss` 一致 |
| `authorization_endpoint` | 用户授权端点,浏览器重定向目标 |
| `token_endpoint` | 用授权码换取 Token 的端点 |
| `userinfo_endpoint` | 用 Access Token 获取用户信息的端点 |
| `jwks_uri` | JSON Web Key Set 端点,包含验证签名的公钥 |
| `scopes_supported` | 支持的 scope 列表 |
| `response_types_supported` | 支持的 response_type(code, id_token 等) |
| `id_token_signing_alg_values_supported` | ID Token 签名算法(RS256, ES256 等) |
| `token_endpoint_auth_methods_supported` | Token 端点支持的认证方式 |
| `claims_supported` | 支持的 Claims 列表 |
## 8.5 OIDC 提供商对比
| 特性 | Keycloak | Auth0 | Okta | Google |
|------|----------|-------|------|--------|
| 类型 | 开源自托管 | SaaS | SaaS | SaaS |
| 协议 | OIDC/SAML/OAuth2 | OIDC/SAML | OIDC/SAML | OIDC |
| 定价 | 免费 | 免费层+付费 | 付费 | 免费 |
| 自定义 | 高 | 中 | 中 | 低 |
| 企业功能 | 完整 | 完整 | 完整 | 有限 |
| 部署 | 自托管/K8s | 云 | 云 | 云 |
### OIDC 常见配置对比
以下表格对比了四大 OIDC 提供商在实际接入时的配置差异,帮助开发者快速上手:
| 配置项 | Keycloak | Auth0 | Okta | Google |
|--------|----------|-------|------|--------|
| **Discovery URL** | `https://{host}/realms/{realm}/.well-known/openid-configuration` | `https://{tenant}.auth0.com/.well-known/openid-configuration` | `https://{domain}.okta.com/.well-known/openid-configuration` | `https://accounts.google.com/.well-known/openid-configuration` |
| **Issuer 格式** | `https://{host}/realms/{realm}` | `https://{tenant}.auth0.com/` | `https://{domain}.okta.com` | `https://accounts.google.com` |
| **Client 注册** | Admin Console → Clients | Dashboard → Applications | Admin Console → Applications | Google Cloud Console → Credentials |
| **Client Secret 位置** | Credentials Tab | Settings → Client Secret | General → Client Credentials | OAuth 2.0 Client IDs → Download JSON |
| **默认签名算法** | RS256 | RS256 | RS256 | RS256 |
| **PKCE 支持** | ✅ 默认支持 | ✅ 默认支持 | ✅ 默认支持 | ✅ 默认支持 |
| **自定义 Claims** | Protocol Mappers | Rules / Actions | Authorization Server Claims | 不支持自定义 |
| **Logout Endpoint** | `/realms/{realm}/protocol/openid-connect/logout` | `/v2/logout` | `/oauth2/v1/logout` | 无标准 RP-Initiated Logout |
| **Token 有效期配置** | Realm Settings → Tokens | API → Token Settings | Authorization Server → Access Policies | 固定(1h access, 不可调) |
| **多租户支持** | 通过 Realm 隔离 | 每个 Tenant 独立 | 通过 Authorization Server | 单一 Google 账号体系 |
| **用户联邦/社交登录** | Identity Providers 配置 | Connections | Identity Providers | 仅 Google 账号 |
| **免费额度** | 无限(自托管) | 7,500 MAU | 15,000 MAU(Developer) | 无限(仅 Google 账号) |
**接入建议:**
- **内部系统 / 私有化部署**:首选 **Keycloak**,完全可控,功能最全
- **SaaS 产品快速上线**:首选 **Auth0**,开发体验最好,文档完善
- **企业级 / 合规要求高**:首选 **Okta**,SOC2/ISO27001 认证齐全
- **仅需 Google 账号登录**:直接用 **Google OIDC**,零成本
## 8.6 Python OIDC 客户端
### 基础 OIDC 登录(Authlib + FastAPI)
```python
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="session-secret")
oauth = OAuth()
oauth.register(
name='keycloak',
server_metadata_url='https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
client_id='my-app',
client_secret='my-secret',
client_kwargs={'scope': 'openid profile email'},
)
@app.get('/login')
async def login(request: Request):
redirect_uri = request.url_for('auth_callback')
return await oauth.keycloak.authorize_redirect(request, redirect_uri)
@app.get('/callback')
async def auth_callback(request: Request):
token = await oauth.keycloak.authorize_access_token(request)
id_token = token.get('id_token')
userinfo = token.get('userinfo')
# 用户已认证
return {
"sub": userinfo["sub"],
"name": userinfo.get("name"),
"email": userinfo.get("email"),
}
```
### OIDC Discovery 自动配置 + ID Token 验证 + UserInfo 调用
```python
"""
完整的 OIDC 客户端实现:
- 自动从 Discovery 端点获取配置
- ID Token 验证(含 nonce 检查)
- UserInfo 端点调用
- 完善的错误处理
依赖安装:pip install authlib httpx fastapi uvicorn itsdangerous
"""
import secrets
import time
import logging
from typing import Optional
from authlib.integrations.starlette_client import OAuth, OAuthError
from authlib.jose import jwt, JsonWebKey
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
import httpx
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ============================================================
# 配置
# ============================================================
OIDC_ISSUER = "https://auth.example.com/realms/myrealm"
OIDC_DISCOVERY_URL = f"{OIDC_ISSUER}/.well-known/openid-configuration"
CLIENT_ID = "my-app"
CLIENT_SECRET = "my-secret"
REDIRECT_URI = "http://localhost:8000/callback"
# 允许的时钟偏差(秒),用于 exp/iat 验证
CLOCK_SKEW = 5
app = FastAPI(title="OIDC Client Demo")
app.add_middleware(
SessionMiddleware,
secret_key="change-this-to-a-random-secret-in-production",
)
# ============================================================
# 1. OIDC Discovery 自动配置
# ============================================================
# Authlib 的 OAuth 客户端会自动从 server_metadata_url 获取
# 所有 OIDC 端点配置(authorization_endpoint, token_endpoint,
# jwks_uri, userinfo_endpoint 等),无需手动指定。
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=OIDC_DISCOVERY_URL,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
client_kwargs={
"scope": "openid profile email",
# 指定 token 端点的认证方式
"token_endpoint_auth_method": "client_secret_post",
},
)
# ============================================================
# 2. 手动 ID Token 验证(演示底层原理)
# ============================================================
# 缓存 JWKS 公钥集合
_jwks_cache: Optional[dict] = None
_jwks_cache_time: float = 0
JWKS_CACHE_TTL = 3600 # 缓存 1 小时
async def fetch_jwks(jwks_uri: str) -> dict:
"""从 JWKS 端点获取公钥集合,带缓存。"""
global _jwks_cache, _jwks_cache_time
if _jwks_cache and (time.time() - _jwks_cache_time < JWKS_CACHE_TTL):
return _jwks_cache
async with httpx.AsyncClient() as client:
resp = await client.get(jwks_uri)
resp.raise_for_status()
_jwks_cache = resp.json()
_jwks_cache_time = time.time()
logger.info("已刷新 JWKS 公钥缓存,共 %d 个密钥", len(_jwks_cache.get("keys", [])))
return _jwks_cache
def verify_id_token(
id_token_str: str,
jwks: dict,
issuer: str,
client_id: str,
nonce: Optional[str] = None,
) -> dict:
"""
手动验证 ID Token 的完整流程。
验证步骤:
1. 解码 JWT 并验证签名(使用 JWKS 中的公钥)
2. 检查 iss(签发者)
3. 检查 aud(受众)
4. 检查 exp(过期时间)
5. 检查 iat(签发时间)
6. 检查 nonce(防重放)
Args:
id_token_str: 原始 JWT 字符串
jwks: JWKS 公钥集合
issuer: 预期的签发者
client_id: 本客户端的 Client ID
nonce: 认证请求中发送的 nonce 值
Returns:
解码后的 Claims 字典
Raises:
HTTPException: 验证失败时抛出
"""
try:
# 步骤 1:验证签名并解码
claims = jwt.decode(
id_token_str,
JsonWebKey.import_key_set(jwks),
)
now = time.time()
# 步骤 2:检查 iss
if claims.get("iss") != issuer:
raise ValueError(
f"iss 不匹配: 期望 '{issuer}', 实际 '{claims.get('iss')}'"
)
# 步骤 3:检查 aud
aud = claims.get("aud")
if isinstance(aud, str):
if aud != client_id:
raise ValueError(f"aud 不匹配: 期望 '{client_id}', 实际 '{aud}'")
elif isinstance(aud, list):
if client_id not in aud:
raise ValueError(f"aud 列表中不包含 '{client_id}'")
# 多受众时检查 azp
azp = claims.get("azp")
if azp and azp != client_id:
raise ValueError(f"azp 不匹配: 期望 '{client_id}', 实际 '{azp}'")
else:
raise ValueError("aud 字段缺失或格式错误")
# 步骤 4:检查 exp
exp = claims.get("exp")
if not exp or now > exp + CLOCK_SKEW:
raise ValueError("ID Token 已过期")
# 步骤 5:检查 iat
iat = claims.get("iat")
if not iat or iat > now + CLOCK_SKEW:
raise ValueError("iat 签发时间异常(在未来)")
# 步骤 6:检查 nonce
if nonce is not None:
if claims.get("nonce") != nonce:
raise ValueError("nonce 不匹配,可能存在重放攻击")
logger.info("ID Token 验证通过,sub=%s", claims.get("sub"))
return dict(claims)
except ValueError as e:
logger.warning("ID Token 验证失败: %s", e)
raise HTTPException(status_code=401, detail=f"ID Token 验证失败: {e}")
except Exception as e:
logger.error("ID Token 解码异常: %s", e)
raise HTTPException(status_code=401, detail=f"ID Token 解码失败: {e}")
# ============================================================
# 3. 路由
# ============================================================
@app.get("/login")
async def login(request: Request):
"""发起 OIDC 登录,生成 nonce 并存入会话。"""
# 生成 nonce 并存入 session
nonce = secrets.token_urlsafe(32)
request.session["oidc_nonce"] = nonce
redirect_uri = str(request.url_for("auth_callback"))
return await oauth.oidc.authorize_redirect(
request,
redirect_uri,
nonce=nonce,
)
@app.get("/callback")
async def auth_callback(request: Request):
"""
OIDC 回调处理:
1. 用授权码换取 Token
2. 验证 ID Token(含 nonce)
3. 调用 UserInfo 端点获取详细信息
"""
try:
# 用授权码换取 Token(Authlib 自动完成)
token = await oauth.oidc.authorize_access_token(request)
except OAuthError as e:
logger.error("Token 交换失败: %s", e)
raise HTTPException(status_code=400, detail=f"认证失败: {e.description}")
# --- ID Token 验证 ---
id_token_str = token.get("id_token")
if not id_token_str:
raise HTTPException(status_code=400, detail="响应中缺少 id_token")
# 获取 OIDC 配置和 JWKS
metadata = await oauth.oidc.load_server_metadata()
jwks = await fetch_jwks(metadata["jwks_uri"])
# 从 session 取出 nonce
expected_nonce = request.session.pop("oidc_nonce", None)
# 手动验证 ID Token(演示完整流程)
claims = verify_id_token(
id_token_str=id_token_str,
jwks=jwks,
issuer=metadata["issuer"],
client_id=CLIENT_ID,
nonce=expected_nonce,
)
# --- 调用 UserInfo 端点 ---
access_token = token.get("access_token")
userinfo = None
if access_token and "userinfo_endpoint" in metadata:
async with httpx.AsyncClient() as client:
resp = await client.get(
metadata["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code == 200:
userinfo = resp.json()
logger.info("UserInfo 获取成功: sub=%s", userinfo.get("sub"))
else:
logger.warning("UserInfo 请求失败: %d", resp.status_code)
# 建立应用会话
request.session["user"] = {
"sub": claims["sub"],
"name": claims.get("name") or (userinfo or {}).get("name"),
"email": claims.get("email") or (userinfo or {}).get("email"),
}
return {
"message": "登录成功",
"id_token_claims": claims,
"userinfo": userinfo,
}
@app.get("/me")
async def get_current_user(request: Request):
"""获取当前登录用户信息。"""
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401, detail="未登录")
return user
@app.get("/logout")
async def logout(request: Request):
"""登出,清除会话。"""
request.session.clear()
return RedirectResponse(url="/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
```
## 8.7 Java OIDC 客户端(Spring Security)
Spring Security 提供了对 OIDC 的一流支持,通过 `spring-boot-starter-oauth2-client` 可以快速集成。
### application.yml 配置
```yaml
# Spring Boot OIDC 配置
# Spring Security 会自动从 issuer-uri 获取 Discovery 文档
spring:
security:
oauth2:
client:
registration:
# 注册名称,可自定义(用于 URL 路径:/oauth2/authorization/keycloak)
keycloak:
client-id: my-app
client-secret: my-secret
scope: openid, profile, email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
# Spring 会自动访问 {issuer-uri}/.well-known/openid-configuration
issuer-uri: https://auth.example.com/realms/myrealm
# 可选:指定从 ID Token 或 UserInfo 中提取用户名的字段
user-name-attribute: preferred_username
# 日志配置(调试时开启)
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: TRACE
```
### SecurityConfig — OIDC 登录配置
```java
package com.example.oidcdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.SecurityFilterChain;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Spring Security OIDC 登录配置。
*
* 功能:
* - 配置 OIDC 登录流程
* - 自定义用户信息提取(从 ID Token 中提取角色)
* - 配置登录/登出行为
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 访问控制
.authorizeHttpRequests(auth -> auth
// 公开端点
.requestMatchers("/", "/health", "/error").permitAll()
// 管理端点需要 ADMIN 角色
.requestMatchers("/admin/**").hasRole("ADMIN")
// 其他所有请求需要认证
.anyRequest().authenticated()
)
// OIDC 登录配置
.oauth2Login(oauth2 -> oauth2
// 自定义登录页面(可选,默认使用 Spring 生成的页面)
.loginPage("/oauth2/authorization/keycloak")
// 自定义用户信息服务,用于从 ID Token 提取角色
.userInfoEndpoint(userInfo -> userInfo
.oidcUserService(oidcUserService())
)
// 登录成功后跳转
.defaultSuccessUrl("/dashboard", true)
)
// 登出配置
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
);
return http.build();
}
/**
* 自定义 OIDC 用户服务:从 ID Token 的 realm_access.roles 中提取角色。
* 适用于 Keycloak 等在 ID Token 中嵌入角色信息的提供商。
*/
@Bean
public OAuth2UserService oidcUserService() {
final OidcUserService delegate = new OidcUserService();
return userRequest -> {
// 先调用默认的 OIDC 用户服务(会调用 UserInfo 端点)
OidcUser oidcUser = delegate.loadUser(userRequest);
// 从 ID Token 中提取角色(Keycloak 格式)
Set authorities = new HashSet<>(oidcUser.getAuthorities());
Map realmAccess = oidcUser.getIdToken().getClaim("realm_access");
if (realmAccess != null) {
@SuppressWarnings("unchecked")
List roles = (List) realmAccess.get("roles");
if (roles != null) {
roles.forEach(role ->
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
);
}
}
// 返回包含角色信息的用户对象
return new DefaultOidcUser(
authorities,
oidcUser.getIdToken(),
oidcUser.getUserInfo(),
"preferred_username" // 用作 principal name 的 claim
);
};
}
}
```
### UserController — ID Token 解析和用户信息提取
```java
package com.example.oidcdemo.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 用户信息控制器:演示如何从 OIDC 认证中提取用户信息。
*/
@RestController
public class UserController {
/**
* 获取当前登录用户的完整信息。
* Spring Security 自动将 OIDC 用户注入 @AuthenticationPrincipal。
*/
@GetMapping("/me")
public Map getCurrentUser(@AuthenticationPrincipal OidcUser oidcUser) {
Map userInfo = new HashMap<>();
// --- 从 ID Token 提取标准 Claims ---
userInfo.put("sub", oidcUser.getSubject()); // 用户唯一标识
userInfo.put("name", oidcUser.getFullName()); // 全名
userInfo.put("email", oidcUser.getEmail()); // 邮箱
userInfo.put("email_verified", oidcUser.getEmailVerified()); // 邮箱是否验证
userInfo.put("preferred_username", oidcUser.getPreferredUsername()); // 用户名
// --- ID Token 元数据 ---
userInfo.put("issuer", oidcUser.getIssuer().toString()); // 签发者
userInfo.put("issued_at", oidcUser.getIssuedAt()); // 签发时间
userInfo.put("expires_at", oidcUser.getExpiresAt()); // 过期时间
userInfo.put("nonce", oidcUser.getNonce()); // nonce 值
// --- 从 UserInfo 端点获取的额外信息 ---
if (oidcUser.getUserInfo() != null) {
userInfo.put("userinfo_claims", oidcUser.getUserInfo().getClaims());
}
// --- 用户角色 ---
userInfo.put("authorities", oidcUser.getAuthorities().stream()
.map(Object::toString)
.toList());
return userInfo;
}
/**
* 获取原始 ID Token(JWT 字符串)。
* 可用于传递给下游微服务。
*/
@GetMapping("/token")
public Map getTokenInfo(@AuthenticationPrincipal OidcUser oidcUser) {
Map tokenInfo = new HashMap<>();
tokenInfo.put("id_token", oidcUser.getIdToken().getTokenValue());
tokenInfo.put("claims", oidcUser.getIdToken().getClaims());
return tokenInfo;
}
@GetMapping("/dashboard")
public Map dashboard(@AuthenticationPrincipal OidcUser oidcUser) {
return Map.of(
"message", "欢迎, " + oidcUser.getFullName() + "!",
"email", oidcUser.getEmail() != null ? oidcUser.getEmail() : "未提供"
);
}
}
```
## 8.8 Go OIDC 客户端
使用 `github.com/coreos/go-oidc/v3` 库实现完整的 OIDC 登录流程。
```go
/*
完整的 Go OIDC 客户端实现:
- OIDC Provider Discovery(自动获取配置)
- Authorization Code Flow(含 state 和 nonce)
- ID Token 验证(签名、iss、aud、exp、nonce)
- UserInfo 端点调用
依赖安装:
go get github.com/coreos/go-oidc/v3/oidc
go get golang.org/x/oauth2
*/
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// ============================================================
// 配置
// ============================================================
// Config 保存 OIDC 客户端配置
type Config struct {
IssuerURL string // OIDC Provider 的 Issuer URL
ClientID string // 客户端 ID
ClientSecret string // 客户端密钥
RedirectURL string // 回调 URL
ListenAddr string // 监听地址
}
func loadConfig() Config {
return Config{
IssuerURL: getEnv("OIDC_ISSUER_URL", "https://auth.example.com/realms/myrealm"),
ClientID: getEnv("OIDC_CLIENT_ID", "my-app"),
ClientSecret: getEnv("OIDC_CLIENT_SECRET", "my-secret"),
RedirectURL: getEnv("OIDC_REDIRECT_URL", "http://localhost:8080/callback"),
ListenAddr: getEnv("LISTEN_ADDR", ":8080"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ============================================================
// 会话管理(简化版,生产环境应使用 Redis 等)
// ============================================================
// SessionStore 简单的内存会话存储
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
// Session 保存用户会话数据
type Session struct {
State string // OAuth2 state 参数(防 CSRF)
Nonce string // OIDC nonce 参数(防重放)
User *UserInfo // 登录后的用户信息
CreatedAt time.Time
}
// UserInfo 用户信息
type UserInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
PreferredUsername string `json:"preferred_username"`
Picture string `json:"picture"`
}
func NewSessionStore() *SessionStore {
return &SessionStore{sessions: make(map[string]*Session)}
}
func (s *SessionStore) Set(id string, session *Session) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[id] = session
}
func (s *SessionStore) Get(id string) (*Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[id]
return sess, ok
}
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
// generateRandomString 生成加密安全的随机字符串
func generateRandomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("生成随机字符串失败: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
// ============================================================
// OIDC 客户端
// ============================================================
// OIDCClient 封装 OIDC 客户端逻辑
type OIDCClient struct {
provider *oidc.Provider
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
sessions *SessionStore
}
func NewOIDCClient(ctx context.Context, cfg Config) (*OIDCClient, error) {
// -------------------------------------------------------
// 1. OIDC Provider Discovery
// 自动从 {issuerURL}/.well-known/openid-configuration
// 获取所有端点配置(authorization, token, userinfo, jwks)
// -------------------------------------------------------
log.Printf("正在发现 OIDC Provider: %s", cfg.IssuerURL)
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("OIDC Discovery 失败: %w", err)
}
log.Printf("OIDC Discovery 成功,Endpoint: %s", provider.Endpoint().AuthURL)
// -------------------------------------------------------
// 2. 配置 OAuth2 客户端
// -------------------------------------------------------
oauth2Config := oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(), // 从 Discovery 自动获取
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
// -------------------------------------------------------
// 3. 配置 ID Token 验证器
// 自动从 jwks_uri 获取公钥,验证签名、iss、aud、exp
// -------------------------------------------------------
verifier := provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
// 可选:设置允许的时钟偏差
// Now: func() time.Time { return time.Now() },
})
return &OIDCClient{
provider: provider,
oauth2Config: oauth2Config,
verifier: verifier,
sessions: NewSessionStore(),
}, nil
}
// HandleLogin 处理登录请求,重定向到 OIDC Provider
func (c *OIDCClient) HandleLogin(w http.ResponseWriter, r *http.Request) {
// 生成 state(防 CSRF)和 nonce(防重放)
state, err := generateRandomString(32)
if err != nil {
http.Error(w, "内部错误", http.StatusInternalServerError)
return
}
nonce, err := generateRandomString(32)
if err != nil {
http.Error(w, "内部错误", http.StatusInternalServerError)
return
}
// 将 state 和 nonce 存入会话
sessionID, _ := generateRandomString(16)
c.sessions.Set(sessionID, &Session{
State: state,
Nonce: nonce,
CreatedAt: time.Now(),
})
// 设置会话 Cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: 300, // 5 分钟内完成登录
})
// 重定向到 OIDC Provider,附带 nonce 参数
authURL := c.oauth2Config.AuthCodeURL(
state,
oidc.Nonce(nonce),
)
log.Printf("重定向到 OIDC Provider: %s", authURL)
http.Redirect(w, r, authURL, http.StatusFound)
}
// HandleCallback 处理 OIDC 回调
func (c *OIDCClient) HandleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// --- 获取会话 ---
cookie, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "会话不存在", http.StatusBadRequest)
return
}
session, ok := c.sessions.Get(cookie.Value)
if !ok {
http.Error(w, "会话已过期", http.StatusBadRequest)
return
}
// --- 验证 state 参数(防 CSRF) ---
if r.URL.Query().Get("state") != session.State {
http.Error(w, "state 不匹配,可能存在 CSRF 攻击", http.StatusBadRequest)
return
}
// --- 检查错误响应 ---
if errCode := r.URL.Query().Get("error"); errCode != "" {
errDesc := r.URL.Query().Get("error_description")
http.Error(w, fmt.Sprintf("认证失败: %s - %s", errCode, errDesc), http.StatusBadRequest)
return
}
// --- 用授权码换取 Token ---
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "缺少授权码", http.StatusBadRequest)
return
}
oauth2Token, err := c.oauth2Config.Exchange(ctx, code)
if err != nil {
log.Printf("Token 交换失败: %v", err)
http.Error(w, "Token 交换失败", http.StatusInternalServerError)
return
}
// --- 提取并验证 ID Token ---
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
http.Error(w, "响应中缺少 id_token", http.StatusBadRequest)
return
}
// verifier.Verify 会自动完成:
// 1. 从 JWKS 获取公钥并验证签名
// 2. 检查 iss 是否匹配 Provider
// 3. 检查 aud 是否包含 ClientID
// 4. 检查 exp 是否过期
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
log.Printf("ID Token 验证失败: %v", err)
http.Error(w, fmt.Sprintf("ID Token 验证失败: %v", err), http.StatusUnauthorized)
return
}
// --- 验证 nonce(防重放攻击) ---
if idToken.Nonce != session.Nonce {
http.Error(w, "nonce 不匹配,可能存在重放攻击", http.StatusUnauthorized)
return
}
// --- 提取用户 Claims ---
var claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
PreferredUsername string `json:"preferred_username"`
Picture string `json:"picture"`
}
if err := idToken.Claims(&claims); err != nil {
log.Printf("Claims 解析失败: %v", err)
http.Error(w, "Claims 解析失败", http.StatusInternalServerError)
return
}
// --- 调用 UserInfo 端点获取更多信息 ---
userInfoResp, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
if err != nil {
log.Printf("UserInfo 请求失败(非致命): %v", err)
// UserInfo 失败不阻断登录,ID Token 中已有基本信息
} else {
// 用 UserInfo 补充 Claims 中缺失的字段
var uiClaims struct {
Name string `json:"name"`
Email string `json:"email"`
Picture string `json:"picture"`
}
if err := userInfoResp.Claims(&uiClaims); err == nil {
if claims.Name == "" {
claims.Name = uiClaims.Name
}
if claims.Email == "" {
claims.Email = uiClaims.Email
}
if claims.Picture == "" {
claims.Picture = uiClaims.Picture
}
}
}
// --- 更新会话 ---
session.User = &UserInfo{
Sub: claims.Sub,
Name: claims.Name,
Email: claims.Email,
EmailVerified: claims.EmailVerified,
PreferredUsername: claims.PreferredUsername,
Picture: claims.Picture,
}
session.State = "" // 清除已使用的 state
session.Nonce = "" // 清除已使用的 nonce
log.Printf("用户登录成功: sub=%s, name=%s, email=%s", claims.Sub, claims.Name, claims.Email)
// 返回用户信息
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "登录成功",
"sub": claims.Sub,
"name": claims.Name,
"email": claims.Email,
"issuer": idToken.Issuer,
"issued_at": idToken.IssuedAt,
"expiry": idToken.Expiry,
})
}
// HandleMe 返回当前登录用户信息
func (c *OIDCClient) HandleMe(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
http.Error(w, `{"error": "未登录"}`, http.StatusUnauthorized)
return
}
session, ok := c.sessions.Get(cookie.Value)
if !ok || session.User == nil {
http.Error(w, `{"error": "未登录"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session.User)
}
// HandleLogout 登出
func (c *OIDCClient) HandleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("session_id"); err == nil {
c.sessions.Delete(cookie.Value)
}
// 清除 Cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
})
http.Redirect(w, r, "/", http.StatusFound)
}
// ============================================================
// 主函数
// ============================================================
func main() {
cfg := loadConfig()
ctx := context.Background()
// 初始化 OIDC 客户端(包含 Discovery)
client, err := NewOIDCClient(ctx, cfg)
if err != nil {
log.Fatalf("OIDC 客户端初始化失败: %v", err)
}
// 注册路由
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `OIDC Demo
登录 |
我的信息 |
登出`)
})
http.HandleFunc("/login", client.HandleLogin)
http.HandleFunc("/callback", client.HandleCallback)
http.HandleFunc("/me", client.HandleMe)
http.HandleFunc("/logout", client.HandleLogout)
log.Printf("OIDC 客户端启动,监听 %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}
```
## 8.9 小结
- OIDC 是 OAuth 2.0 之上的**身份认证层**,提供标准化的用户身份信息
- **ID Token** 是 JWT 格式,包含用户身份 Claims
- **ID Token 验证**必须检查签名、iss、aud、exp、nonce,缺一不可
- **Discovery** 端点使客户端可以自动发现 OIDC 配置,避免硬编码端点地址
- **JWKS** 端点提供签名验证所需的公钥,客户端应缓存并定期刷新
- Keycloak 是最流行的开源 OIDC 提供商,适合自托管场景
- OIDC 是零信任架构中人类身份认证的首选协议
- 各语言生态都有成熟的 OIDC 库:Python(Authlib)、Java(Spring Security)、Go(go-oidc)