JMI-OPENATOMJMI-OPENATOM
首页
快速开始
  • 架构概览
  • 项目结构
  • 认证与权限
  • 数据库迁移
  • 配置说明
  • 开发规范
  • 架构概览
  • 项目结构
  • 路由与权限
  • API 请求
  • 组件库
  • UniApp 小程序
  • Docker 部署
  • CI/CD
  • Nginx 反向代理
  • 环境变量
  • QQ 机器人
  • 实验室管理系统
  • API 权限清单
  • 数据库表结构
  • 常见问题
首页
快速开始
  • 架构概览
  • 项目结构
  • 认证与权限
  • 数据库迁移
  • 配置说明
  • 开发规范
  • 架构概览
  • 项目结构
  • 路由与权限
  • API 请求
  • 组件库
  • UniApp 小程序
  • Docker 部署
  • CI/CD
  • Nginx 反向代理
  • 环境变量
  • QQ 机器人
  • 实验室管理系统
  • API 权限清单
  • 数据库表结构
  • 常见问题
  • API 参考

    • OpenAtom OAuth 2.0 / OIDC 使用文档
    • API 权限清单

OpenAtom OAuth 2.0 / OIDC 使用文档

本文档对应当前 openatom-system 的认证中心实现。系统支持:

  • OAuth 2.0 Authorization Code 授权码模式
  • OpenID Connect(OIDC)
  • PKCE(推荐使用 S256)
  • Refresh Token
  • UserInfo
  • Token Introspection

1. 服务地址

生产环境 Issuer:

https://oauth.jmi-openatom.cn/api/v1

OIDC Discovery:

GET https://oauth.jmi-openatom.cn/api/v1/.well-known/openid-configuration

主要端点:

用途方法地址
发起授权GET/oauth/authorize
换取或刷新令牌POST/oauth/token
获取用户信息GET/oauth/userinfo
检查令牌POST/oauth/introspect
获取签名密钥描述GET/oauth/jwks

下文使用:

OIDC_ISSUER=https://oauth.jmi-openatom.cn/api/v1

2. 注册 OAuth 客户端

管理员进入:

管理后台 -> 认证应用 -> 新增应用

需要配置:

字段说明示例
应用名称后台展示名称实验室管理系统
Client ID客户端唯一标识lab-lms
Client Secret机密客户端密钥;纯前端应用留空请使用随机强密钥
回调地址授权成功后的回调地址,多个地址使用英文逗号分隔https://example.com/auth/callback
Scopes空格分隔openid profile email roles permissions
Grant Types空格分隔authorization_code refresh_token

回调地址采用精确匹配,包括协议、域名、端口和路径。例如:

https://example.com/auth/callback

与以下地址均不相同:

http://example.com/auth/callback
https://example.com/auth/callback/
https://www.example.com/auth/callback

客户端类型

  • 浏览器 SPA、桌面端、移动端属于公开客户端:不应保存 client_secret,注册时留空,必须使用 PKCE。
  • 有安全后端的 Web 应用属于机密客户端:在服务端保存 client_secret,不可发送到浏览器。

3. 授权码流程

3.1 生成 PKCE 参数

客户端生成一个高熵随机字符串作为 code_verifier,再计算:

code_challenge = BASE64URL(SHA256(code_verifier))

浏览器示例:

function base64Url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}

export async function createPkce() {
  const random = crypto.getRandomValues(new Uint8Array(32))
  const codeVerifier = base64Url(random)
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(codeVerifier),
  )

  return {
    codeVerifier,
    codeChallenge: base64Url(new Uint8Array(digest)),
  }
}

将 code_verifier 临时保存在当前登录会话中,回调换取令牌时使用。

3.2 跳转到授权端点

GET /oauth/authorize

参数:

参数必填说明
response_type是固定为 code
client_id是注册的 Client ID
redirect_uri是必须与注册值完全一致
scope否默认 openid profile
state强烈建议防止 CSRF 的一次性随机值
nonce建议绑定本次 OIDC 登录
code_challenge公开客户端必填PKCE Challenge
code_challenge_method公开客户端必填推荐并固定使用 S256

示例:

https://oauth.jmi-openatom.cn/api/v1/oauth/authorize
  ?response_type=code
  &client_id=your-client-id
  &redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback
  &scope=openid%20profile%20email%20roles%20permissions
  &state=RANDOM_STATE
  &nonce=RANDOM_NONCE
  &code_challenge=PKCE_CODE_CHALLENGE
  &code_challenge_method=S256

实际使用时应拼成一行,并对参数进行 URL 编码。

授权成功后,认证中心重定向到:

https://example.com/auth/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE

客户端必须先验证返回的 state 与本地保存值一致,再交换令牌。

授权码有效期为 5 分钟,并且只能使用一次。

4. 使用授权码换取令牌

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

公开客户端:

curl -X POST "$OIDC_ISSUER/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=your-client-id" \
  --data-urlencode "code=AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=https://example.com/auth/callback" \
  --data-urlencode "code_verifier=PKCE_CODE_VERIFIER"

机密客户端额外提交:

--data-urlencode "client_secret=YOUR_CLIENT_SECRET"

成功响应示例:

{
  "access_token": "ACCESS_TOKEN",
  "id_token": "ID_TOKEN",
  "refresh_token": "REFRESH_TOKEN",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email roles permissions",
  "user": {
    "sub": "1",
    "club_user_id": 1,
    "preferred_username": "username",
    "username": "username",
    "name": "张三",
    "nickname": "张三",
    "email": "user@example.com",
    "phone": "13800000000",
    "phone_number": "13800000000",
    "student_id": "20260001",
    "avatar": "https://example.com/avatar.png",
    "is_lab_member": true,
    "lab_role": 0,
    "roles": ["formal_member"],
    "permissions": ["activity:list"]
  },
  "issuer": "https://oauth.jmi-openatom.cn/api/v1"
}

令牌有效期:

  • Access Token:1 小时
  • ID Token:1 小时
  • Refresh Token:7 天

5. 调用 UserInfo

curl "$OIDC_ISSUER/oauth/userinfo" \
  -H "Authorization: Bearer ACCESS_TOKEN"

响应字段与换取令牌结果中的 user 基本一致。

推荐以后端返回的 sub 作为用户稳定唯一标识,不要使用用户名、姓名、邮箱或手机号作为关联主键。

6. 刷新令牌

curl -X POST "$OIDC_ISSUER/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "client_id=your-client-id" \
  --data-urlencode "refresh_token=REFRESH_TOKEN"

机密客户端同样需要提交:

--data-urlencode "client_secret=YOUR_CLIENT_SECRET"

刷新成功后会返回一组新的 Access Token、ID Token 和 Refresh Token。旧 Refresh Token 会立即失效,因此客户端必须原子地替换整组令牌。

7. Token Introspection

curl -X POST "$OIDC_ISSUER/oauth/introspect" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "token=ACCESS_TOKEN"

有效令牌示例:

{
  "active": true,
  "sub": "1",
  "username": "username",
  "name": "张三",
  "client_id": "your-client-id",
  "scope": "openid profile",
  "exp": 1780000000,
  "roles": ["formal_member"],
  "permissions": ["activity:list"]
}

无效或过期令牌返回:

{
  "active": false,
  "sub": null,
  "username": null,
  "name": null,
  "client_id": null,
  "scope": null,
  "exp": null,
  "roles": null,
  "permissions": null
}

8. Scope 与用户字段

支持的 Scope:

Scope用途
openid启用 OIDC;系统会自动保留该 Scope
profile用户名、姓名、头像等基本资料
email邮箱
roles系统角色
permissions系统权限

服务端只会授予客户端已注册允许的 Scope。请求未填写 Scope 时,默认申请:

openid profile

注意:当前实现的 UserInfo 返回字段尚未按 Scope 逐字段裁剪。客户端仍应只使用业务实际需要的数据。

9. 前端接入示例

const issuer = 'https://oauth.jmi-openatom.cn/api/v1'
const clientId = 'your-client-id'
const redirectUri = `${window.location.origin}/auth/callback`

async function login() {
  const { codeVerifier, codeChallenge } = await createPkce()
  const state = crypto.randomUUID()
  const nonce = crypto.randomUUID()

  sessionStorage.setItem('oauth_code_verifier', codeVerifier)
  sessionStorage.setItem('oauth_state', state)
  sessionStorage.setItem('oauth_nonce', nonce)

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'openid profile email',
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  })

  window.location.assign(`${issuer}/oauth/authorize?${params}`)
}

async function handleCallback() {
  const params = new URLSearchParams(window.location.search)
  const code = params.get('code')
  const state = params.get('state')

  if (!code || state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('OAuth 回调校验失败')
  }

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: clientId,
    code,
    redirect_uri: redirectUri,
    code_verifier: sessionStorage.getItem('oauth_code_verifier') || '',
  })

  const response = await fetch(`${issuer}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  })

  if (!response.ok) throw new Error('换取令牌失败')
  return response.json()
}

生产应用应优先采用“后端代理/BFF”保存 Refresh Token。若必须由 SPA 保存令牌,避免使用长期持久化存储,并配合严格的 CSP 和 XSS 防护。

10. 常见错误

错误HTTP 状态常见原因
invalid_client401Client ID 不存在、应用被禁用或 Client Secret 错误
invalid_grant400授权码无效、过期、已使用,回调地址不一致,PKCE 校验失败,或 Refresh Token 无效
unsupported_grant_type400grant_type 不是 authorization_code 或 refresh_token
unsupported_response_type重定向错误response_type 不是 code,或客户端未允许授权码模式
invalid_token401Access Token 缺失、无效或已过期

排查重点:

  1. redirect_uri 是否与后台配置完全一致。
  2. Token 请求是否使用 application/x-www-form-urlencoded,而不是 JSON。
  3. PKCE 的 code_verifier 是否与发起授权时属于同一次会话。
  4. Refresh Token 是否已被使用过;当前实现会轮换 Refresh Token。
  5. 服务器时间是否准确。

11. 当前实现的安全注意事项

  1. 公开客户端务必使用 PKCE S256。服务端目前兼容不带 PKCE 和 plain 的请求,这是兼容能力,不是推荐用法。
  2. client_secret 只能存放在可信后端,不能写入 SPA、移动端安装包或公开仓库。
  3. state 必须是不可预测的一次性随机值,不能仅用作回跳路径。
  4. 当前 /oauth/introspect 未要求客户端认证,生产环境如需对公网开放,建议增加机密客户端认证或限制为内网访问。
  5. 当前 JWKS 使用对称算法 HS256,并以 oct JWK 形式输出签名密钥材料。不要将该端点公开给不可信客户端;生产环境建议改用 RS256/ES256,只发布公钥。
  6. 当前实现不提供 OAuth 撤销端点。Access Token 在到期前不能通过标准 /oauth/revoke 主动撤销。
Next
API 权限清单