Skip to content

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

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

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

本文交付给 CRM 后端同事,用于把旧版 CRM Laravel 后端和 CRM MySQL 数据库接入当前 IAM,实现企业统一身份登录。

本次涉及两个独立系统,必须先明确边界:

系统 技术栈 负责人 本次职责
IAM Better Auth 1.7.1、Hono、PostgreSQL IAM 负责人 创建和配置 CRM OAuth/OIDC Client,提供 issuer、Client ID、Client Secret 和 Claims;必要时修改 IAM 数据库
CRM API Laravel 9、Sanctum、Spatie Permission CRM 后端同事 实现 OIDC 登录、Token 校验、用户绑定、CRM Token 签发、Back-Channel Logout
CRM 数据库 MySQL CRM 后端同事 执行 Laravel migration,修改 userspersonal_access_tokens
CRM Web Vue 3 CRM 前端负责人 发起整页登录跳转、接收一次性 ticket、换取并保存 CRM Sanctum Token

本文不会要求 CRM 后端同事修改 IAM PostgreSQL,也不会要求 IAM 负责人直接修改 CRM MySQL。

项目 基线
IAM commit 7fa227324f0fac6af12fbad0172c1453f29aaf22
CRM API 原仓库 commit 765c42049bac02f1bd3d17ebbf590304a79f69e6
CRM API PHP ^8.0.2、Laravel ^9.19、Sanctum ^3.0
IAM Better Auth 1.7.1,OAuth 2.1/OIDC Provider
IAM JWT 算法 RS256,RSA 2048
CRM 数据库 MySQL

CRM API 工作区中已有一套目标实现作为参考,主要文件见第 10 节。后端同事应基于自己的后端分支实现、审查并执行 migration,不能把本地未提交文件直接当作已上线结果。

采用服务端 BFF 模式:

  • CRM Web 不保存 OIDC Client Secret。
  • CRM Web 不直接用 Authorization Code 交换 Token。
  • CRM API 使用 Authorization Code + PKCE S256 与 IAM 通信。
  • CRM API 必须验证 ID Token 签名及 OIDC Claims。
  • IAM 负责证明用户身份和 CRM 应用准入。
  • CRM 继续使用自己的 users、Sanctum Token、Spatie 角色、团队和业务权限。
  • IAM Access Token 不能作为 CRM 业务接口的 Bearer Token。
  • IAM app_role 当前只用于日志或后续扩展,不能覆盖 CRM 本地角色。
  • 默认不自动创建 CRM 用户;用户必须先存在于 CRM。
sequenceDiagram
    autonumber
    participant U as 浏览器
    participant W as CRM Web
    participant C as CRM API
    participant I as IAM
    participant M as CRM MySQL

    U->>W: 点击统一身份登录
    W->>C: GET /api/auth/iam/start?return_to=/...
    C->>C: 生成 state、nonce、PKCE 并写共享缓存
    C-->>U: 302 IAM authorize
    U->>I: 企业微信登录并授权
    I-->>C: 302 callback?code=&state=
    C->>I: 服务端用 code + verifier 换 Token
    C->>I: 拉取 JWKS 并验证 ID Token
    C->>I: 使用 Access Token 请求 UserInfo
    C->>M: 匹配并绑定 CRM 用户
    C-->>U: 302 CRM Web /iam/callback?ticket=...
    W->>C: POST /api/auth/iam/exchange
    C->>M: 创建带 IAM sid 的 Sanctum Token
    C-->>W: 返回 CRM Token 和本地角色

本节由 IAM 负责人完成,CRM 后端同事只负责提供 CRM API/Web 的实际地址并参与核对。

建议通过 IAM 管理端创建,不建议直接手写 IAM 数据库记录。配置如下:

配置项
Client Name CRM
Client 类型 机密客户端
Redirect URI https://<crm-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://<crm-api-domain>/api/auth/iam/backchannel-logout
Back-Channel Logout Session Required true
Skip Consent 按产品要求;内部系统可评估开启

Back-Channel Logout URI 在生产必须是外部可访问的 HTTPS 地址,不能带凭据和 fragment。本地开发无法直接接收 IAM 的服务器间回调时,可先验证登录主流程,再在可公网访问的测试环境验收统一下线。

