Skip to content

OA 后端与 MySQL 接入 IAM 对接文档

OA 后端与 MySQL 接入 IAM 对接文档

Section titled “OA 后端与 MySQL 接入 IAM 对接文档”

本文根据当前 oain-weboacn-api 代码整理,交付给 OA 后端同事,用于修改 oacn-api 和 OA MySQL 数据库,使 OA 接入当前 IAM 统一身份认证。

系统 技术栈 负责人 本次职责
IAM Better Auth 1.7.1、Hono、PostgreSQL IAM 负责人 创建并配置 OA OAuth/OIDC Client,提供 issuer、Client ID、Client Secret 和 Claims 契约
oacn-api Laravel 10、Sanctum、Bouncer OA 后端同事 实现 OIDC 登录、Token 校验、OA 用户绑定、OA Token 签发和 Back-Channel Logout
OA 数据库 MySQL OA 后端同事 审查并执行 Laravel migration,修改 userspersonal_access_tokens
oain-web Vue 3、Vite、Pinia、Axios OA 前端负责人 发起登录、接收一次性 ticket、保存 OA Sanctum Token;当前代码已具备目标流程,联调时验证即可

边界结论:

  • OA 后端同事同时负责修改 oacn-api 和 OA MySQL。
  • IAM 负责人可以修改 IAM 自己的 PostgreSQL,但不直接修改 OA MySQL。
  • OA Web 不接触 OIDC Client Secret,也不直接用 Authorization Code 换取 IAM Token。
  • 本文描述的是当前工作区中的目标实现;后端同事仍需在自己的正式分支审查、迁移、测试并发布。
项目 当前基线
oain-web commit 46aad626b114f44767112bc4072d6b4a79dc734e
oacn-api commit 1a323270a47c6d13362d6c31bba53edc304d3897
OA Web Vue 3.5、Vite 6、TypeScript、Axios、Pinia
OA API PHP ^8.1、Laravel ^10.10、Sanctum ^3.2、Bouncer
OA 数据库 MySQL
IAM Better Auth 1.7.1,OAuth 2.1/OIDC Provider
IAM 当前 JWT 签名算法 RS256,RSA 2048
OA 本地 Token 默认期限 建议 300 分钟,以最终环境配置为准

采用服务端 BFF 模式:

sequenceDiagram
    autonumber
    participant U as 浏览器
    participant W as oain-web
    participant A as oacn-api
    participant I as IAM
    participant M as OA MySQL

    U->>W: 点击统一身份登录
    W->>A: GET /api/auth/iam/start?return_to=/...
    A->>A: 生成 state、nonce、PKCE,写入共享缓存
    A-->>U: 302 跳转 IAM authorize
    U->>I: 企业微信登录并授权
    I-->>A: 302 callback?code=&state=
    A->>I: 服务端使用 code + verifier 换 Token
    A->>I: 获取 JWKS,验证 ID Token,调用 UserInfo
    A->>M: 匹配并绑定 OA 用户
    A-->>U: 302 oain-web /iam/callback?ticket=...
    W->>A: POST /api/auth/iam/exchange
    A->>M: 创建关联 IAM sid 的 Sanctum Token
    A-->>W: 返回 OA Token
    W->>A: GET /api/auth/user
    A-->>W: 返回 OA 本地 Bouncer 角色

核心原则:

  • IAM 负责证明用户身份和判断用户能否进入 OA 应用。
  • OA 继续使用自己的用户、Sanctum Token、Bouncer 角色和业务权限。
  • IAM Access Token 不能直接作为 OA 业务接口的 Bearer Token。
  • IAM app_role 当前只用于日志或后续扩展,不能覆盖 OA 本地 Bouncer 角色。
  • 默认不自动创建 OA 用户;统一登录用户必须先存在于 OA。

当前 oain-web 已实现统一登录入口和回调页,后端必须保持以下接口契约。

前端整页跳转:

GET {VITE_BASE_URL}/auth/iam/start?return_to=%2Ftarget

其中 VITE_BASE_URL 已包含 /apireturn_to 仅允许 OA 站内绝对路径;前后端都应拒绝 //evil.example、带 scheme、反斜杠或控制字符的地址。

OA 后端完成 IAM 回调和用户匹配后,跳转:

https://<oa-web-domain>/iam/callback?ticket=<one-time-ticket>

