* feat: 新增统一认证中心 * perf: 优化代码 * fix: 修复问题 * perf: 优化页面 * fix: 修复部分接口缺失Request参数的问题 * perf: 优化访问控制机制 * perf: 优化代码 * fix: 修复 Logout Token 缺少 exp * perf&fix: 优化和修复 * perf: 优化代码 * perf: 优化代码 * fix: 修复测试问题
48 KiB
统一认证中心开发者使用手册
更新日期:2026-10-01。适用于当前 Vue3、Vue2 两个仓库的统一认证中心;两者使用相同的后端协议和接入配置。
本文面向平台管理员、业务应用开发者和资源服务开发者,按“启用服务 → 注册应用 → 登录 → 调用 API → 续期和退出”的顺序说明接入方法。本文的域名、客户端编号和凭据均为占位示例,使用时替换为实际值。
1. 选择接入方式
统一认证中心为外部应用提供 OpenID Connect(OIDC)登录和 OAuth 2.0 授权,支持以下方式:
| 场景 | 客户端类型 | 授权方式 | 客户端认证 |
|---|---|---|---|
| 有后端的 Web 应用、BFF | confidential(机密) |
authorization_code + PKCE S256;按需增加 refresh_token |
client_secret_basic |
| 纯浏览器 SPA、不能保管密钥的客户端 | public(公开) |
authorization_code + PKCE S256;按需增加 refresh_token |
none,表单传 client_id |
| 定时任务、服务间调用 | confidential |
client_credentials |
client_secret_basic |
| 资源 API 的在线令牌校验 | 独立的 confidential 客户端 |
调用 Introspection,并绑定到对应资源 | client_secret_basic |
当前不支持密码授权、Implicit、client_secret_post、PKCE plain,也不提供动态客户端注册。应用由平台管理员预先登记。原生应用若使用公开客户端,仍须满足当前 HTTP(S) 回调注册规则,不支持自定义 URI scheme。
原有管理后台/App 的登录 Token、菜单权限与 OIDC 相互隔离。OAuth Access Token 不能直接替代管理后台 Token 调用 /system/**;业务应用也不应把认证中心登录页当成管理后台登录接口。
1.1 各方负责什么
| 角色 | 需要完成的工作 |
|---|---|
| 平台管理员 | 启用认证中心、初始化签名密钥、登记客户端/资源/Scope、管理授权和会话 |
| 业务应用(Client / RP) | 发起授权、检查回调、兑换令牌、验证用户身份、维护自己的登录会话 |
| 资源服务(Resource Server / RS) | 验证 Access Token、受众和 Scope,并执行自己的业务数据权限判断 |
1.2 先理解三个标识
| 标识 | 示例 | 用途 |
|---|---|---|
client_id |
管理端生成的 cli_... |
标识一个接入应用;客户端配置中的 clientId |
resourceId |
orders-api |
管理端绑定资源的内部业务标识 |
audience |
https://api.example.com/orders |
令牌受众;协议请求的 resource 参数填写此值 |
管理 API 的 JSON 字段使用 camelCase,协议参数使用 snake_case。例如管理端的 redirectUris 是列表,授权请求中的 redirect_uri 是其中一个精确匹配的地址。
2. 准备地址和环境
后续示例统一使用:
| 配置 | 示例值 |
|---|---|
| 认证中心 Issuer | https://auth.example.com |
| 业务应用 | https://app.example.com |
| 登录回调 | https://app.example.com/oidc/callback |
| 退出回调 | https://app.example.com/oidc/logged-out |
| 订单资源受众 | https://api.example.com/orders |
Issuer 必须是无路径前缀、无查询串的固定公开地址。OIDC_PUBLIC_BASE_URL 必须与它一致;不能把 /prod-api、/dev-api 或 /oauth2 写进 Issuer。
生产环境使用 HTTPS。仅在应用环境为 dev、test、local 时允许 localhost、127.0.0.1、::1 的本机 HTTP;本地开发建议统一使用 http://localhost,不要在 Issuer、回调和浏览器地址之间混用 localhost 与 127.0.0.1。SSO Cookie 始终要求 Secure,不能通过关闭 Cookie 安全属性解决代理或浏览器问题。
3. 平台启用与检查
所有后端命令在 ruoyi-fastapi-backend 目录执行,并使用已安装项目依赖的 Python 3 解释器。命令适用于 Windows PowerShell 和 macOS Terminal,不依赖特定的 Python 环境管理工具。下面以 dev 环境为例,其他环境替换为对应的 --env 和配置文件。
ruoyi 命令在 Windows 和 macOS 上的参数一致。直接调用 Python 时,下文分别给出 Windows 的 python 和 macOS 的 python3 写法;若本机解释器命令不同,替换为实际安装项目依赖的解释器。
3.1 配置功能开关和密钥材料
在对应 .env.<环境> 中配置:
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com
OIDC_PUBLIC_BASE_URL=https://auth.example.com
OIDC_INTERACTION_LOGIN_URL=https://auth.example.com/auth-center/login
OIDC_INTERACTION_CONSENT_URL=https://auth.example.com/auth-center/consent
OIDC_INTERACTION_ERROR_URL=https://auth.example.com/auth-center/error
OIDC_REQUIRE_PKCE=true
OIDC_PKCE_METHODS=S256
OIDC_SIGNING_ALGORITHM=RS256
OIDC_TOKEN_HASH_PEPPER=<独立生成并长期保存的随机字符串>
OIDC_SIGNING_KEY_ENCRYPTION_KEY=<另一个独立生成并长期保存的随机字符串>
OIDC_SSO_COOKIE_NAME=__Host-ruoyi-sso
OIDC_SSO_COOKIE_SECURE=true
OIDC_SSO_COOKIE_SAMESITE=lax
OIDC_SSO_COOKIE_DOMAIN=
OIDC_LEGACY_AUTH_ISOLATION_ENABLED=true
三个交互页面地址必须与 Issuer 同源,即协议、主机和端口一致。修改密码页由认证中心流程进入,不需要另设一个交互 URL 配置。
Pepper 和签名密钥加密材料各至少 32 个 UTF-8 字节,彼此独立,也不要复用原有 JWT 或传输加密密钥。可执行两次以下命令生成两份不同的值,再通过部署配置或密钥管理系统保存:
Windows PowerShell:
python -c "import secrets; print(secrets.token_urlsafe(48))"
macOS Terminal:
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
多实例使用同一套持久化配置。每次重启重新生成 Pepper 会使已有不透明凭据无法匹配;更换加密材料会影响数据库中现有签名私钥的解密。配置文件不提交真实密钥。修改环境配置后重启后端实例。
3.2 数据库、Redis 和首把签名密钥
先完成平台本身的数据库和 Redis 配置。新空库使用对应数据库的初始化 SQL;存量库按实际结构升级。完整初始化 SQL 含 DROP TABLE,不能向存量业务库直接重新导入。
当前启动流程可通过 Base.metadata.create_all 补建缺失表,但不会修改已有列或回填历史数据;当前没有认证模块专用的 Alembic 升级命令。
在数据库结构准备完成后启动后端,再初始化首把签名密钥:
ruoyi app run --env dev
在另一个终端执行:
ruoyi oidc key bootstrap --env dev --kid auth-primary --dry-run
ruoyi oidc key bootstrap --env dev --kid auth-primary --yes
ruoyi app doctor --env dev
若终端找不到 ruoyi 命令,可直接使用 Python 模块入口,将上面命令的 ruoyi 前缀替换为下表中的调用方式,其余参数保持不变:
| 系统 | 替代前缀 | 检查命令示例 |
|---|---|---|
| Windows PowerShell | python -X utf8 -m cli.main |
python -X utf8 -m cli.main app doctor --env dev |
| macOS Terminal | python3 -X utf8 -m cli.main |
python3 -X utf8 -m cli.main app doctor --env dev |
生产密钥初始化还需要显式传入 --allow-prod,并指定实际生产环境。
bootstrap 会创建并激活首把 RSA 签名密钥,已有有效 Active 密钥时执行校验,可重复执行。--dry-run 不创建密钥。此流程将私钥加密存入数据库;使用这条路径不需要自行填写私钥文件路径或手工设置 OIDC_ACTIVE_KID。不要把客户端 Secret 当作签名私钥。
3.3 代理路由和前端开关
前端和协议端点需要通过正确的公开地址访问。部署代理至少保持以下关系:
| 浏览器请求路径 | 目标 |
|---|---|
/auth-center/** |
Vue 前端,深层路径刷新回落到 index.html |
/.well-known/**、/oauth2/** |
后端,保留原始路径 |
/auth/interaction/** |
后端,保留原始路径、Cookie、Origin 等请求头 |
/prod-api/** 或开发环境 /dev-api/** |
平台普通 API,移除该前缀后转发给后端 |
平台前端通过普通 API 前缀请求 GET /auth/status,例如浏览器中的 /prod-api/auth/status。该匿名接口只返回开关状态,数据部分为 {"enabled": true},使用平台响应包装并禁止缓存。它不代表数据库、Redis 或签名密钥已经就绪。
当 OIDC_ENABLED=false 时,Vue3/Vue2 都会阻止进入认证中心登录、同意和修改密码页,展示“统一认证服务未启用”;状态请求失败时展示“暂不可用”。错误页仍可访问,普通后台登录不受影响。协议端点关闭时返回 404。前后端需同步发布,否则旧后端缺少状态接口会使新前端拒绝进入认证页。
3.4 确认服务可用
使用 Python 标准库检查公开端点,无需额外安装 HTTP 命令行工具。
Windows PowerShell:
python -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/.well-known/openid-configuration', timeout=10).read().decode('utf-8'))"
python -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/oauth2/jwks', timeout=10).read().decode('utf-8'))"
macOS Terminal:
python3 -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/.well-known/openid-configuration', timeout=10).read().decode('utf-8'))"
python3 -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/oauth2/jwks', timeout=10).read().decode('utf-8'))"
检查 Discovery 中的 issuer 和各端点是否为预期公开 HTTPS 地址,JWKS 是否包含可用 RSA 公钥,并确认 ruoyi app doctor 的 OIDC 检查通过。开关为真但无有效签名密钥时,Discovery/JWKS 等就绪检查可能返回 503;先检查密钥状态和加密配置。
4. 注册资源、Scope 和客户端
使用具有 OAuth 管理权限的后台账号操作。以下 JSON 用于说明与管理页面对应的配置,也可由受信任的管理程序调用 API。管理 API 需要原有管理后台身份和权限,不能拿业务应用的 OAuth Token 调用。
下文的 /system/** 为后端原始路径;通过前端代理调用时加上部署中的普通 API 前缀。管理结果使用平台 {code, msg, data} 等响应结构;OAuth 协议响应不使用这个包装。
4.1 登记业务资源
只有登录需求时可以暂时跳过资源和业务 Scope,只申请身份范围。若应用要访问订单 API,先在资源管理中创建以下资源,或调用 POST /system/oauth/resource:
{
"resourceId": "orders-api",
"resourceName": "订单服务",
"audience": "https://api.example.com/orders",
"tokenFormat": "jwt",
"signingAlg": "RS256",
"accessTokenTtlSeconds": 600,
"allowedClaims": []
}
audience 是稳定的受众标识,不需要认证中心访问该 URL。以后协议参数 resource 使用这个值。需要在线内省时,按第 7 节登记校验客户端,并在资源中设置它的 introspectionClientId。
4.2 登记业务 Scope
在权限范围管理中创建,或调用 POST /system/oauth/scope:
{
"scopeCode": "orders.read",
"scopeName": "查看订单",
"scopeType": "resource",
"resourceId": "orders-api",
"claims": [],
"consentRequired": true,
"sensitive": false,
"status": "0"
}
资源 Scope 必须绑定资源;身份 Scope 不绑定资源。标识应使用稳定的英文编码,例如 orders.read、orders.write;显示名称可用中文。status="0" 为启用,"1" 为停用。
系统已有以下身份 Scope,无需重复创建:
| Scope | 用途 |
|---|---|
openid |
OIDC 用户登录,当前所有用户授权请求均要求携带 |
profile |
基础资料,例如显示名、用户名、头像 |
email、phone |
邮箱、手机及相应验证状态 |
dept |
部门信息 |
roles |
经客户端允许列表过滤后的角色信息 |
offline_access |
申请刷新令牌资格 |
最终返回字段还受用户实际资料、获准 Scope 和 Claim 策略控制。允许申请 roles 不等于可读取全部后台角色;客户端的 allowedRoleKeys 为空时不发布角色。业务服务仍需自行判断数据归属和操作权限。
4.3 登记有后端的业务应用
在客户端管理页面(默认 /oauth/client)新增应用,或调用 POST /system/oauth/client:
{
"clientName": "订单工作台",
"clientType": "confidential",
"tokenEndpointAuthMethod": "client_secret_basic",
"grantTypes": ["authorization_code", "refresh_token"],
"responseTypes": ["code"],
"requirePkce": true,
"requireConsent": true,
"trustedClient": false,
"scopeCodes": ["openid", "profile", "offline_access", "orders.read"],
"resourceIds": ["orders-api"],
"allowedRoleKeys": [],
"preAuthorizedScopeCodes": [],
"redirectUris": ["https://app.example.com/oidc/callback"],
"postLogoutRedirectUris": ["https://app.example.com/oidc/logged-out"],
"backchannelLogoutUris": [],
"corsOrigins": []
}
保存响应中的 data.clientId。client_id 由平台生成,不使用应用名称或资源编号替代。
创建客户端不会自动返回 Secret。机密客户端保存后,还要在行操作中“生成/轮换密钥”,或调用 POST /system/oauth/client/{client_id}/secret,请求体可为 {}。响应中 data.clientSecret 只显示这一次,应立即保存到应用后端的安全配置;后续详情接口无法找回明文。公开客户端不生成 Secret。
URI 和授权策略需要满足以下规则:
- 登录回调与退出回调分别注册、精确匹配;不能使用通配符、URL 用户信息或 fragment。路径、端口、查询串和尾部斜杠应与实际请求保持一致。
- 生产回调使用 HTTPS;本机开发可登记本机 HTTP。Back-Channel 地址还受更严格的公网 HTTPS 和网络地址校验,见第 10 节。
- 所有
authorization_code客户端都必须开启 PKCE S256、配置responseTypes: ["code"]和至少一个登录回调。 refresh_token必须与authorization_code同时启用。用户还需实际获准offline_access,才会收到 Refresh Token。scopeCodes、resourceIds指向已存在且启用的配置;一个请求当前只能选择一个业务资源,相关业务 Scope 应属于该资源。- 客户端类型创建后不能直接修改;由公开类型切换为机密类型时应另建客户端。常规接入保持显式同意,可信应用及预授权范围由管理员按实际业务配置。
4.4 纯前端 SPA 的差异
将客户端设为 public,认证方式设为 none,其余授权码与 PKCE 配置保留;corsOrigins 登记应用 Origin,例如 https://app.example.com,不带路径或尾部斜杠。也可由管理员配置 OIDC_CORS_ALLOWED_ORIGINS 的逗号分隔 Origin 列表。
浏览器直接调用 Token/UserInfo 端点时才需要相应跨域配置。有后端代为兑换令牌的应用通常无需这项配置。发起授权和退出使用浏览器页面跳转,不通过跨域 AJAX 获取登录页面。SPA 不保管客户端 Secret,不能使用 client_credentials。
5. 接入用户登录
外部应用从 /oauth2/authorize 发起流程,由认证中心创建交互并导航到登录、同意或修改密码页。不要直接拼接 /auth-center/login,也不要调用 /auth/interaction/** 模拟外部应用登录。
sequenceDiagram
participant B as 浏览器
participant A as 业务应用
participant I as 认证中心
participant R as 资源 API
B->>A: 点击登录
A-->>B: 保存 state、nonce、verifier 后跳转
B->>I: authorize + PKCE challenge
I-->>B: 登录与同意;有有效 SSO 时按策略跳过
I-->>B: 回调 code、state、iss
B->>A: 登录回调
A->>I: token + code + verifier
I-->>A: Access Token、ID Token、可选 Refresh Token
A->>A: 验证身份并建立应用会话
A->>R: Bearer Access Token
R->>R: 校验令牌、受众、Scope 与业务权限
5.1 生成授权请求
建议用支持 OIDC Authorization Code + PKCE 的库实现完整流程。下面展示 Python 服务端的核心参数生成,方便与现有框架集成;后续示例可放在同一模块中,依赖 httpx 和 PyJWT[crypto]。
import base64
import hashlib
import secrets
import time
from urllib.parse import urlencode
ISSUER = 'https://auth.example.com'
CLIENT_ID = 'cli_REPLACE_WITH_REGISTERED_ID'
REDIRECT_URI = 'https://app.example.com/oidc/callback'
RESOURCE = 'https://api.example.com/orders'
def begin_login():
verifier = secrets.token_urlsafe(48)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
transaction = {
'state': secrets.token_urlsafe(32),
'nonce': secrets.token_urlsafe(32),
'verifier': verifier,
'expires_at': time.time() + 300,
}
parameters = {
'client_id': CLIENT_ID,
'response_type': 'code',
'response_mode': 'query',
'redirect_uri': REDIRECT_URI,
'scope': 'openid profile offline_access orders.read',
'resource': RESOURCE,
'state': transaction['state'],
'nonce': transaction['nonce'],
'code_challenge': challenge,
'code_challenge_method': 'S256',
}
return f'{ISSUER}/oauth2/authorize?{urlencode(parameters)}', transaction
把 transaction 保存在与当前浏览器会话绑定的短期服务端存储中,并按 state 区分并发登录。回调处理时原子取出并删除,防止重复使用;不要把 verifier 或 Secret 放到授权 URL、前端日志或可被其他浏览器重放的共享状态中。公开 SPA 可由 OIDC 库在浏览器中维护对应短期状态。
仅登录时从 scope 移除 orders.read,并完全省略 resource;不需要续期时移除 offline_access。当前用户授权请求即使只关心业务 API,也必须包含 openid 和 nonce。一次请求不能携带多个业务资源。
可选参数:prompt=login 强制重新登录,prompt=consent 要求重新同意,prompt=none 尝试无交互授权,max_age 要求认证新鲜度。none 不能与其他 prompt 值一起使用;静默授权返回 login_required/consent_required 时应重新发起允许交互的流程。
授权端点也接受 application/x-www-form-urlencoded 的 POST,此时所有授权参数放在表单体,不能同时在查询串传参,不能重复传同名参数。
5.2 接收回调并兑换授权码
成功回调形如:
https://app.example.com/oidc/callback?code=...&state=...&iss=https%3A%2F%2Fauth.example.com
回调路由应拒绝重复的 state、iss、code、error 参数,校验与本次浏览器事务绑定的 state、过期时间和固定 Issuer,再处理成功或错误结果。错误回调不能建立登录会话。
以下函数接收框架已提取、无重复参数的字典,以及从当前浏览器会话中一次性取出的事务。client_secret 由服务端安全配置提供;公开客户端传 None。
import httpx
async def exchange_code(query, transaction, client_secret=None):
if not transaction or time.time() >= transaction['expires_at']:
raise ValueError('登录事务不存在或已过期,请重新登录')
if not secrets.compare_digest(query.get('state', ''), transaction['state']):
raise ValueError('state 不匹配')
if query.get('iss') != ISSUER:
raise ValueError('授权响应 issuer 不匹配')
if query.get('error'):
raise ValueError(f'授权未完成:{query["error"]}')
if not query.get('code'):
raise ValueError('回调缺少授权码')
form = {
'grant_type': 'authorization_code',
'code': query['code'],
'redirect_uri': REDIRECT_URI,
'code_verifier': transaction['verifier'],
}
auth = None
if client_secret is None:
form['client_id'] = CLIENT_ID
else:
auth = httpx.BasicAuth(CLIENT_ID, client_secret)
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
response = await client.post(f'{ISSUER}/oauth2/token', data=form, auth=auth)
response.raise_for_status()
return response.json()
这里的 Basic 示例适用于平台生成的 URL 安全字符客户端编号和 Secret。自行实现客户端认证时,按 OAuth Basic 规则对客户端编号和 Secret 分别做表单编码后再组装 Basic Header;不要把 client_secret 放到 URL 或请求体。客户端认证和令牌传输都依赖 HTTPS。
授权码默认 90 秒有效、只能使用一次。兑换时 redirect_uri 必须与授权时一致,verifier 必须对应本次 challenge。该次兑换不要附加 scope 或 resource,授权内容已绑定在授权码上。兑换失败或超时后不要反复重放同一个授权码,应重新开始登录。
协议返回的是原始 OAuth JSON,例如:
{
"access_token": "<access-token>",
"token_type": "Bearer",
"expires_in": 600,
"scope": "openid profile offline_access orders.read",
"id_token": "<id-token>",
"refresh_token": "<refresh-token>"
}
以实际返回的 scope、expires_in 和可选字段为准;用户可能只同意部分范围。收到这些字符串并不等于已经验证用户身份。
5.3 验证 ID Token,再创建应用登录会话
ID Token 用于登录身份,Access Token 用于资源访问,Refresh Token 用于续期。三者不要混用。JWKS 地址从固定可信 Issuer 的 Discovery 获取,核对 Discovery 的 issuer;不得按未验证 Token 的 iss、jku 等字段动态选择信任来源。
下面是适配当前 RS256 Profile 的最小校验示例。固定 JWKS 来源,校验类型、算法、签名、受众、时效、nonce 和 at_hash;如请求使用了 max_age,还需由 OIDC 库按 auth_time 校验相应要求。
import jwt
jwks_client = jwt.PyJWKClient(f'{ISSUER}/oauth2/jwks', timeout=5)
def verify_identity(tokens, expected_nonce):
id_token = tokens['id_token']
header = jwt.get_unverified_header(id_token)
if header.get('alg') != 'RS256' or header.get('typ') != 'JWT':
raise ValueError('ID Token 类型或算法不正确')
if not isinstance(header.get('kid'), str) or not header['kid'].strip():
raise ValueError('ID Token 缺少 kid')
if any(name in header for name in ('crit', 'jku', 'jwk', 'x5u', 'x5c')):
raise ValueError('不支持的 JWT Header')
key = jwks_client.get_signing_key_from_jwt(id_token).key
claims = jwt.decode(
id_token,
key,
algorithms=['RS256'],
issuer=ISSUER,
audience=CLIENT_ID,
leeway=60,
options={'require': ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'sid']},
)
if not isinstance(claims.get('sub'), str) or not claims['sub']:
raise ValueError('ID Token 缺少有效 sub')
nonce = claims.get('nonce')
if not isinstance(nonce, str) or not secrets.compare_digest(nonce, expected_nonce):
raise ValueError('nonce 不匹配')
audiences = claims['aud'] if isinstance(claims['aud'], list) else [claims['aud']]
if len(audiences) > 1 or 'azp' in claims:
if claims.get('azp') != CLIENT_ID:
raise ValueError('azp 不匹配')
if 'at_hash' in claims:
digest = hashlib.sha256(tokens['access_token'].encode('ascii')).digest()
expected_hash = base64.urlsafe_b64encode(digest[:16]).rstrip(b'=').decode('ascii')
if not isinstance(claims['at_hash'], str) or not secrets.compare_digest(claims['at_hash'], expected_hash):
raise ValueError('at_hash 不匹配')
return claims
get_signing_key_from_jwt 可能访问 JWKS 网络端点,在异步 Web 路由中将同步验证放在线程池,或使用支持异步获取与缓存 JWKS 的 OIDC 库。遇到未知 kid 时刷新可信 JWKS 并重新校验,失败则拒绝;不能临时关闭签名验证。
完成 verify_identity(tokens, transaction["nonce"]) 后,以 (iss, sub) 作为外部用户稳定标识,按业务需要关联本地用户,并保存 sid 以处理退出通知。用户名、手机号、邮箱都不应当作不变的外部主键。
有后端的应用把令牌保存在服务端,只给浏览器自己的随机会话 Cookie,并设置 HttpOnly、Secure 和适合业务的 SameSite。应用自己的写操作仍需要相应 CSRF 防护。认证中心不会替应用创建本地登录态,也不会自动配置应用的数据权限。
5.4 获取用户资料
用用户 Access Token 请求 GET /oauth2/userinfo,或带相同 Bearer Header 的 POST:
GET /oauth2/userinfo HTTP/1.1
Host: auth.example.com
Authorization: Bearer <access-token>
返回原始用户 Claims JSON;核对返回的 sub 与已验证 ID Token 相同。字段取决于获准身份 Scope 及 Claim 策略。机器令牌不用于 UserInfo。
6. 应用调用业务 API
将 Access Token 放在 Header,不放到 URL:
GET /orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <access-token>
订单服务必须验证令牌针对 https://api.example.com/orders 签发,并检查包含 orders.read。拿到 openid profile 不代表具有订单读取权限;用户允许登录也不等于应用可以访问所有资源。
用户 Access Token 的受众包括认证中心 UserInfo,申请业务资源时另含相应业务受众;机器 Access Token 仅面向选定业务资源。不能因为 JWT 签名正确或包含某个 Scope 就省略 aud 校验。
7. 资源服务验证令牌
7.1 在线内省:用于及时响应撤销
新建一个专供订单 API 校验令牌的机密客户端,例如:
{
"clientName": "订单服务令牌校验",
"clientType": "confidential",
"tokenEndpointAuthMethod": "client_secret_basic",
"grantTypes": ["client_credentials"],
"responseTypes": [],
"requirePkce": true,
"scopeCodes": [],
"resourceIds": [],
"redirectUris": []
}
生成并保存该客户端的 Secret,然后编辑订单资源,将 introspectionClientId 设置为这个新客户端的 clientId。这里的校验权限来自资源绑定,不是来自 grantTypes 或业务客户端的申请范围。
订单 API 使用该校验客户端的 Basic 凭据调用:
POST /oauth2/introspect HTTP/1.1
Host: auth.example.com
Authorization: Basic <校验客户端编号与密钥按规则编码后的值>
Content-Type: application/x-www-form-urlencoded
token=<urlencoded-access-token>&token_type_hint=access_token
校验顺序:
- 检查 HTTP 调用成功,且响应
active严格为true。 - 检查
iss、aud是否属于预期认证中心和订单资源,必要时限制client_id。 - 将空格分隔的
scope拆分,检查本次操作要求的范围。 - 根据
gty区分用户访问和机器访问,再执行资源自己的用户/租户/记录级授权。
失效响应为 {"active": false}。未绑定到该资源的其他客户端,即使知道某个有效 Token,也不能据此内省成功。只面向 UserInfo、没有业务资源受众的 Token 不用于这条资源内省路径;获取用户资料应调用 UserInfo。
内省超时或 5xx 应作为认证依赖不可用处理,不能放行。缓存内省结果会引入撤销延迟;需要即时反映授权撤销或会话下线的接口,应控制或避免正向缓存。
7.2 本地 JWT 校验:接受失效传播延迟时使用
固定可信 Issuer/JWKS 和 RS256,校验 typ=at+jwt、签名、kid、iss、预期业务 aud、exp/nbf/iat、Scope,以及当前接口允许的用户/机器令牌类型。不要接受 typ=JWT 的 ID Token 或 logout+jwt 的退出通知令牌作为 API 凭据。
本地验签不能自动得知 Grant 撤销、会话下线或客户端停用;已有 JWT 在本地到期前可能仍通过纯密码学检查。需要及时失效时使用在线内省或可靠的状态同步。当前实现的完整 Token Profile 可参考 JWT 校验代码。
8. 刷新令牌与会话续期
取得 Refresh Token 需要客户端允许 refresh_token,且用户本次实际获准 offline_access。“记住授权选择”和浏览器“保持登录”均不等于获得离线访问资格。
机密客户端使用 Basic,公开客户端不传 Basic、改在表单中加入 client_id:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Authorization: Basic <业务应用的客户端认证值>
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=<urlencoded-current-refresh-token>
每次成功续期都会轮换 Refresh Token。将新的 Access Token、Refresh Token 和到期信息原子替换到应用会话中;当前刷新响应不重新签发 ID Token,不要覆盖原有已验证身份记录为“空”。
同一个应用会话的刷新操作必须串行化,多个并发 API 请求不能各自使用同一 Refresh Token。复用已使用的 Refresh Token 会触发令牌家族失效;请求超时且无法确认是否已经轮换时,不要盲目重试旧凭据,应重新授权恢复。
刷新时可选 scope 仅能缩小原授权;不能扩大权限,不能切换业务资源。若发送 resource,必须与原资源一致。新增 Scope 或切换资源应重新走授权码流程。
默认时限如下;客户端、资源配置和全局上限可能进一步限制实际结果,应以响应为准:
| 配置 | 默认值 |
|---|---|
| 授权码 | 90 秒 |
| 登录/同意交互 | 300 秒 |
| ID Token | 300 秒 |
| Access Token | 600 秒,全局最大值默认 1800 秒 |
| Refresh Token 闲置期限 | 7 天 |
| Refresh Token 绝对期限 | 30 天,轮换不重置原始绝对期限 |
| SSO 闲置期限 | 30 分钟 |
| SSO 普通绝对期限 / 保持登录期限 | 8 小时 / 7 天 |
| 时钟容差 | 60 秒 |
SSO 会话自然到期不自动取消已允许的离线授权;显式退出、管理员下线、授权撤销、用户被禁止访问或相关安全状态变化会阻断对应访问和续期。
9. 服务间调用
为后台任务单独登记机密客户端,只开启 client_credentials,绑定需要的资源和资源 Scope。例如:
{
"clientName": "订单同步任务",
"clientType": "confidential",
"tokenEndpointAuthMethod": "client_secret_basic",
"grantTypes": ["client_credentials"],
"responseTypes": [],
"requirePkce": true,
"scopeCodes": ["orders.read"],
"resourceIds": ["orders-api"],
"redirectUris": []
}
该流程没有授权回调,也没有 PKCE 参数交换;管理模型保留 requirePkce=true 不影响机器授权。保存后生成客户端 Secret,再发送:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Authorization: Basic <机器客户端的认证值>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=orders.read&resource=https%3A%2F%2Fapi.example.com%2Forders
scope 和 resource 都要明确填写,Scope 只能包含该资源的业务范围。不要申请 openid、profile、offline_access。响应只有 Access Token 等访问字段,没有 ID Token 或 Refresh Token,到期后重新获取。
机器令牌的 gty=client_credentials,sub 为 client:<client_id>。资源 API 不得将它当作某个用户身份,用户专属操作应拒绝机器令牌或使用单独的机器授权策略。
10. 退出、撤销与 Back-Channel
10.1 浏览器退出
应用发起退出时,为当前会话保存随机退出 state,将浏览器导航到 Discovery 中的 end_session_endpoint,参数示例:
https://auth.example.com/oauth2/logout
?id_token_hint=<urlencoded-id-token>
&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Foidc%2Flogged-out
&state=<random-logout-state>
实际 URL 不换行,各参数均 URL 编码。post_logout_redirect_uri 必须已经在该客户端登记。保留本次需要的 id_token_hint 后,应用应按自己的退出语义清理本地会话;回到退出回调时验证预先保存的退出 state。
当前实现会展示退出确认页,用户确认后才清理认证中心会话及相关凭据。仅 GET 退出地址不立即执行全局退出。确认提交由认证中心页面完成,应用不要自行调用 /oauth2/logout/confirm。取消认证中心退出时,不能声称其他应用也已退出。
10.2 令牌撤销与管理员撤销的区别
客户端可以向 /oauth2/revoke 发送表单 token 和可选 token_type_hint,采用该客户端原有认证方式。未知或已撤销凭据按幂等语义成功处理。
| 操作 | 当前影响 |
|---|---|
| 撤销 Refresh Token | 终止相应刷新能力;不能视为立即撤销所有已经签发的 Access Token |
| 撤销指定 Access Token | 阻断该 Token 的在线校验;纯本地验签仍需状态同步 |
| 后台“外部授权 → 撤销” | 撤销该用户对该应用的现有 Grant 和关联刷新凭据;旧 Token 的在线校验失败 |
| 后台“访问策略 → 禁止” | 阻止该用户再向该应用授权,并撤销已有授权;可在首次登录前配置 |
| 解除访问禁止 | 允许重新授权,不恢复旧 Grant 或旧 Token |
| 后台“外部会话 → 下线” | 撤销对应 sid 的访问资格,包括自然过期会话遗留的离线资格 |
| 客户端停用 | 阻止新登录/签发,并撤销该客户端相关授权与凭据 |
应用本地会话与资源 Token 分别管理:本地 Cookie 没有过期不代表资源授权仍然有效;收到资源拒绝或退出通知后,应更新应用的会话状态。
10.3 接收后端退出通知
需要跨应用及时退出时,登记客户端的 backchannelLogoutUris,例如 https://app.example.com/oidc/backchannel-logout。默认只允许通过安全校验的公网 HTTPS 地址,不允许查询串和私网/回环地址。
接收端支持表单 POST 参数 logout_token,并完成以下处理:
- 固定可信 Issuer 和 JWKS,验证 RS256 签名、
typ=logout+jwt、kid、iss和aud=本客户端编号。 - 校验
iat、exp、jti,以及events中的http://schemas.openid.net/event/backchannel-logout: {}。 - 要求包含
sid或sub,且不能包含nonce;按照被验证的sid/sub查找本应用会话。存在sid时优先精确匹配,避免误下线同一用户的其他设备。 - 按
(iss, client_id, jti)实现幂等处理,清理对应会话后返回 200;重复通知仍返回成功。临时处理失败返回 5xx,由投递端重试。
通知目标来自该 SSO 会话实际参与的客户端,不是所有使用过同一用户的应用。接收端不依赖用户浏览器 Cookie,也不要信任未验签的退出 Claims。
11. 密钥维护
客户端 Secret 和认证中心签名密钥是两套不同的凭据:
| 项目 | 客户端 Secret | OIDC 签名密钥 |
|---|---|---|
| 用途 | 业务应用/资源服务向认证中心认证 | 认证中心签发 JWT,接入方通过 JWKS 验签 |
| 操作入口 | 客户端行操作“生成/轮换密钥” | 签名密钥管理,默认 /oauth/key |
| 应用保存什么 | 机密客户端保存自己的 Secret | 接入方只保存可信 Issuer 配置和缓存的公钥 |
| 轮换重点 | 使用生效时间和旧 Secret 退役窗口,完成部署后再撤销旧 Secret | 先发布新公钥,再激活签名;保留旧公钥覆盖已有 Token 的验证窗口 |
普通客户端 Secret 轮换不会自动撤销正常用户授权。泄漏处置若需要阻断已有访问,应另外停用客户端或撤销相关授权,不能只依赖换 Secret。
签名密钥可通过 POST /system/oauth/key/rotate 创建 Pending 密钥,再通过 PUT /system/oauth/key/{kid}/activate 激活;安排发布、生效和旧密钥退役时间时考虑 Token 有效期、时钟容差及接入方 JWKS 缓存。不要删除仍用于验证未过期 Token 的旧公钥。
12. 接口速查
下表协议路径相对于固定 Issuer,不加平台 API 前缀:
| 方法 | 路径 | 用途与身份 |
|---|---|---|
| GET | /.well-known/openid-configuration |
OIDC Discovery |
| GET | /.well-known/oauth-authorization-server |
OAuth 服务端元数据 |
| GET | /oauth2/jwks |
公开验签密钥 |
| GET / POST | /oauth2/authorize |
浏览器用户授权;POST 使用表单 |
| POST | /oauth2/token |
授权码兑换、刷新、机器授权;表单和客户端认证 |
| GET / POST | /oauth2/userinfo |
用户 Access Token 的 Bearer 认证 |
| POST | /oauth2/introspect |
绑定资源的机密客户端 Basic 认证 |
| POST | /oauth2/revoke |
令牌撤销;对应客户端认证 |
| GET / POST | /oauth2/logout |
浏览器发起确认退出 |
平台管理及内部页面接口:
| 路径 | 用途 |
|---|---|
/auth/status |
匿名读取启用开关;平台响应包装,前端通过普通 API 前缀访问 |
/auth/interaction/** |
认证中心自己的登录、验证码、同意、修改密码和完成交互;不作为外部应用接入 API |
/system/oauth/client/** |
客户端与 Secret 管理 |
/system/oauth/resource/**、/system/oauth/scope/** |
资源和权限范围管理 |
/system/oauth/session/**、/system/oauth/grant/** |
会话、授权和访问策略管理 |
/system/oauth/key/** |
签名密钥管理 |
/monitor/oauth/audit/** |
OAuth 审计查询与导出 |
管理端要求相应的 system:oauthClient:*、system:oauthResource:*、system:oauthScope:*、system:oauthSession:*、system:oauthGrant:*、system:oauthKey:* 等权限。菜单不可见时检查角色分配和对应菜单数据,不要改成匿名开放管理接口。
13. 常见问题
| 现象 | 优先检查与处理 |
|---|---|
| 认证页显示“未启用” | 当前后端实例的 OIDC_ENABLED、配置环境及是否重启;/auth/status 返回的开关值 |
| 认证页显示“暂不可用” | 状态接口代理、后端是否同步升级、网络/超时;正常后台登录可用不代表 OIDC 已就绪 |
| 协议端点 404 | 开关是否关闭,代理是否把协议误加 /prod-api 或转发到 SPA |
| Discovery/JWKS 503 | Active 签名密钥、加密材料、数据库与 Redis 就绪状态;运行 ruoyi app doctor |
invalid_client |
客户端类型/认证方式、编号、Secret 生效与到期、是否已停用;机密客户端使用 Basic |
回调提示 redirect_uri is not registered |
精确比对已登记的协议、域名、端口、路径和查询串;不要依赖前缀匹配 |
invalid_request |
缺少 nonce/PKCE、重复字段、JSON 代替表单、授权 POST 混用查询参数、错误响应模式 |
invalid_scope / invalid_target |
是否包含 openid,范围是否启用并授权给客户端,资源是否允许,resource 是否填了 audience,是否混入第二个资源 |
invalid_grant |
授权码过期/复用、verifier 或回调不一致、刷新凭据已使用/撤销、会话或授权状态变化;重新授权 |
| 没收到 Refresh Token | 客户端是否启用刷新、请求是否含 offline_access、用户是否实际同意该范围 |
| 刷新后下次刷新失败 | 是否原子保存新 Refresh Token,是否多个请求并发使用同一个旧值 |
内省始终 active=false |
RS 校验客户端是否绑定到对应资源、Token 是否含业务受众、授权/会话/客户端是否失效 |
| UserInfo 401 | 是否传用户 Access Token、是否过期/撤销;不要使用 ID Token、机器令牌或后台 Token |
| 角色/资料未返回 | 获准 Scope、客户端角色允许列表、Claim 策略和用户实际资料;不要假设请求即获准 |
| SPA 跨域失败 | 精确登记 Origin,检查预检代理;不要用 * 掩盖配置错误 |
| 已登录却反复显示登录页 | Issuer 与交互页是否同源,Cookie 是否因 HTTPS、主机、代理或浏览器策略未保存 |
| 登录/同意交互过期 | 默认 5 分钟,重新从业务应用开始授权,不复用旧 interaction 链接 |
login_required / consent_required |
静默授权无法完成,重新发起允许页面交互的授权 |
429 / temporarily_unavailable |
遵守 Retry-After,检查依赖与限流;不要循环重放一次性授权码或刷新凭据 |
| 退出后另一应用仍显示登录 | 是否接收并验签 Back-Channel、是否映射并清理对应 sid、本地会话是否更新;仅清当前 Cookie 不会通知其他应用 |
排查时结合 OAuth 审计记录和服务端日志中的错误代码、客户端编号、会话/授权标识。日志中不记录 Secret、密码、授权码、完整 Token 或 verifier。
14. 接入验收清单
- 配置启用,Discovery/JWKS 可访问,关闭开关时三个认证交互页面被阻止,错误页与普通后台登录可用。
- 首次登录完成授权码 + PKCE;错误 state、nonce、Issuer、签名、受众或过期 Token 会被应用拒绝。
- 同一浏览器接入第二个应用可以复用 SSO,各应用仍分别遵守自己的授权策略。
- 仅身份登录、允许订单访问、拒绝订单范围三种场景的结果符合预期,API 不越权。
- Refresh Token 正确轮换并串行使用;用户不同意离线访问时不假定存在刷新凭据。
- 机器令牌可访问允许的资源,不能冒充用户或访问未授权资源。
- 撤销 Grant、禁止访问、会话下线和客户端停用能使在线内省/续期失败;解除禁止后需要重新授权。
- 全局退出经过用户确认;退出回调 state 正确;Back-Channel 验签、会话隔离、重复通知和失败重试有效。
- 客户端 Secret、签名密钥轮换后新凭据可用,过渡期旧凭据按预期验证,日志无敏感凭据。
在目标环境完成真实 HTTPS、反向代理、数据库并发、Cookie 与公网 Back-Channel 联调;单元测试不能替代这些部署验证。
15. 代码参考
需要了解具体实现时,可查阅以下配置、协议模型和令牌校验源码。
| 资料 | 用途 |
|---|---|
| OIDC 配置 | 配置名、默认值和启动校验 |
| 协议数据模型 | 授权、Token 等入参约束 |
| 客户端管理模型 | 客户端类型、Grant、URI 与 Secret 配置 |
| 资源与 Scope 模型 | 资源受众、内省绑定与 Scope 配置 |
| JWT Profile | Access/ID/Logout Token 的用途与完整校验 |