# 第八章: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)