Redirect URI 必须逐字符一致。协议、域名、端口、路径或末尾 / 任一不同都会导致授权失败。

IAM 负责人通过安全渠道提供:

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

Client Secret 只允许进入 CRM API 的 Secret 管理系统,不得发给 CRM Web,不得出现在仓库、群聊截图、日志或监控事件中。

CRM 需要使用以下 Claims:

Claim 用途
sub IAM 稳定用户标识,绑定后作为 CRM 的首要匹配键
sid IAM 会话标识,用于 Back-Channel Logout 精确撤销 CRM Token
email 首次绑定 CRM 用户的候选邮箱
email_verified 只有严格布尔值 true 才允许邮箱首次绑定
wecom_user_id 优先绑定已有 CRM 企业微信用户
app_role 当前仅记录,不覆盖 CRM Spatie 角色

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

这些路由不能放在 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 IAM 回调;交换 Token、校验身份、绑定用户、签发一次性 ticket
POST /api/auth/iam/exchange 一次性 ticket 换 CRM Sanctum Token
POST /api/auth/iam/backchannel-logout 验证 IAM Logout Token,按 sid 撤销 CRM Token

请求:

GET /api/auth/iam/start?return_to=/dashboard

后端必须:

  1. 检查 IAM 配置完整。
  2. 校验 return_to 只能是 CRM 站内路径:以 / 开头、不能以 // 开头、不能包含 scheme、反斜杠或用户信息形式。
  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. 拒绝或安全处理 IAM 返回的 error
  2. 校验 codestate 存在。
  3. 使用 Cache::pull 一次性消费 state 上下文,拒绝过期或重放。
  4. 在 CRM API 服务端调用 Token Endpoint,使用 client_secret_basic 和原始 code_verifier
  5. 要求 Token 响应同时包含 id_tokenaccess_token
  6. 通过 IAM Discovery/JWKS 验证 ID Token。
  7. 要求 ID Token 包含非空 sid
  8. 使用 Access Token 调用 UserInfo。
  9. 按第 6 节规则匹配并绑定 CRM 用户。
  10. 再次校验 CRM 本地 status 为启用状态。
  11. 创建 32 字节随机、默认 60 秒有效、仅可使用一次的 ticket。
  12. 302 跳转到 CRM Web 的 /iam/callback?ticket=...

URL 中只能携带随机 ticket,不能携带 IAM Access Token、ID Token、CRM Token 或用户资料。

请求:

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

后端必须:

  1. 使用 Cache::pull 一次性消费 ticket。
  2. 区分“已使用”和“不存在/已过期”,两者都拒绝登录。
  3. 再次查询 CRM 用户并检查 status
  4. 复用原 CRM 登录响应结构和本地角色逻辑。
  5. 创建 CRM Sanctum Token,并把 ticket 中的 IAM sid 写入 personal_access_tokens.iam_session_id
  6. 只把 CRM Sanctum Token 返回给前端。

成功响应应与旧 /api/auth/login 的前端消费方式兼容,至少包含:

{
"code": 200,
"data": {
"access_token": "<crm-sanctum-token>",
"token_type": "Bearer",
"user": {
"id": 1,
"name": "张三",
"email": "zhangsan@example.com"
},
"role": ["sales"],
"team_leader": 0
}
}

IAM 注销会向 CRM API POST:

POST /api/auth/iam/backchannel-logout
Content-Type: application/x-www-form-urlencoded
logout_token=<signed-jwt>

CRM 必须验证:

  • Header typ === logout+jwt
  • 签名和 kid
  • issaudiatexp
  • 非空 jtisidsub
  • events 包含 http://schemas.openid.net/event/backchannel-logout
  • 不能包含 nonce

验证通过后:

  1. jti 建立短期防重放缓存。
  2. 删除 personal_access_tokens.iam_session_id = sid 的 CRM Token。
  3. 重复 Logout Token 返回成功但不重复处理。
  4. 日志只记录必要的 sid/sub 和撤销数量,不记录完整 Logout Token。

不能只 Base64 解码 JWT Payload。必须完成:

