# OIDC (OpenID Connect) 集成指南 ## 1. 概述 OIDC (OpenID Connect) 是基于 OAuth 2.0 的现代身份认证协议,本平台基于 [Ory Hydra](https://www.ory.sh/hydra/) 实现了完整的 OIDC 标准流程。 ### 1.1 适用场景 - ✅ 需要标准 OIDC 协议支持的应用 - ✅ 需要获取 ID Token 和 Access Token 的场景 - ✅ 需要支持 Refresh Token 的长期会话 - ✅ 需要标准化的用户信息获取(UserInfo 端点) - ✅ 需要与第三方 OIDC 库/框架集成 ### 1.2 与 Simple API 的区别 | 特性 | OIDC | Simple API | |------|------|------------| | 协议标准 | OAuth 2.0 / OIDC | 自定义 Ticket 机制 | | Token 类型 | ID Token, Access Token, Refresh Token | Ticket (一次性) | | 用户信息获取 | UserInfo 端点 | 验证接口返回 | | 会话管理 | Refresh Token | 需重新登录 | | 适用场景 | 现代应用、第三方集成 | 传统系统、快速对接 | ## 2. 核心概念 ### 2.1 OIDC 流程 OIDC 使用标准的 **Authorization Code Flow**(授权码流程): 1. **授权请求**:应用重定向用户到授权端点 2. **用户认证**:用户在 UAP 平台完成登录 3. **授权同意**:用户确认授权(通常自动完成) 4. **授权码返回**:平台重定向回应用,携带授权码 5. **Token 交换**:应用用授权码换取 Token 6. **用户信息获取**:使用 Access Token 获取用户信息 ### 2.2 端点说明 本平台基于 Ory Hydra,并通过网关统一暴露以下标准端点(假设前端网关地址为 `http://localhost`,生产环境请替换为实际域名,例如 `https://sso.example.com`): - **授权端点**: `http://localhost/hydra/oauth2/auth` - **Token 端点**: `http://localhost/hydra/oauth2/token` - **UserInfo 端点**: `http://localhost/hydra/userinfo` - **发现端点**: `http://localhost/hydra/.well-known/openid-configuration` > **注意**: 生产环境请将 `http://localhost` 替换为实际的前端网关域名,例如 `https://sso.example.com`。 ## 3. 准备工作 ### 3.1 创建 OIDC 应用 1. 登录 UAP 管理后台 2. 进入「应用管理」,点击「创建应用」 3. **关键配置**: - **协议类型**:选择 `OIDC` - **回调地址 (Redirect URIs)**:填写您的应用回调地址,例如: - `http://your-app.com/callback` - `http://localhost:3000/auth/callback` - **支持的 Scopes**:`openid offline profile` 4. 保存后获取 `App ID` 和 `App Secret` ### 3.2 配置说明 **Redirect URIs** 必须与您的应用实际回调地址完全匹配(包括协议、域名、端口、路径)。 **Scopes 说明**: - `openid`: 必需,标识 OIDC 请求 - `offline`: 可选,用于获取 Refresh Token - `profile`: 可选,用于获取用户基本信息 ## 4. 前端集成(作为 OIDC Client) ### 4.1 启动授权流程 当检测到用户未登录时,重定向用户到授权端点: ```javascript // 构建授权 URL const clientId = 'YOUR_APP_ID' const redirectUri = encodeURIComponent('http://your-app.com/callback') const scope = 'openid offline profile' const state = generateRandomString() // 用于防止 CSRF 攻击 const nonce = generateRandomString() // 用于验证 ID Token const authUrl = `http://localhost/hydra/oauth2/auth?` + `client_id=${clientId}&` + `response_type=code&` + `scope=${scope}&` + `redirect_uri=${redirectUri}&` + `state=${state}&` + `nonce=${nonce}` // 重定向用户 window.location.href = authUrl ``` ### 4.2 处理回调 用户完成授权后,平台会重定向回您的回调地址,URL 格式: ``` http://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE ``` **处理回调的代码**: ```javascript // 从 URL 中提取授权码 const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') const state = urlParams.get('state') const error = urlParams.get('error') // 验证 state(防止 CSRF) if (state !== storedState) { console.error('State mismatch') return } // 检查错误 if (error) { console.error('Authorization error:', error) return } // 用授权码换取 Token(见下一节) ``` ## 5. 后端集成(Token 交换) ### 5.1 用授权码换取 Token **接口地址**: `POST http://localhost/hydra/oauth2/token` **请求参数** (application/x-www-form-urlencoded): | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `grant_type` | string | 是 | 固定值: `authorization_code` | | `code` | string | 是 | 从回调 URL 获取的授权码 | | `redirect_uri` | string | 是 | 必须与授权请求中的 redirect_uri 完全一致 | | `client_id` | string | 是 | 您的 App ID | | `client_secret` | string | 是 | 您的 App Secret | **代码示例**: #### Python ```python import requests def exchange_code_for_token(code: str, redirect_uri: str): token_url = "http://localhost/hydra/oauth2/token" data = { "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": "YOUR_APP_ID", "client_secret": "YOUR_APP_SECRET" } response = requests.post(token_url, data=data) if response.status_code == 200: token_data = response.json() return { "access_token": token_data["access_token"], "id_token": token_data["id_token"], "refresh_token": token_data.get("refresh_token"), # 如果请求了 offline scope "expires_in": token_data["expires_in"], "token_type": token_data["token_type"] } else: raise Exception(f"Token exchange failed: {response.text}") ``` #### Node.js ```javascript const axios = require('axios') async function exchangeCodeForToken(code, redirectUri) { const tokenUrl = 'http://localhost/hydra/oauth2/token' const params = new URLSearchParams() params.append('grant_type', 'authorization_code') params.append('code', code) params.append('redirect_uri', redirectUri) params.append('client_id', 'YOUR_APP_ID') params.append('client_secret', 'YOUR_APP_SECRET') try { const response = await axios.post(tokenUrl, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) return { access_token: response.data.access_token, id_token: response.data.id_token, refresh_token: response.data.refresh_token, expires_in: response.data.expires_in, token_type: response.data.token_type } } catch (error) { throw new Error(`Token exchange failed: ${error.message}`) } } ``` #### Java (Spring Boot) ```java import org.springframework.web.client.RestTemplate; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; public class OidcClient { private static final String TOKEN_URL = "http://localhost/hydra/oauth2/token"; private static final String CLIENT_ID = "YOUR_APP_ID"; private static final String CLIENT_SECRET = "YOUR_APP_SECRET"; public TokenResponse exchangeCodeForToken(String code, String redirectUri) { RestTemplate restTemplate = new RestTemplate(); MultiValueMap params = new LinkedMultiValueMap<>(); params.add("grant_type", "authorization_code"); params.add("code", code); params.add("redirect_uri", redirectUri); params.add("client_id", CLIENT_ID); params.add("client_secret", CLIENT_SECRET); TokenResponse response = restTemplate.postForObject( TOKEN_URL, params, TokenResponse.class ); return response; } } class TokenResponse { public String access_token; public String id_token; public String refresh_token; public Integer expires_in; public String token_type; } ``` ### 5.2 Token 响应示例 ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "def50200a1b2c3d4e5f6...", "expires_in": 3600, "token_type": "Bearer", "scope": "openid offline profile" } ``` ## 6. 获取用户信息 ### 6.1 从 ID Token 获取(推荐) ID Token 是 JWT 格式,可以直接解析获取用户信息: ```javascript // JavaScript 示例 function parseJwt(token) { const base64Url = token.split('.')[1] const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') const jsonPayload = decodeURIComponent( atob(base64) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ) return JSON.parse(jsonPayload) } const idToken = tokenResponse.id_token const claims = parseJwt(idToken) console.log('User ID:', claims.sub) console.log('Email:', claims.email) console.log('Preferred Username:', claims.preferred_username) console.log('Phone:', claims.phone_number) ``` **ID Token Claims 说明**: | Claim | 说明 | 示例 | |-------|------|------| | `sub` | 用户唯一标识(User ID) | `"123"` | | `preferred_username` | 映射的用户名 | `"user_zhangsan"` | | `email` | 映射的邮箱 | `"zhangsan@example.com"` | | `phone_number` | 用户手机号 | `"13800138000"` | | `exp` | Token 过期时间(Unix 时间戳) | `1704067200` | | `iat` | Token 签发时间 | `1704063600` | | `nonce` | 随机字符串(用于验证) | `"abc123"` | ### 6.2 从 UserInfo 端点获取 **接口地址**: `GET http://localhost/hydra/userinfo` **请求头**: ``` Authorization: Bearer {access_token} ``` **代码示例**: ```python import requests def get_userinfo(access_token: str): userinfo_url = "http://localhost/hydra/userinfo" headers = { "Authorization": f"Bearer {access_token}" } response = requests.get(userinfo_url, headers=headers) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get userinfo: {response.text}") ``` ## 7. Refresh Token 使用 如果请求了 `offline` scope,您会获得 `refresh_token`,可用于刷新 Access Token。 ### 7.1 刷新 Token **接口地址**: `POST http://localhost/hydra/oauth2/token` **请求参数**: | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `grant_type` | string | 是 | 固定值: `refresh_token` | | `refresh_token` | string | 是 | 之前获取的 Refresh Token | | `client_id` | string | 是 | 您的 App ID | | `client_secret` | string | 是 | 您的 App Secret | **代码示例**: ```python def refresh_access_token(refresh_token: str): token_url = "http://localhost/hydra/oauth2/token" data = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": "YOUR_APP_ID", "client_secret": "YOUR_APP_SECRET" } response = requests.post(token_url, data=data) if response.status_code == 200: return response.json() # 包含新的 access_token 和 refresh_token else: raise Exception(f"Token refresh failed: {response.text}") ``` ## 8. 发现端点(Discovery) OIDC 提供发现端点,可以自动获取所有端点配置: **接口地址**: `GET http://localhost/hydra/.well-known/openid-configuration` **响应示例**: ```json { "issuer": "http://localhost/hydra", "authorization_endpoint": "http://localhost/hydra/oauth2/auth", "token_endpoint": "http://localhost/hydra/oauth2/token", "userinfo_endpoint": "http://localhost/hydra/userinfo", "jwks_uri": "http://localhost/hydra/.well-known/jwks.json", "response_types_supported": ["code", "code id_token"], "scopes_supported": ["openid", "offline", "profile"], "subject_types_supported": ["public", "pairwise"], "id_token_signing_alg_values_supported": ["RS256"] } ``` ## 9. 自定义 Login/Consent Provider 本平台提供了自定义的 Login 和 Consent Provider,支持账号映射注入。 ### 9.1 Login Provider 接口 - **获取登录请求**: `GET {{API_BASE_URL}}/oidc/login-request?challenge={challenge}` - **接受登录**: `POST {{API_BASE_URL}}/oidc/login/accept?challenge={challenge}` - **拒绝登录**: `POST {{API_BASE_URL}}/oidc/login/reject?challenge={challenge}` ### 9.2 Consent Provider 接口 - **获取同意请求**: `GET {{API_BASE_URL}}/oidc/consent-request?challenge={challenge}` - **拒绝同意**: `POST {{API_BASE_URL}}/oidc/consent/reject?challenge={challenge}` > **注意**: Consent 通常自动接受,无需手动调用接受接口。 ### 9.3 拒绝请求示例 ```javascript // 拒绝登录 await fetch(`${API_BASE_URL}/oidc/login/reject?challenge=${challenge}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'access_denied', error_description: '用户取消了登录' }) }) ``` ## 10. 账号映射 OIDC 支持将平台用户映射到应用特定的账号标识,映射信息会自动注入到 ID Token 中。 ### 10.1 映射配置 在 UAP 管理后台配置账号映射: - 进入「应用管理」→ 选择应用 → 「账号映射」 - 添加映射关系:平台用户 → 应用账号标识 ### 10.2 映射信息在 ID Token 中的位置 映射的账号标识会出现在以下 Claims: - `preferred_username`: 映射的用户名 - `email`: 映射的邮箱(如果配置了邮箱映射) ## 11. 安全建议 1. **使用 HTTPS**: 生产环境必须使用 HTTPS 2. **验证 State**: 始终验证授权回调中的 `state` 参数 3. **验证 ID Token**: 验证 ID Token 的签名和过期时间 4. **安全存储 Secret**: App Secret 必须安全存储,不要暴露在前端代码中 5. **Token 存储**: Access Token 和 Refresh Token 应安全存储(如 httpOnly Cookie) 6. **验证 Nonce**: 如果使用了 nonce,验证 ID Token 中的 nonce 值 ## 12. 常见问题 ### Q1: 授权码有效期是多久? A: 授权码通常有效期为 10 分钟,建议立即交换 Token。 ### Q2: 如何判断用户是否已登录? A: 检查是否有有效的 Access Token,或调用 UserInfo 端点验证。 ### Q3: Refresh Token 会过期吗? A: Refresh Token 通常长期有效,但可能被撤销。建议在刷新失败时引导用户重新登录。 ### Q4: ID Token 和 Access Token 的区别? A: - **ID Token**: 包含用户身份信息,用于标识用户 - **Access Token**: 用于访问受保护的资源(如 UserInfo 端点) ### Q5: 如何实现登出? A: OIDC 支持 RP-Initiated Logout,可以调用 Hydra 的登出端点。 ## 13. 完整示例 ### 13.1 前端完整流程(React) ```jsx import React, { useEffect, useState } from 'react' import axios from 'axios' function OidcLogin() { const [loading, setLoading] = useState(false) const handleLogin = () => { const clientId = 'YOUR_APP_ID' const redirectUri = encodeURIComponent('http://localhost:3000/callback') const state = Math.random().toString(36).substring(7) const nonce = Math.random().toString(36).substring(7) // 保存 state 用于验证 sessionStorage.setItem('oidc_state', state) sessionStorage.setItem('oidc_nonce', nonce) const authUrl = `http://localhost:4444/oauth2/auth?` + `client_id=${clientId}&` + `response_type=code&` + `scope=openid offline profile&` + `redirect_uri=${redirectUri}&` + `state=${state}&` + `nonce=${nonce}` window.location.href = authUrl } return ( ) } // 回调处理组件 function Callback() { useEffect(() => { const handleCallback = async () => { const params = new URLSearchParams(window.location.search) const code = params.get('code') const state = params.get('state') const error = params.get('error') // 验证 state const storedState = sessionStorage.getItem('oidc_state') if (state !== storedState) { console.error('State mismatch') return } if (error) { console.error('Authorization error:', error) return } // 交换 Token try { const tokenParams = new URLSearchParams() tokenParams.append('grant_type', 'authorization_code') tokenParams.append('code', code) tokenParams.append('redirect_uri', 'http://localhost:3000/callback') tokenParams.append('client_id', 'YOUR_APP_ID') tokenParams.append('client_secret', 'YOUR_APP_SECRET') const response = await axios.post( 'http://localhost:4444/oauth2/token', tokenParams, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ) // 保存 Token localStorage.setItem('access_token', response.data.access_token) localStorage.setItem('id_token', response.data.id_token) if (response.data.refresh_token) { localStorage.setItem('refresh_token', response.data.refresh_token) } // 解析 ID Token 获取用户信息 const idToken = response.data.id_token const claims = parseJwt(idToken) console.log('User logged in:', claims) // 重定向到应用首页 window.location.href = '/' } catch (error) { console.error('Token exchange failed:', error) } } handleCallback() }, []) return
处理登录回调...
} function parseJwt(token) { const base64Url = token.split('.')[1] const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') const jsonPayload = decodeURIComponent( atob(base64) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ) return JSON.parse(jsonPayload) } ``` ## 14. 参考资源 - [OIDC 规范](https://openid.net/specs/openid-connect-core-1_0.html) - [OAuth 2.0 规范](https://oauth.net/2/) - [Ory Hydra 文档](https://www.ory.sh/docs/hydra/) - [JWT.io](https://jwt.io/) - JWT 调试工具 --- **最后更新**: 2026-01-XX