失败时跳转:

https://<oa-web-domain>/iam/callback?error=<error-code>

/iam/callback 是公开路由。前端读取参数后会立即清理地址栏,避免 ticket 留在浏览历史中。

请求:

POST /api/auth/iam/exchange
Content-Type: application/json
{"ticket":"<one-time-ticket>"}

成功响应必须沿用 OA API 的统一响应包装,使前端能够读取 result.data.token

{
"code": 200,
"message": "success",
"data": {
"token": "<sanctum-plain-text-token>",
"token_type": "Bearer",
"role": ["<oa-local-role>"]
}
}

实际顶层 codemessage 名称以 OA 现有响应封装为准,但 data.token 不得改变。前端保存 OA Token 后会继续请求:

GET /api/auth/user
Authorization: Bearer <oa-sanctum-token>
APP-DATA-SYS: NDD

/api/auth/user 返回的 data.role 是前端最终使用的 OA 本地角色。没有 OA 角色时,前端应拒绝进入业务页面。

  • 现有账号密码登录和企业微信旧登录可在迁移期保留,是否下线另行安排。
  • 新 IAM 映射流程不得复用旧企业微信登录中的“按手机号自动创建用户”逻辑。
  • 当前 OA 页面退出只删除 OA 本地 Token,不会退出 IAM 或其他系统;页面提示应保持这一语义。
  • IAM 主动退出通过 Back-Channel Logout 通知 OA 撤销对应 Token,不依赖浏览器前端回调。

建议通过 IAM 管理端创建,不直接手写 IAM PostgreSQL 记录。

配置项
Client Name OA
Client 类型 机密客户端
Redirect URI https://<oa-api-domain>/api/auth/iam/callback
Token Endpoint Auth Method client_secret_basic
Grant Types authorization_code
Response Types code
Scope openid profile email
Require PKCE true,只接受 S256
Back-Channel Logout URI https://<oa-api-domain>/api/auth/iam/backchannel-logout
Back-Channel Logout Session Required true
Skip Consent 按产品要求;内部系统可评估开启

根据当前 Web 环境文件可识别的地址,联调前应由后端同事和运维再次确认:

环境 OA API 候选地址 OA Web 候选地址
生产 https://oain-api.ndd-x.cn https://oa-intl.chaoqing-i.com
测试 https://oain-api.dev.ndd-x.cn https://oa-intl-dev.chaoqing-i.com
本地 http://127.0.0.1:8000 http://localhost:9001

候选地址不能直接当作最终发布配置。Redirect URI 必须与实际环境逐字符一致,包括协议、域名、端口、路径和末尾斜杠。

生产 Back-Channel Logout URI 必须是 IAM 服务端能够访问的 HTTPS 地址,不能包含凭据或 fragment。本地环境无法接收服务器间回调时,应在公网测试环境完成统一下线验收。

IAM 负责人通过 Secret 管理系统或其他安全渠道提供:

IAM_OIDC_ISSUER=https://iam.cq-i.cn/api/auth
IAM_OIDC_CLIENT_ID=<oa-client-id>
IAM_OIDC_CLIENT_SECRET=<oa-client-secret>

Client Secret 仅允许配置在 oacn-api,不得进入 oain-web、Git 仓库、群聊截图、日志或监控事件。

Claim 用途
sub IAM 稳定用户标识;绑定后作为 OA 首要匹配键
sid IAM 会话标识;用于精确撤销对应 OA Sanctum Token
wecom_user_id 首次绑定时优先匹配 OA 的 wechat_work_userid
email 允许配置开启后作为首次绑定候选
email_verified 只有严格布尔值 true 才允许邮箱首次绑定
app_role 仅记录,不覆盖 OA Bouncer 角色

IAM 当前使用 RS256 签发 ID Token 和 Logout Token。OA 后端可保留 EdDSA/Ed25519 验签作为旧密钥兼容能力,但不得把它当作当前主算法。

以下路由不能放在 auth:sanctum 中间件后面:

Route::prefix('auth/iam')->group(function () {
Route::get('start', [IamAuthController::class, 'start']);
Route::get('callback', [IamAuthController::class, 'callback']);
Route::post('exchange', [IamAuthController::class, 'exchange']);
Route::post('backchannel-logout', [IamAuthController::class, 'backchannelLogout']);
});
方法与路径 用途
GET /api/auth/iam/start 创建 state、nonce 和 PKCE,跳转 IAM
GET /api/auth/iam/callback 交换 Token、验证身份、绑定 OA 用户、签发一次性 ticket
POST /api/auth/iam/exchange 一次性 ticket 换 OA Sanctum Token
POST /api/auth/iam/backchannel-logout 验证 IAM Logout Token,按 sid 撤销 OA Token
  1. 校验 IAM 配置完整。
  2. 校验 return_to 只能是 OA 站内路径。
  3. 使用加密安全随机数生成 statenoncecode_verifier
  4. 计算 code_challenge = BASE64URL(SHA256(code_verifier))
  5. 以 state 为键,将 verifier、nonce、return_to 写入 Laravel Cache,建议有效期 300 秒。
  6. 整页 302 跳转 IAM Authorization Endpoint。

Authorization 请求至少包含:

client_id
redirect_uri
response_type=code
scope=openid profile email
state
nonce
code_challenge
code_challenge_method=S256
  1. 使用原子读取并删除方式消费 state;state 不存在、过期或重放立即失败。
  2. 服务端通过 client_secret_basic,使用 code 和原始 verifier 请求 Token Endpoint。
  3. 从 Discovery 获取 issuer、端点和 jwks_uri,不在代码中猜测端点。
  4. 使用 JWKS 验证 ID Token 签名,并验证 issaud、必要时的 azpexpiatnoncesub
  5. 必须取得非空 sid,否则不能建立支持统一下线的 OA 会话。
  6. 使用 IAM Access Token 调用 UserInfo,并确认 UserInfo 的 sub 与 ID Token 一致。
  7. 按第 7 节规则查找并绑定 OA 用户,检查用户本地状态。
  8. 生成不可预测、单次消费、建议 60 秒有效的一次性 ticket,仅缓存 user_idiam_session_id 和 return_to。
  9. 302 跳转 OA Web /iam/callback,不得把 IAM Access Token、ID Token 或 OA Token 放入 URL。
  1. 原子消费 ticket,防止并发重复兑换和重放。
  2. 再次检查 OA 用户存在且状态有效。
  3. 创建 OA Sanctum Token,名称建议为 iam-oidc
  4. 将 IAM sid 写入该 Token 对应的 personal_access_tokens.iam_session_id
  5. 写入 OA 现有登录日志。
  6. 按第 3.3 节返回 data.tokentoken_type 和 OA 本地角色。

5.5 backchannel-logout 必须完成的行为

Section titled “5.5 backchannel-logout 必须完成的行为”

接收 IAM 表单请求中的 logout_token,完整验证:

  • JWT 签名和允许的算法;
  • issaudiatexp
  • 非空且未使用过的 jti
  • events 包含 OIDC Back-Channel Logout 事件;
  • 包含 sidsub,当前 OA 实现要求使用 sid
  • 不允许存在 nonce

验证通过后,仅删除:

DELETE FROM personal_access_tokens
WHERE iam_session_id = :sid;

不能按用户删除其全部 OA Token,否则会误伤该用户在其他设备或 IAM 会话中的登录。jti 必须写入共享缓存防重放。

state、ticket、JWKS/Discovery 缓存和 Logout Token jti 防重放都依赖 Laravel Cache。生产多实例部署不能使用本机 file 缓存,应使用 Redis 等所有实例共享且支持原子 pull/add 的缓存后端。

建议有效期:

数据 建议有效期
OAuth state/nonce/verifier 300 秒
一次性 ticket 60 秒
Discovery/JWKS 3600 秒
Logout Token jti 防重放 至少覆盖 Token 可接受时间窗口

以下 migration 由 OA 后端同事审查和执行,IAM 负责人不直接修改 OA MySQL。

新增字段:

字段 类型 约束 用途
iam_subject varchar(255) nullable、unique 保存 IAM sub
iam_linked_at timestamp nullable 首次绑定时间
iam_last_login_at timestamp nullable 最近一次 IAM 登录时间

同时更新 User Model:

  • 将三个字段加入允许赋值配置;
  • 将两个时间字段加入 datetime cast;
  • 不删除现有企业微信字段和角色关系。

新增:

字段 类型 约束 用途
iam_session_id varchar(191) nullable、index 保存 IAM sid,用于精确撤销 Token

