OIDC (OpenID Connect) 是基于 OAuth 2.0 的现代身份认证协议,本平台基于 Ory Hydra 实现了完整的 OIDC 标准流程。
| 特性 | OIDC | Simple API |
|---|---|---|
| 协议标准 | OAuth 2.0 / OIDC | 自定义 Ticket 机制 |
| Token 类型 | ID Token, Access Token, Refresh Token | Ticket (一次性) |
| 用户信息获取 | UserInfo 端点 | 验证接口返回 |
| 会话管理 | Refresh Token | 需重新登录 |
| 适用场景 | 现代应用、第三方集成 | 传统系统、快速对接 |
OIDC 使用标准的 Authorization Code Flow(授权码流程):
本平台基于 Ory Hydra,并通过网关统一暴露以下标准端点(假设前端网关地址为 http://localhost,生产环境请替换为实际域名,例如 https://sso.example.com):
http://localhost/hydra/oauth2/authhttp://localhost/hydra/oauth2/tokenhttp://localhost/hydra/userinfohttp://localhost/hydra/.well-known/openid-configuration注意: 生产环境请将
http://localhost替换为实际的前端网关域名,例如https://sso.example.com。
OIDChttp://your-app.com/callbackhttp://localhost:3000/auth/callbackopenid offline profileApp ID 和 App SecretRedirect URIs 必须与您的应用实际回调地址完全匹配(包括协议、域名、端口、路径)。
Scopes 说明:
openid: 必需,标识 OIDC 请求offline: 可选,用于获取 Refresh Tokenprofile: 可选,用于获取用户基本信息当检测到用户未登录时,重定向用户到授权端点:
// 构建授权 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
用户完成授权后,平台会重定向回您的回调地址,URL 格式:
http://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE
处理回调的代码:
// 从 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(见下一节)
接口地址: 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 |
代码示例:
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}")
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}`)
}
}
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;
}
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "def50200a1b2c3d4e5f6...",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "openid offline profile"
}
ID Token 是 JWT 格式,可以直接解析获取用户信息:
// 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" |
接口地址: GET http://localhost/hydra/userinfo
请求头:
Authorization: Bearer {access_token}
代码示例:
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}")
如果请求了 offline scope,您会获得 refresh_token,可用于刷新 Access 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 |
代码示例:
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}")
OIDC 提供发现端点,可以自动获取所有端点配置:
接口地址: GET http://localhost/hydra/.well-known/openid-configuration
响应示例:
{
"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"]
}
本平台提供了自定义的 Login 和 Consent 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}GET {{API_BASE_URL}}/oidc/consent-request?challenge={challenge}POST {{API_BASE_URL}}/oidc/consent/reject?challenge={challenge}注意: Consent 通常自动接受,无需手动调用接受接口。
// 拒绝登录
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: '用户取消了登录'
})
})
OIDC 支持将平台用户映射到应用特定的账号标识,映射信息会自动注入到 ID Token 中。
在 UAP 管理后台配置账号映射:
映射的账号标识会出现在以下 Claims:
preferred_username: 映射的用户名email: 映射的邮箱(如果配置了邮箱映射)state 参数A: 授权码通常有效期为 10 分钟,建议立即交换 Token。
A: 检查是否有有效的 Access Token,或调用 UserInfo 端点验证。
A: Refresh Token 通常长期有效,但可能被撤销。建议在刷新失败时引导用户重新登录。
A:
A: OIDC 支持 RP-Initiated Logout,可以调用 Hydra 的登出端点。
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)
}
最后更新: 2026-01-XX