Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md
T
insistence a89aec54aa feat: 新增 OAuth 2.0 / OIDC 统一认证中心 (#129)
* feat: 新增统一认证中心

* perf: 优化代码

* fix: 修复问题

* perf: 优化页面

* fix: 修复部分接口缺失Request参数的问题

* perf: 优化访问控制机制

* perf: 优化代码

* fix: 修复 Logout Token 缺少 exp

* perf&fix: 优化和修复

* perf: 优化代码

* perf: 优化代码

* fix: 修复测试问题
2026-10-01 22:56:05 +08:00

48 KiB
Raw Blame History

统一认证中心开发者使用手册

更新日期: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

校验顺序:

  1. 检查 HTTP 调用成功,且响应 active 严格为 true。
  2. 检查 iss、aud 是否属于预期认证中心和订单资源,必要时限制 client_id。
  3. 将空格分隔的 scope 拆分,检查本次操作要求的范围。
  4. 根据 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,并完成以下处理:

  1. 固定可信 Issuer 和 JWKS,验证 RS256 签名、typ=logout+jwt、kid、iss 和 aud=本客户端编号。
  2. 校验 iat、exp、jti,以及 events 中的 http://schemas.openid.net/event/backchannel-logout: {}。
  3. 要求包含 sid 或 sub,且不能包含 nonce;按照被验证的 sid/sub 查找本应用会话。存在 sid 时优先精确匹配,避免误下线同一用户的其他设备。
  4. 按 (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 的用途与完整校验