该字段不能设为 unique,因为同一 IAM 会话可能因重新兑换或业务需要产生多个 OA Token。

在实际 OA 数据库执行只读检查,表名或状态值如与生产不一致需相应调整:

-- 企业微信 UserID 重复时不能自动决定绑定哪个 OA 用户
SELECT wechat_work_userid, COUNT(*) AS total
FROM users
WHERE wechat_work_userid IS NOT NULL
AND wechat_work_userid <> ''
GROUP BY wechat_work_userid
HAVING COUNT(*) > 1;
-- 邮箱重复或大小写归一后的冲突
SELECT LOWER(email) AS normalized_email, COUNT(*) AS total
FROM users
WHERE email IS NOT NULL
AND email <> ''
GROUP BY LOWER(email)
HAVING COUNT(*) > 1;
-- 核对现有状态值,确定哪些状态允许登录
SELECT status, COUNT(*) AS total
FROM users
GROUP BY status;

如果企业微信 UserID 存在重复,必须先由业务负责人确认并清洗,不能让代码静默选择第一条记录。

Terminal window
php artisan migrate --pretend
php artisan migrate

执行后检查:

SHOW COLUMNS FROM users LIKE 'iam_%';
SHOW INDEX FROM users WHERE Key_name LIKE '%iam%';
SHOW COLUMNS FROM personal_access_tokens LIKE 'iam_session_id';
SHOW INDEX FROM personal_access_tokens WHERE Key_name LIKE '%iam%';

迁移需同时准备可恢复方案。回滚前应确认是否已经产生 IAM 登录数据,避免直接删除仍在使用的绑定和 Token 关联字段。

首次登录按以下顺序匹配,命中后写入 iam_subject

  1. users.iam_subject = sub:已绑定用户,直接使用。
  2. users.wechat_work_userid = wecom_user_idiam_subject IS NULL:优先绑定现有企业微信身份。
  3. 仅在 IAM_ALLOW_EMAIL_LINK=true 时,用已验证邮箱匹配 iam_subject IS NULL 的用户。
  4. 均未命中则拒绝登录,提示联系管理员;不得自动创建 OA 用户。

邮箱绑定还必须满足:

  • email_verified 必须是严格布尔值 true
  • 邮箱格式有效;
  • 拒绝企业微信占位邮箱,例如 @wecom.invalid@wecom.com
  • 查询结果必须唯一;有歧义时拒绝绑定。

权限规则:

  • OA 用户必须处于本地允许登录状态。
  • OA 最终角色来自 Bouncer,例如 $user->getRoles()
  • IAM app_role 不写入或覆盖 OA 角色。
  • IAM 管理端的应用准入与 OA 本地业务授权是两道独立检查,缺一不可。

建议在各环境配置:

IAM_OIDC_ISSUER=https://iam.cq-i.cn/api/auth
IAM_OIDC_CLIENT_ID=
IAM_OIDC_CLIENT_SECRET=
IAM_OIDC_REDIRECT_URI=http://localhost:8000/api/auth/iam/callback
IAM_FRONTEND_CALLBACK=http://localhost:9001/iam/callback
IAM_POST_LOGOUT_REDIRECT_URI=http://localhost:9001/login
IAM_ALLOW_EMAIL_LINK=true
SANCTUM_TOKEN_EXPIRATION=300

后端可继续提供 scope、HTTP timeout、Discovery/JWKS 缓存时间和端点覆盖等高级配置,但默认应通过 Discovery 获取标准端点。

特别注意:

  • 不要把当前 .env.example 中的 DB_DATABASE 直接覆盖到生产;必须保留实际 OA MySQL 数据库名称。
  • IAM_OIDC_REDIRECT_URI 必须与 IAM Client 中登记的 Redirect URI 完全一致。
  • IAM_FRONTEND_CALLBACK 必须指向当前环境真实的 oain-web 地址。
  • Secret 只配置在后端运行环境。
  • 如果保留 Ed25519 旧密钥兼容,PHP 运行环境需启用 ext-sodium;当前 IAM 主链路为 RS256。
  1. OA 后端确认测试、生产的 OA API/Web 实际域名。
  2. IAM 负责人创建测试环境 OA Client,并安全交付 Client ID/Secret。
  3. OA 后端执行第 6.3 节的数据检查,处理重复身份。
  4. OA 后端合入代码和 migration,先运行 migrate --pretend,再迁移测试库。
  5. OA 后端配置共享缓存、OIDC 环境变量和 Sanctum 期限。
  6. OA Web 指向测试 API,完成登录、回调、角色和 return_to 联调。
  7. 验证 IAM Back-Channel Logout 能按 sid 精确撤销 OA Token。
  8. 完成安全与异常用例后,再创建/核对生产 Client 并按变更窗口发布。