项目 规则
算法 当前必须支持 RS256;如保留 EdDSA,只能作为明确白名单兼容
kid 必须在 IAM JWKS 中命中;未命中时清理 JWKS 缓存并允许后续重新拉取
iss 严格等于 IAM_OIDC_ISSUER
aud 字符串或数组中必须包含 CRM Client ID
azp 存在时必须等于 CRM Client ID
exp 必须存在且未过期,可允许最多 60 秒时钟偏差
iat 必须存在,不能显著晚于当前时间
nonce 必须严格等于 start 阶段缓存值
sub 必须是非空字符串
sid 当前 CRM 方案要求存在,用于统一下线

必须拒绝 none、HS256 和其他未配置算法,不能根据 Token Header 动态放宽白名单。

  • 保留 ext-sodium,用于兼容旧 EdDSA/Ed25519 JWK;当前 RS256 使用 OpenSSL。
  • Discovery 和 JWKS 可分别缓存 3600 秒。
  • state、PKCE、ticket 和 logout jti 必须写共享缓存。
  • CRM API 多实例生产环境必须使用 Redis 等共享 Cache Driver,不能使用每实例独立 file cache。
  • 所有实例必须进行时间同步。

CRM 数据库由 CRM 后端同事负责修改。IAM 负责人不执行以下 SQL 或 migration。

字段 Laravel 定义 约束 用途
iam_subject string nullable unique IAM sub,绑定后的稳定身份主键
iam_linked_at timestamp nullable 首次绑定时间
iam_last_login_at timestamp nullable 最近一次 IAM 登录时间

目标 migration:

Schema::table('users', function (Blueprint $table) {
$table->string('iam_subject')->nullable()->unique()->after('wechat_work_userid');
$table->timestamp('iam_linked_at')->nullable()->after('iam_subject');
$table->timestamp('iam_last_login_at')->nullable()->after('iam_linked_at');
});

User Model 同步修改:

  • $fillable 增加 iam_subjectiam_linked_atiam_last_login_at
  • $casts 将两个时间字段设为 datetime
字段 Laravel 定义 约束 用途
iam_session_id string(191) nullable index 保存 IAM ID Token 的 sid,用于精确撤销 CRM Token

目标 migration:

Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->string('iam_session_id', 191)->nullable()->index();
});

不要给 iam_session_id 加 unique:同一 IAM Session 可以对应多个 CRM Token。

新增字段本身不需要批量回填,但必须先检查旧 CRM 用户数据是否会导致首次绑定歧义:

-- 企业微信 UserID 重复;必须先人工确认
SELECT wechat_work_userid, COUNT(*) AS cnt
FROM users
WHERE wechat_work_userid IS NOT NULL
AND wechat_work_userid <> ''
AND deleted_at IS NULL
GROUP BY wechat_work_userid
HAVING COUNT(*) > 1;
-- 邮箱忽略大小写后重复;开启邮箱首次绑定前必须处理
SELECT LOWER(email) AS normalized_email, COUNT(*) AS cnt
FROM users
WHERE email IS NOT NULL
AND email <> ''
AND deleted_at IS NULL
GROUP BY LOWER(email)
HAVING COUNT(*) > 1;
-- 用户状态分布,确认旧数据中的启用值
SELECT status, COUNT(*) AS cnt
FROM users
WHERE deleted_at IS NULL
GROUP BY status;

如果存在重复,不能依赖 Eloquent first() 随机绑定,必须在开放 IAM 登录前清理或人工确定归属。

后端同事先备份 CRM MySQL,再执行:

Terminal window
php artisan migrate --pretend
php artisan migrate

执行后检查:

SHOW COLUMNS FROM users LIKE 'iam_%';
SHOW INDEX FROM users WHERE Column_name = 'iam_subject';
SHOW COLUMNS FROM personal_access_tokens LIKE 'iam_session_id';
SHOW INDEX FROM personal_access_tokens WHERE Column_name = 'iam_session_id';

验收条件:

  • users.iam_subject 可空且有唯一索引;
  • 两个 IAM 时间字段可空;
  • personal_access_tokens.iam_session_id 可空且有普通索引;
  • 旧用户、旧 Sanctum Token、角色、团队和业务数据未被删除或重写。

