|
|
@@ -0,0 +1,596 @@
|
|
|
+# 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<String, String> 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 (
|
|
|
+ <button onClick={handleLogin} disabled={loading}>
|
|
|
+ {loading ? '登录中...' : '使用 OIDC 登录'}
|
|
|
+ </button>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// 回调处理组件
|
|
|
+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 <div>处理登录回调...</div>
|
|
|
+}
|
|
|
+
|
|
|
+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
|