发布依赖顺序建议为:数据库 migration → oacn-apioain-web 环境配置/发布。旧登录入口可暂时并存,因此无需为了本次接入立刻删除旧接口。

文件 作用
src/api/index.ts /auth/iam/start URL 和 ticket exchange 请求
src/pages/login/index.vue 统一身份登录入口
src/pages/login/iamCallback.vue ticket 回调、兑换、用户和角色加载
src/router/index.ts /iam/callback 公开路由
src/utils/safeRedirect.ts return_to 安全校验
src/utils/loginRedirect.ts 登录后统一跳转
src/utils/axios.ts OA Sanctum Bearer Token 与回调页 401 处理
src/utils/userInfo.ts Token、角色和用户信息存储
文件 作用
routes/api.php IAM 四个公开路由
app/Http/Controllers/Api/IamAuthController.php 登录、回调、ticket exchange、后通道退出
app/Repositories/IamOidcRepository.php Discovery、PKCE、Token、JWKS 和 JWT 校验
app/Services/IamUserMapper.php OA 用户匹配和首次绑定
app/Models/User.php IAM 绑定字段
config/services.php IAM OIDC 配置
config/sanctum.php OA Token 有效期
database/migrations/2026_08_28_000001_add_iam_fields_to_users_table.php OA 用户绑定字段 migration
database/migrations/2026_08_28_000002_add_iam_session_id_to_personal_access_tokens_table.php IAM sid migration

工作区中的这些文件是实现参考,不代表 migration 已在测试或生产 OA 数据库执行。后端同事必须以目标环境的 migration 记录和表结构为准。

Terminal window
php artisan test \
tests/Feature/IamAuthFeatureTest.php \
tests/Unit/IamOidcIdTokenTest.php \
tests/Unit/IamOidcPkceTest.php \
tests/Unit/IamReturnToValidationTest.php \
tests/Unit/IamUserMapperTest.php

oain-web 执行项目现有测试、类型检查和构建命令,并至少人工验证:

  • 从登录页进入统一身份登录;
  • callback URL 中 ticket 被及时清理;
  • data.token 被保存,/api/auth/user 能加载 OA 角色;
  • return_to 返回原站内页面;
  • ticket 无效时不产生 OA 会话;
  • callback 页面出现 401 时不会刷新丢失 ticket;
  • 密码登录和旧企业微信登录未被本次改造意外破坏。
  • state、nonce、ticket 和 Logout Token jti 重放均失败;
  • PKCE 只接受 S256;
  • 错误 issaudazp、签名、nonce、过期 Token 均失败;
  • UserInfo sub 与 ID Token 不一致时失败;
  • 未验证邮箱、占位邮箱、重复邮箱和重复企业微信 UserID 均不能自动绑定;
  • 无 OA 用户、停用用户或无 OA 角色不能进入 OA;
  • 恶意 return_to 不能跳出 OA 站点;
  • IAM 退出后,只撤销同一 sid 的 OA Token,其他会话继续有效;
  • 日志中不出现 Client Secret、Authorization Code、ID Token、Access Token、ticket 或完整 OA Token。
  • 四个 IAM 路由已合入 oacn-api
  • Authorization Code + PKCE S256 已启用
  • ID Token、UserInfo 和 Logout Token 校验完整
  • 用户按 sub → 企业微信 UserID → 已验证邮箱的顺序绑定
  • 未匹配用户不会自动创建
  • OA 状态和 Bouncer 角色仍为本地授权依据
  • users 三个 IAM 字段已迁移并核对唯一索引
  • personal_access_tokens.iam_session_id 已迁移并建立普通索引
  • ticket 一次性、短时有效,响应包含 data.token
  • 生产使用共享缓存
  • Back-Channel Logout 已按 sid 精确撤销 Token
  • 测试环境全链路和安全用例通过
  • Secret 未进入前端、仓库和日志
  • 生产域名、Redirect URI 和 Back-Channel Logout URI 已最终确认