后端按以下顺序匹配,命中后停止:

  1. users.iam_subject === sub
  2. users.wechat_work_userid === wecom_user_id,且 iam_subject IS NULL
  3. 仅迁移期 IAM_ALLOW_EMAIL_LINK=true 时,使用 IAM 已验证邮箱匹配 iam_subject IS NULL 的 CRM 用户。

邮箱绑定必须同时满足:

  • email_verified 或兼容字段 emailVerified 严格为布尔值 true
  • 邮箱格式合法;
  • 不是 @wecom.invalid@wecom.com 占位邮箱;
  • CRM 邮箱忽略大小写后完全一致;
  • CRM 用户尚未绑定其他 iam_subject

首次匹配成功后写入:

iam_subject = IAM sub
iam_linked_at = 当前时间
iam_last_login_at = 当前时间

后续登录只按 iam_subject 匹配;邮箱或企业微信资料变化不应把账号漂移到另一个 CRM 用户。

当前范围明确不做:

  • 不自动创建 CRM 用户;
  • 不把 app_role 写入 Spatie 角色;
  • 不自动分配团队、客户、订单或其他业务权限;
  • 不允许未验证邮箱或占位邮箱绑定;
  • 不允许一个 CRM 用户重新绑定另一个 sub
  • 不删除旧账号密码登录和旧企业微信登录,是否下线由后续切换计划决定。

全员完成首次绑定并核对无误后,建议将 IAM_ALLOW_EMAIL_LINK 改为 false,只保留 iam_subject 和企业微信 UserID 的确定性匹配。

CRM 后端需要配置:

IAM_OIDC_ISSUER=https://iam.cq-i.cn/api/auth
IAM_OIDC_CLIENT_ID=<由 IAM 提供>
IAM_OIDC_CLIENT_SECRET=<由 IAM 安全提供>
# 必须与 IAM Client 登记值完全一致
IAM_OIDC_REDIRECT_URI=https://<crm-api-domain>/api/auth/iam/callback
# CRM Web 公开回调页和登录页
IAM_FRONTEND_CALLBACK=https://<crm-web-domain>/iam/callback
IAM_POST_LOGOUT_REDIRECT_URI=https://<crm-web-domain>/login
IAM_OIDC_SCOPES="openid profile email"
IAM_OIDC_TOKEN_ENDPOINT_AUTH_METHOD=client_secret_basic
IAM_OIDC_STATE_TTL=300
IAM_OIDC_TICKET_TTL=60
IAM_ALLOW_EMAIL_LINK=true
IAM_OIDC_HTTP_TIMEOUT=15
IAM_OIDC_DISCOVERY_CACHE_TTL=3600
IAM_OIDC_JWKS_CACHE_TTL=3600
# CRM 本地 Sanctum Token 过期时间,单位为分钟
SANCTUM_TOKEN_EXPIRATION=720

修改环境变量后执行:

Terminal window
php artisan optimize:clear

生产环境通常不需要手工配置 authorize/token/userinfo/JWKS 地址,优先从 Discovery 获取。仅联调或故障隔离时使用显式 endpoint override。

本节用于后端联调,不要求 CRM 后端同事修改 Vue 代码。

CRM Web 发起登录必须使用整页跳转,不能用 Axios 请求 start 后期待自动完成跨站 302:

const url = new URL(`${CRM_API_URL}/auth/iam/start`)
url.searchParams.set('return_to', '/dashboard')
window.location.assign(url.toString())

CRM Web /iam/callback 页面应:

  1. 读取 ticket
  2. 立即使用 history.replaceState 清除地址栏中的 ticket;
  3. POST /api/auth/iam/exchange
  4. 按旧账号密码登录方式保存 CRM Sanctum Token;
  5. 恢复登录前的安全站内路径;
  6. 展示 erroriam_error,但不记录 ticket。

ticket 不能写入 localStorage、埋点、日志或错误上报。CRM Web 当前仓库尚未实现 IAM 回调页面和 IAM API 封装,因此后端上线前必须与前端负责人确认联调版本。

  1. CRM 后端同事备份 CRM MySQL,执行第 5.3 节数据检查。
  2. CRM 后端部署两条 migration。
  3. CRM 后端部署 OIDC 代码和共享缓存配置,但暂不向全部用户开放入口。
  4. IAM 负责人创建 CRM Client,配置精确 Redirect URI 和 Back-Channel Logout URI。
  5. IAM 负责人通过安全渠道交付 Client ID/Secret。
  6. CRM 后端配置环境变量并执行 php artisan optimize:clear
  7. CRM 前端部署登录入口和 /iam/callback
  8. 使用一个已存在、启用且角色正确的 CRM 用户进行首次绑定。
  9. 验收登录、重复 ticket、停用用户、错误 state/nonce 和统一下线。
  10. 小范围用户验证通过后再逐步开放。

当前本地参考实现涉及:

文件 后端同事需要完成的内容
app/Http/Controllers/IamAuthController.php start、callback、exchange、backchannel logout
app/Repositories/IamOidcRepository.php Discovery、PKCE、Token、UserInfo、JWKS、ID/Logout Token 验证
app/Services/IamUserMapper.php IAM Claims 到 CRM 用户的匹配与首次绑定
app/Models/User.php 新增 IAM 字段 fillable/casts
routes/api.php 注册四个公开 IAM 路由
config/services.php IAM OIDC 配置
config/sanctum.php CRM Token 过期时间
.env.example IAM 配置项说明,不包含真实 Secret
composer.json 声明 ext-sodium 兼容依赖
database/migrations/2026_08_26_000001_add_iam_fields_to_users_table.php 修改 users
database/migrations/2026_08_28_000002_add_iam_session_id_to_personal_access_tokens_table.php 修改 personal_access_tokens

至少执行:

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

必须覆盖:

  • PKCE S256、state、nonce 和 Authorization URL;
  • ID Token RS256 验签及错误 iss/aud/azp/exp/iat/nonce;
  • Logout Token typ/events/jti/sid/sub 和重放防护;
  • ticket 过期和重复消费;
  • iam_subject、企微 UserID、已验证邮箱三种匹配路径;
  • 占位邮箱、未验证邮箱、禁用用户和不存在用户;
  • return_to Open Redirect 防护;
  • CRM Token 正确写入 iam_session_id
  • Back-Channel Logout 只撤销对应 sid 的 CRM Token。
  • IAM Discovery 的 issuer 与 IAM_OIDC_ISSUER 完全一致。
  • IAM Client Redirect URI 与 CRM 配置逐字符一致。
  • CRM Client 为机密客户端、client_secret_basic、Authorization Code + PKCE。
  • CRM API 能访问 IAM Discovery、JWKS、Token 和 UserInfo。
  • CRM Web 不包含 Client Secret。
  • 首次登录后 MySQL 正确写入 users.iam_subject 和时间字段。
  • 再次登录只按 iam_subject 成功,不依赖邮箱继续一致。
  • CRM 返回的是本地 Sanctum Token,本地角色和团队权限未改变。
  • 同一 ticket 第二次提交被拒绝。
  • CRM 停用用户不能通过 IAM 登录绕过本地状态。
  • IAM 注销后,对应 iam_session_id 的 CRM Token 被删除,其他会话不受影响。
  • 日志中没有 Client Secret、Authorization Code、ID Token、Access Token、ticket 或完整 Claims。
  1. 全员绑定完成后设置 IAM_ALLOW_EMAIL_LINK=false
  2. 定期清理过期 Sanctum Token;不能只依赖请求时的 expiration 判断。
  3. 监控 OIDC callback 失败率、state/ticket 过期、JWKS 拉取失败和用户绑定失败。
  4. Client Secret 泄露时,由 IAM 负责人重置 Secret,CRM 后端同步更新并清理配置缓存。
  5. 用户解绑或换绑必须走管理员审批和审计流程,不允许直接通过登录自动覆盖 iam_subject
  6. 旧账号密码和旧企微登录的下线应单独制定灰度计划,本次接入不自动删除旧能力。
  • CRM Client ID 和通过安全渠道交付的 Client Secret;
  • issuer、Redirect URI、Back-Channel Logout URI 配置截图或脱敏记录;
  • 测试用户在 IAM 的 subwecom_user_id 和邮箱验证状态核对结果。
  • 实际后端 commit/分支;
  • 两条 Laravel migration 及执行结果;
  • 第 5.3 节重复数据检查结果;
  • 自动化测试结果;
  • 登录和 Back-Channel Logout 联调记录;
  • 未完成项、兼容项和旧登录下线计划。