增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
这个提交包含在:
@@ -340,7 +340,8 @@ business role assignment -> separate administrator-audited operation
|
||||
- Binding DB: unique `WecomIdentityBinding.employee_id` and
|
||||
`WecomIdentityBinding.external_user_id`.
|
||||
- Audit actions: `AUTH_WECOM_AUTO_BOUND` followed by
|
||||
`AUTH_WECOM_LOGIN_SUCCESS`; the latter records only `bindingSource`.
|
||||
`AUTH_WECOM_LOGIN_SUCCESS`; the latter records only `bindingSource` and the
|
||||
fixed allowlisted `entrySource`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -411,6 +412,105 @@ OAuth userid -> existing binding OR exact userid/unique enterprise email match
|
||||
-> transactional binding -> redacted audit -> direct WeCom session
|
||||
```
|
||||
|
||||
## Scenario: Hosted PC WeCom Web Login
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: adding or changing ordinary-browser WeCom login, the public WeCom
|
||||
config response, OAuth state/callback handling or login-entry audit.
|
||||
- Purpose: let an existing OA employee use the WeCom-hosted Web login page from
|
||||
a normal PC browser without loading third-party scripts beside OA password or
|
||||
email-code inputs.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Public config: `GET /api/auth/wecom/config` returns
|
||||
`pcWebLoginEnabled: boolean` and `pcLoginUrl: string | null` separately from
|
||||
the workbench `loginUrl`.
|
||||
- OA entry: `GET /api/auth/wecom/login?returnTo=<internal-path>&entrySource=pc_qr`.
|
||||
- Provider entry: `https://login.work.weixin.qq.com/wwlogin/sso/login` with
|
||||
`login_type=CorpApp`, CorpID, AgentID, callback and signed state.
|
||||
- Shared callback: `GET /api/auth/wecom/callback?code=<one-time-code>&state=<signed-state>`.
|
||||
- Env gate: `WECOM_PC_WEB_LOGIN_ENABLED=false` by default.
|
||||
- Audit source: `entrySource=pc_qr | wecom_workbench`; `mode` remains
|
||||
`login | bind | onboard`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- The OA login page uses a normal redirect to the WeCom-hosted page. Do not add
|
||||
`@wecom/jssdk`, a WeCom CDN script or a QR iframe to the credential page.
|
||||
- `entrySource` is independent of `mode`, is validated against a fixed server
|
||||
allowlist and never accepts `pc_qr` for bind/onboard. Missing source in an
|
||||
in-flight legacy state means `wecom_workbench`; an unknown signed value fails.
|
||||
- PC state remains a short-lived HMAC-signed payload and is correlated to the
|
||||
initiating browser with a short-lived HttpOnly, Secure-in-production,
|
||||
SameSite=Lax cookie. Validate and clear it before code exchange.
|
||||
- The cookie is a CSRF/browser-correlation control, not server-side nonce
|
||||
storage. Normal-browser replay fails after cookie clearing; deliberate replay
|
||||
of both URL and old cookie is finally stopped by WeCom's one-time, five-minute
|
||||
code contract and must not create a second OA session.
|
||||
- Reuse existing userid mapping, fail-closed auto-binding, session limit,
|
||||
device fingerprint, returnTo allowlist and structured mapping notices.
|
||||
- Successful login audit contains only `bindingSource` and `entrySource`.
|
||||
Code, state, nonce, cookies, full userid, Secret and raw provider payload stay
|
||||
out of logs, audit and browser error details.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- PC env gate off or base entry config incomplete -> `pcLoginUrl=null`; direct
|
||||
PC entry is unavailable while workbench/password/email-code behavior remains.
|
||||
- `pc_qr + bind/onboard` or unknown query source -> validation failure before
|
||||
redirecting to WeCom.
|
||||
- Tampered/expired state, unknown callback source, missing/mismatched/expired
|
||||
correlation cookie or missing code -> fail before provider code exchange.
|
||||
- Signed PC state after the PC gate is turned off -> clear the cookie and fail.
|
||||
- Existing binding/unique auto-match -> shared session flow; mapping required,
|
||||
conflict or inactive -> existing stable notice and no session.
|
||||
- Replayed URL plus old cookie -> provider rejects the already-consumed code;
|
||||
OA session and success audit counts remain unchanged.
|
||||
- External/absolute/protocol-relative returnTo -> safe internal default.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: normal PC browser redirects to the hosted page, scans, returns with a
|
||||
correlated callback and reaches the requested internal OA page.
|
||||
- Base: WeCom workbench continues through `snsapi_base` with
|
||||
`entrySource=wecom_workbench`; missing source remains compatible during a
|
||||
rolling deployment.
|
||||
- Bad: add `mode=qr`, load an SDK next to password fields, expose Secret in the
|
||||
frontend, accept callback without browser correlation, or claim a signed
|
||||
cookie is a server-consumed nonce.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests assert config gate, exact hosted host/path and parameters, signed
|
||||
source/state, secure cookie attributes and PC bind/onboard rejection.
|
||||
- Callback tests assert missing/mismatched/expired cookie, expired/tampered
|
||||
state, missing code, unknown source and gate-off failures occur before fetch.
|
||||
- Success/replay tests assert shared mapping and session behavior, sanitized
|
||||
`entrySource`, cookie clearing and exactly one created session.
|
||||
- Frontend E2E asserts PC-only button, safe returnTo/device fingerprint and no
|
||||
duplicate button in a WeCom user agent; typecheck and production build pass.
|
||||
- Run full backend tests, Prisma validation, external/readiness contracts,
|
||||
sensitive-content scan, process/Trellis gates and `git diff --check`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
normal browser -> mode=qr -> third-party SDK on OA credential page
|
||||
-> callback trusts signed state alone -> audit raw userid
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
normal browser -> entrySource=pc_qr -> OA sets correlation cookie
|
||||
-> WeCom-hosted login -> shared callback validates state + cookie
|
||||
-> one-time code -> existing userid mapping -> sanitized session audit
|
||||
```
|
||||
|
||||
## Scenario: WeCom Directory Export With Optional Phone
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,143 @@
|
||||
# PC 企业微信扫码登录设计
|
||||
|
||||
## 1. Design Goal
|
||||
|
||||
在普通 PC 浏览器提供企业微信托管 Web 登录入口,同时复用现有身份映射、会话签发和回调错误处理。首版不向 OA 登录页注入企微脚本,不改变工作台免登、绑定或 onboard 流程。
|
||||
|
||||
官方依据:
|
||||
|
||||
- 企业微信《开始开发》:新版 Web 登录支持移动端企微扫码和企微桌面端快速登录,并要求自建应用开启“企业微信授权登录”。
|
||||
- <https://developer.work.weixin.qq.com/document/path/98151>
|
||||
- 企业微信《Web登录组件》:托管页链接为 `https://login.work.weixin.qq.com/wwlogin/sso/login`,自建应用使用 `login_type=CorpApp`、CorpID、AgentID、URL 编码后的回调地址和 state。
|
||||
- <https://developer.work.weixin.qq.com/document/path/98152>
|
||||
- 企业微信《获取用户登录身份》:Web 登录 code 继续由 `/cgi-bin/auth/getuserinfo` 消费,code 一次有效且 5 分钟过期。
|
||||
- <https://developer.work.weixin.qq.com/document/path/98176>
|
||||
|
||||
## 2. Current Architecture
|
||||
|
||||
```text
|
||||
企微工作台 WebView
|
||||
-> /api/auth/wecom/login
|
||||
-> open.weixin.qq.com/connect/oauth2/authorize
|
||||
-> /api/auth/wecom/callback
|
||||
-> auth/getuserinfo
|
||||
-> userid 映射/自动映射
|
||||
-> OA session + 审计 + returnTo
|
||||
```
|
||||
|
||||
现有 `mode` 只表达 `login / bind / onboard`。回调会把未知 mode 收敛为 `login`,因此不能用 `mode=qr` 承担入口区分。
|
||||
|
||||
## 3. Target Architecture
|
||||
|
||||
```text
|
||||
普通 PC 浏览器
|
||||
-> 登录页“企业微信扫码登录”
|
||||
-> /api/auth/wecom/login?entrySource=pc_qr
|
||||
-> 写入短期浏览器关联 cookie
|
||||
-> login.work.weixin.qq.com/wwlogin/sso/login
|
||||
-> /api/auth/wecom/callback?code=...&state=...
|
||||
-> 校验签名 state + 过期时间 + entrySource + cookie nonce
|
||||
-> 清除关联 cookie
|
||||
-> 复用 auth/getuserinfo、userid 映射、session 与安全回跳
|
||||
|
||||
企微工作台 WebView
|
||||
-> 保持现有入口和授权 URL
|
||||
-> callback 缺少旧版 entrySource 时兼容解释为 wecom_workbench
|
||||
```
|
||||
|
||||
## 4. Contracts
|
||||
|
||||
### 4.1 Entry configuration
|
||||
|
||||
`GET /api/auth/wecom/config` 在现有返回中增加:
|
||||
|
||||
- `pcWebLoginEnabled: boolean`
|
||||
- `pcLoginUrl: string | null`
|
||||
|
||||
新增非敏感生产开关 `WECOM_PC_WEB_LOGIN_ENABLED`,默认关闭。只有现有企微 OAuth 配置完整且该开关打开时返回 PC 登录 URL。该开关允许独立回滚扫码入口,不影响工作台免登。
|
||||
|
||||
现有 `loginUrl` 继续只代表工作台入口,避免客户端误把两种企微授权地址混用。
|
||||
|
||||
### 4.2 Entry source
|
||||
|
||||
- 请求白名单:`wecom_workbench | pc_qr`。
|
||||
- `/wecom/login` 未传值时默认为 `wecom_workbench`,保持既有调用兼容。
|
||||
- state 中写入白名单化 `entrySource`。
|
||||
- 回调兼容部署前已经发出的 state:缺少 `entrySource` 时按 `wecom_workbench` 处理;存在未知值时失败关闭,不静默降级。
|
||||
- `mode` 不增加新值,绑定与 onboard 不允许使用 `pc_qr`。
|
||||
|
||||
### 4.3 Hosted Web login URL
|
||||
|
||||
仅服务端构造:
|
||||
|
||||
```text
|
||||
https://login.work.weixin.qq.com/wwlogin/sso/login
|
||||
?login_type=CorpApp
|
||||
&appid=<CorpID>
|
||||
&agentid=<AgentID>
|
||||
&redirect_uri=<encoded callback>
|
||||
&state=<encoded signed state>
|
||||
```
|
||||
|
||||
CorpID、AgentID 和回调地址复用现有配置;Secret 不进入 URL 或前端。
|
||||
|
||||
### 4.4 Browser correlation and replay boundary
|
||||
|
||||
现有 state 是 `base64url(payload).HMAC-SHA256`,具备完整性和过期校验,但 nonce 没有消费状态。PC 扫码增加浏览器关联 cookie:
|
||||
|
||||
- HttpOnly、Secure(生产)、SameSite=Lax。
|
||||
- path 限制为 `/api/auth/wecom`。
|
||||
- 生命周期不长于 state 的 10 分钟。
|
||||
- cookie 保存服务端签名的 nonce/入口上下文,或保存与 state nonce 可恒定时间比较的不可伪造值。
|
||||
- callback 在调用企微 token/code 接口前校验;缺失、篡改或不匹配时失败关闭。
|
||||
- 无论回调成功还是已进入终止性失败,都清除关联 cookie;正常浏览器第二次回调因 cookie 已清除而在 OA 侧先失败。
|
||||
- 关联 cookie 是浏览器相关性/CSRF 控制,不是服务端一次性 nonce 存储。恶意客户端若同时保存并重放 URL 与旧 cookie,最终单次消费边界由企微 code 的“一次有效、5 分钟过期”合同保证;OA 不创建第二个会话。
|
||||
|
||||
工作台既有入口不强制增加该 cookie,避免扩大本任务兼容风险。若实施测试证明企微 WebView 可无差别支持,仍不顺手改变其合同。
|
||||
|
||||
### 4.5 Audit and privacy
|
||||
|
||||
成功登录继续使用 `AUTH_WECOM_LOGIN_SUCCESS`,extra 增加:
|
||||
|
||||
- `bindingSource`
|
||||
- `entrySource`
|
||||
|
||||
日志和审计不得记录 code、state、nonce、cookie、Secret、完整 userid 或企微原始响应。失败日志仅记录固定错误类别、request id、mode 和 entrySource。
|
||||
|
||||
### 4.6 Frontend behavior
|
||||
|
||||
- 普通浏览器在“企业入口”区域展示“企业微信扫码登录”按钮。
|
||||
- 点击按钮沿用当前安全 `returnTo` 与设备指纹拼接工具,跳转服务端 `pcLoginUrl`。
|
||||
- 企业微信 WebView 不展示该按钮,继续显示“一键进入”。
|
||||
- PC 入口不可用时不展示失效按钮;密码和邮箱验证码始终不受影响。
|
||||
- 不加载第三方 SDK、script 或 iframe。
|
||||
|
||||
## 5. Error Matrix
|
||||
|
||||
| Condition | Behavior |
|
||||
| --- | --- |
|
||||
| PC 开关关闭或基础配置缺失 | `pcLoginUrl=null`,登录页保留既有方式 |
|
||||
| 非法 `entrySource` | 400 或认证失败关闭,不重定向企微 |
|
||||
| `pc_qr` 搭配 bind/onboard | 参数校验失败,不进入外部授权 |
|
||||
| state 篡改/过期 | 401,不兑换 code |
|
||||
| PC 关联 cookie 缺失/篡改/nonce 不匹配 | 401,不兑换 code |
|
||||
| 正常浏览器重复回调 | cookie 已清除,OA 关联校验失败 |
|
||||
| 恶意客户端重放 URL 与旧 cookie | 企微拒绝重复 code;OA 不创建第二个会话 |
|
||||
| returnTo 外域或协议相对 URL | 回落安全默认页 |
|
||||
| 未映射/冲突/停用 | 复用现有结构化提示并失败关闭 |
|
||||
| 企微托管页/CDN 不可达 | OA 登录页不受影响,用户返回后可用密码/邮箱验证码 |
|
||||
|
||||
## 6. Compatibility and Rollout
|
||||
|
||||
- 无数据库迁移。
|
||||
- 缺少 `entrySource` 的短期旧 state 仍可完成工作台回调。
|
||||
- 先在企微管理后台人工只读核对授权登录与可信域名,再部署代码。
|
||||
- 部署后先保持 PC 开关关闭完成既有登录回归;经生产配置批准后开启并做一名已绑定 active 员工的真实扫码验收。
|
||||
- 回滚优先关闭 `WECOM_PC_WEB_LOGIN_ENABLED`;必要时回滚应用版本。两种方式都不影响已有登录入口。
|
||||
|
||||
## 7. Explicit Non-Decisions
|
||||
|
||||
- 不引入 `@wecom/jssdk`。
|
||||
- 不在本任务开启 CSP;托管页方案避免新增登录页第三方脚本,但不把既有 `contentSecurityPolicy: false` 宣称为已治理。
|
||||
- 不新增 nonce 数据表、Redis 或通用 OAuth 状态服务。
|
||||
- 不把 PC 扫码用于 bind/onboard。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,98 @@
|
||||
# PC 企业微信扫码登录实施计划
|
||||
|
||||
## 1. Preconditions
|
||||
|
||||
- [ ] 只读核对企微自建应用已开启“企业微信授权登录”。
|
||||
- [ ] 只读核对 OA 公网域名已配置为 OAuth 可信域名或 Web 网页授权回调域名,且回调地址仍指向现有 `/api/auth/wecom/callback`。
|
||||
- [x] 不在代码、日志、Trellis 或聊天中输出 CorpID、AgentID、Secret、真实 userid、code 或 state。
|
||||
- [x] 用户批准本 PRD、设计和实施计划后,才运行 `task.py start` 并修改产品代码。
|
||||
|
||||
## 2. Backend Implementation
|
||||
|
||||
- [x] 在配置层增加默认关闭的 `WECOM_PC_WEB_LOGIN_ENABLED`,同步示例环境、部署检查和运行时 readiness 合同。
|
||||
- [x] 扩展企微公开配置响应,分别返回工作台 `loginUrl` 与 PC `pcLoginUrl`,避免语义混用。
|
||||
- [x] 在 `/wecom/login` 增加固定白名单 `entrySource`,拒绝 `pc_qr + bind/onboard`。
|
||||
- [x] 增加企微托管 Web 登录 URL 构造函数,复用现有 CorpID、AgentID 和回调地址。
|
||||
- [x] 为 `pc_qr` 写入短期 HttpOnly/SameSite=Lax 浏览器关联 cookie,并把白名单入口与 nonce 写入签名 state。
|
||||
- [x] callback 在外部兑换前验证 state、entrySource、时效和 cookie nonce,并在终止路径清除 cookie;不把无状态 cookie 误报为服务端一次性 nonce 存储。
|
||||
- [x] 成功审计增加脱敏 `entrySource`,保持既有 `bindingSource`、映射和会话合同。
|
||||
|
||||
## 3. Frontend Implementation
|
||||
|
||||
- [x] 扩展企微配置类型并在普通浏览器登录页展示“企业微信扫码登录”按钮。
|
||||
- [x] 复用既有 returnTo 和设备指纹 URL 工具跳转服务端入口。
|
||||
- [x] 企微 WebView 继续展示工作台一键进入;PC 开关关闭或配置缺失时保持密码/邮箱验证码入口可用。
|
||||
- [x] 不安装、不加载 `@wecom/jssdk`,不加入第三方 script/iframe。
|
||||
|
||||
## 4. Automated Validation
|
||||
|
||||
### Backend focused tests
|
||||
|
||||
- [x] 配置 ready/disabled/misconfigured 与 `pcLoginUrl` 合同。
|
||||
- [x] 托管页 URL 的 host/path、`CorpApp`、CorpID、AgentID、回调和签名 state。
|
||||
- [x] `entrySource` 默认、白名单和非法组合。
|
||||
- [x] state 篡改、过期、缺 code、非法 returnTo。
|
||||
- [x] cookie 缺失、篡改、nonce 不匹配、回调后清除、正常浏览器重复回调,以及企微重复 code 不创建第二会话。
|
||||
- [x] 成功、未映射、冲突、OA/企微停用以及审计脱敏。
|
||||
- [x] 工作台、bind、onboard 回归。
|
||||
|
||||
建议命令:
|
||||
|
||||
```bash
|
||||
cd server && npm test -- tests/unit/routes/wecomAuth.test.js tests/unit/routes/auth.login.test.js
|
||||
cd server && npm run prisma:validate
|
||||
```
|
||||
|
||||
### Frontend focused tests
|
||||
|
||||
- [x] 普通浏览器显示 PC 企微按钮并构造安全跳转。
|
||||
- [x] 企微 WebView 不重复显示 PC 入口。
|
||||
- [x] 配置关闭/失败时既有登录方式仍可用。
|
||||
- [x] 既有错误提示不再要求用户先用密码绑定。
|
||||
|
||||
建议命令:
|
||||
|
||||
```bash
|
||||
cd client && npm run typecheck
|
||||
cd client && npm run build
|
||||
```
|
||||
|
||||
### Project gates
|
||||
|
||||
```bash
|
||||
node scripts/qa/check-process-contract.mjs --baseline --json
|
||||
node scripts/qa/audit-docs.mjs --check-registry
|
||||
node scripts/qa/check-trellis-governance.mjs
|
||||
python3 ./.trellis/scripts/task.py validate 07-30-pc-wecom-qr-login
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 5. Review and Documentation
|
||||
|
||||
- [x] 认证改动完成后做只读安全/权限 review,不由 reviewer 修改代码。
|
||||
- [x] 若实现形成稳定合同,更新 `.trellis/spec/hyb-oa/wecom-boundary.md`。
|
||||
- [x] 仅在确有操作差异时更新现有管理员手册、部署 checklist/runbook;不新建平行文档。
|
||||
- [ ] commit 使用中文并引用 `07-30-pc-wecom-qr-login`。
|
||||
|
||||
## 6. Production Gate
|
||||
|
||||
- [ ] 企微后台设置、生产环境配置、部署与真实扫码分别取得明确批准。
|
||||
- [ ] 部署后公网 `/ready`、前端构建标识、现有工作台登录及既有密码/邮箱验证码冒烟通过。
|
||||
- [ ] 开启 PC 开关后,由一名已映射 active 员工完成真实 PC 扫码或企微桌面端快速登录,并安全回跳原页面。
|
||||
- [ ] 确认审计有 `entrySource=pc_qr` 且无敏感字段。
|
||||
- [ ] 验证关闭 PC 开关后按钮消失、其他登录方式不受影响。
|
||||
|
||||
## 7. Interrupt and Rollback
|
||||
|
||||
- 真人报销链若出现阻断问题,本任务保持小步提交并可随时中断,优先处理真实业务阻断。
|
||||
- 生产首选回滚:关闭 `WECOM_PC_WEB_LOGIN_ENABLED` 并重建/重启受控服务。
|
||||
- 若后端回归异常,回滚本任务应用版本;不得删除既有企微绑定或会话数据。
|
||||
|
||||
## 8. Local Evidence (2026-07-30)
|
||||
|
||||
- 聚焦后端:`wecomAuth.test.js` 与 `check-wecom-runtime-ready.test.js` 共 37 项通过;补充重放断言后 `wecomAuth.test.js` 26 项通过。
|
||||
- 完整后端:169 个测试文件、1091 项通过。
|
||||
- Prisma schema validate 通过。
|
||||
- 前端 typecheck、生产 build 通过;`wecom-entry.spec.ts` 2 项通过。
|
||||
- 流程合同、文档审计(仅 22 个既有未注册文档 warning)、Trellis governance、task validate、敏感内容扫描和 `git diff --check` 通过。
|
||||
- 未执行企微后台写入、生产环境配置、部署或真实扫码;对应 Production Gate 保持未勾选。
|
||||
@@ -0,0 +1,87 @@
|
||||
# PC 企业微信扫码登录
|
||||
|
||||
## Goal
|
||||
|
||||
让已经完成 OA 与企业微信 userid 映射的员工,在普通 PC 浏览器访问 OA 登录页时,可以使用企业微信完成扫码或桌面端快速登录,不必输入 OA 密码或邮箱验证码;同时保留现有企业微信工作台免登链路。
|
||||
|
||||
## Scope
|
||||
|
||||
- 普通 PC 浏览器登录页的企微托管 Web 登录入口。
|
||||
- 服务端企微 Web 登录 URL、入口来源、短期浏览器关联 cookie、共享 callback 和登录审计。
|
||||
- 默认关闭的独立生产开关、配置/readiness 合同、回滚和真实扫码验收。
|
||||
- 现有工作台免登、绑定、onboard、密码和邮箱验证码登录的回归保护。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- 当前普通浏览器登录页只提供账号密码和企业邮箱验证码;企业微信入口文案要求用户从企微工作台打开 OA。
|
||||
- 当前后端已具备 `code -> userid -> OA 绑定/唯一自动映射 -> 会话签发` 全链路,并已覆盖未映射、映射冲突、账号停用和成功登录。
|
||||
- 当前移动/工作台入口使用企业微信“网页授权登录”;PC 普通浏览器应使用企业微信“Web 登录”入口,二者不是同一个授权地址。
|
||||
- 企业微信官方文档《Web登录组件》(2026-01-14)支持两种网站接入:使用 `@wecom/jssdk >= 2.3.2` 内嵌登录组件,或构造企微托管 Web 登录链接。
|
||||
- 自建应用 Web 登录使用 `login_type=CorpApp`、企业 CorpID、应用 AgentID、回调地址和 state;成功后回调携带 `code` 与原样 state。
|
||||
- 企业微信官方文档《获取用户登录身份》(2026-04-29)确认 Web 登录 code 仍由 `/cgi-bin/auth/getuserinfo` 消费,code 只能使用一次,5 分钟未使用自动过期;现有后端兑换接口可复用。
|
||||
- 首版采用点击按钮后跳转企业微信托管 Web 登录页,不在 OA 登录页内嵌企微 JS-SDK 或第三方脚本。这样不扩大密码与邮箱验证码页面的脚本信任边界,并让企微 CDN 或组件异常不影响 OA 既有登录表单。
|
||||
- 现有 state 是 HMAC 签名的短期 payload,不是 JWT;它包含过期时间和随机 nonce,但 nonce 当前没有持久化消费,实施时必须验证扫码入口的 CSRF 与重放边界,不能仅凭 nonce 字段宣称一次性防重放。
|
||||
- 当前 `mode` 表示 `login / bind / onboard` 业务流程。PC 扫码入口不得引入 `mode=qr`;入口来源应使用独立、固定白名单字段,例如 `entrySource=pc_qr | wecom_workbench`。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 普通 PC 浏览器入口
|
||||
|
||||
- 普通 PC 浏览器登录页展示清晰的“企业微信扫码登录”入口。
|
||||
- 点击入口后跳转企业微信托管 Web 登录页;OA 登录页不加载 `@wecom/jssdk`、企微 CDN 脚本或内嵌二维码 iframe。
|
||||
- 入口不得取代账号密码和邮箱验证码等应急方式。
|
||||
- 企业微信内置 WebView 继续使用当前工作台一键进入,不重复展示 PC 扫码入口。
|
||||
|
||||
### R2 复用既有身份与会话链路
|
||||
|
||||
- Web 登录成功后的 code 复用现有 userid 获取、OA 绑定查找、唯一自动映射、会话签发、设备指纹和安全回跳逻辑。
|
||||
- 已绑定且 active 的员工可直接登录;不要求先用 OA 用户名密码完成首次绑定。
|
||||
- 未映射、映射冲突、OA/企微账号停用时继续失败关闭,并显示现有可理解提示。
|
||||
- 不改变 `bind` 和 `onboard` 两条业务流程。
|
||||
|
||||
### R3 入口来源与审计
|
||||
|
||||
- `mode` 保持业务语义,不承担入口区分。
|
||||
- `entrySource` 只接受服务端固定白名单值,未知值不得进入审计或影响授权地址选择。
|
||||
- 成功登录审计记录入口来源和既有 binding source,但不记录 code、state、userid、Secret 或原始企微响应。
|
||||
|
||||
### R4 安全边界
|
||||
|
||||
- state 必须服务端签名、短时有效、校验回调来源上下文,并继续限制 returnTo,防止开放重定向。
|
||||
- PC 扫码请求必须通过短期 HttpOnly、SameSite=Lax 同站关联 cookie 将随机 nonce 与发起浏览器绑定;回调校验后立即清除该 cookie,正常浏览器重复回调在 OA 侧失败关闭,不新增数据库 nonce 表。若恶意客户端同时重放回调 URL 与旧 cookie,则由企微一次性 code 阻止第二次兑换和会话创建。
|
||||
- 实施前通过测试明确验证 state 篡改、过期、入口来源伪造、code 缺失、企微拒绝授权和重复回调行为。
|
||||
- 前端不得接触应用 Secret;Web 登录 URL 由服务端按受控配置构造。
|
||||
|
||||
### R5 配置、上线与回滚
|
||||
|
||||
- 生产启用前只读核对现有自建应用是否已开启“企业微信授权登录”,以及 OA 域名是否满足 OAuth 可信域名或 Web 网页授权回调域名要求。
|
||||
- 若需要修改企微后台、生产环境变量、部署或真实扫码验收,必须另行获得明确批准。
|
||||
- 功能应具备独立开关或可安全退回现有登录页的配置边界;回滚不得影响工作台免登、密码登录和邮箱验证码登录。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 普通 PC 浏览器登录页出现明确的企业微信 Web 登录入口,企业微信内置 WebView 仍走原工作台入口。
|
||||
- [ ] 已映射 active 员工使用手机企微扫码后进入原请求的安全 OA 页面,无需 OA 密码或首次绑定。
|
||||
- [ ] 已登录企微桌面端时,可按企微官方组件能力完成快速登录或扫码登录。
|
||||
- [ ] 未映射、冲突、OA 停用和企微停用账号均失败关闭,页面提示不引导用户先用密码绑定。
|
||||
- [ ] 登录成功审计可区分 `pc_qr` 与 `wecom_workbench`,且不含 code、state、Secret、完整 userid 或原始 payload。
|
||||
- [ ] state 篡改、过期、非法入口来源、缺 code、重复使用 code 和恶意 returnTo 的自动化负例通过。
|
||||
- [ ] PC 扫码回调缺少、篡改或不匹配浏览器关联 cookie 时失败关闭且不调用企微 code 兑换接口;正常浏览器回调后 cookie 被清除,企微一次性 code 回归证明不会创建第二个会话。
|
||||
- [ ] 现有工作台登录、绑定、onboard、账号密码和邮箱验证码回归通过。
|
||||
- [ ] 本地后端/前端门禁通过;获得部署批准后,生产配置核验、公网构建标识、真实 PC 扫码和回滚检查通过,之后才通知用户可测试。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 后端单元测试覆盖 PC 配置和托管 URL、`entrySource` 白名单、state 和浏览器关联 cookie、安全回跳、成功/失败映射、审计脱敏及现有工作台/bind/onboard 回归。
|
||||
- 前端测试与构建覆盖普通浏览器按钮、企微 WebView 入口隔离、配置关闭时降级,以及 returnTo/设备指纹传递。
|
||||
- 执行 Prisma validate、前后端 typecheck/build、流程合同、文档审计、Trellis governance、task validate 和 `git diff --check`。
|
||||
- 生产阶段另行批准后执行企微后台配置核验、公网 readiness/构建标识、现有登录回归、真实 PC 扫码、审计抽查和关闭开关回滚验证。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不建设新的 OA 账号体系,不要求逐人设置或分发初始密码。
|
||||
- 不改变企微通讯录、userid 与 OA 账户的既有映射规则。
|
||||
- 不扩展到微信个人号、第三方企业、外部联系人或多租户 SSO。
|
||||
- 不在本任务重做找回密码、邮箱验证码或企业微信工作台页面体验。
|
||||
- 不把 PC 扫码入口复用于绑定或新员工 onboard,除非后续任务单独批准。
|
||||
- 不引入企微 JS-SDK、站内二维码 iframe,也不在本任务开启全站 CSP 改造;CSP 基线另行治理。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "pc-wecom-qr-login",
|
||||
"name": "pc-wecom-qr-login",
|
||||
"title": "PC 企业微信扫码登录",
|
||||
"description": "为普通 PC 浏览器增加企业微信 Web 扫码登录入口,复用现有 userid 映射、会话签发和审计链路。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "server,client,deploy,docs",
|
||||
"package": "hyb-oa",
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-07-30",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export type WecomEntryConfig = {
|
||||
corpId: string;
|
||||
agentId: string;
|
||||
loginUrl: string | null;
|
||||
pcWebLoginEnabled: boolean;
|
||||
pcLoginUrl: string | null;
|
||||
bindEnabled: boolean;
|
||||
defaultReturnTo: string;
|
||||
status: "ready" | "disabled" | "misconfigured";
|
||||
|
||||
@@ -89,6 +89,9 @@ export function LoginPage() {
|
||||
|
||||
const wecomConfig = wecomConfigQuery.data;
|
||||
const wecomEntry = wecomConfig?.entryEnabled && wecomConfig.loginUrl ? wecomConfig : null;
|
||||
const pcWecomEntry = !isWecomContext && wecomConfig?.pcWebLoginEnabled && wecomConfig.pcLoginUrl
|
||||
? wecomConfig
|
||||
: null;
|
||||
const wecomNotice = useMemo(
|
||||
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
|
||||
[searchParams],
|
||||
@@ -96,7 +99,7 @@ export function LoginPage() {
|
||||
|
||||
const showWecomPrimary = isWecomContext;
|
||||
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
||||
const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry);
|
||||
const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry) && !pcWecomEntry;
|
||||
|
||||
useEffect(() => {
|
||||
if (emailCodeCooldown <= 0) {
|
||||
@@ -142,6 +145,13 @@ export function LoginPage() {
|
||||
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
|
||||
}
|
||||
|
||||
function handlePcWecomLogin() {
|
||||
if (!pcWecomEntry?.pcLoginUrl) {
|
||||
return;
|
||||
}
|
||||
window.location.assign(buildWecomLoginUrl(pcWecomEntry.pcLoginUrl, redirectTo, getDeviceFingerprint()));
|
||||
}
|
||||
|
||||
async function handleRequestEmailCode(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setEmailCodeError("");
|
||||
@@ -370,7 +380,9 @@ export function LoginPage() {
|
||||
<div className="space-y-4">
|
||||
{!showWecomPrimary ? (
|
||||
<div className="ux-command-note">
|
||||
员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。
|
||||
{pcWecomEntry
|
||||
? "可使用企业微信扫码登录;企业邮箱验证码和账号密码作为备用方式。"
|
||||
: "员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。"}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -432,7 +444,7 @@ export function LoginPage() {
|
||||
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||
: null}
|
||||
|
||||
{!showWecomPrimary && (showBrowserWecomNotice || oidcProvider) ? (
|
||||
{!showWecomPrimary && (pcWecomEntry || showBrowserWecomNotice || oidcProvider) ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span className="h-px flex-1 bg-slate-100" />
|
||||
@@ -440,7 +452,17 @@ export function LoginPage() {
|
||||
<span className="h-px flex-1 bg-slate-100" />
|
||||
</div>
|
||||
|
||||
{showBrowserWecomNotice ? (
|
||||
{pcWecomEntry ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="wecom-entry-pc"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handlePcWecomLogin}
|
||||
>
|
||||
企业微信扫码登录
|
||||
</Button>
|
||||
) : showBrowserWecomNotice ? (
|
||||
<div
|
||||
className="auth-side-entry flex gap-3 text-slate-600"
|
||||
data-testid="wecom-entry-browser-notice"
|
||||
|
||||
@@ -8,6 +8,8 @@ function mockWecomConfigPayload() {
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000001",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
pcWebLoginEnabled: true,
|
||||
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/mobile",
|
||||
status: "ready",
|
||||
@@ -53,14 +55,23 @@ async function mockWecomConfig(page: import("@playwright/test").Page) {
|
||||
});
|
||||
}
|
||||
|
||||
test("E2E 企微入口:普通浏览器下只提示企业微信工作台打开", async ({ page }) => {
|
||||
test("E2E 企微入口:普通浏览器显示托管扫码入口并保留安全回跳", async ({ page }) => {
|
||||
await mockAuthBase(page);
|
||||
await mockWecomConfig(page);
|
||||
let loginEntryUrl = "";
|
||||
await page.route("**/api/auth/wecom/login?**", async (route) => {
|
||||
loginEntryUrl = route.request().url();
|
||||
await route.fulfill({ status: 200, contentType: "text/plain", body: "mock pc wecom login entry" });
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.goto("/login?redirect=%2Freimbursements");
|
||||
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-browser-notice")).toContainText("请从企业微信工作台打开 OA");
|
||||
await expect(page.getByTestId("wecom-entry-secondary")).toHaveCount(0);
|
||||
await expect(page.getByTestId("wecom-entry-pc")).toHaveText("企业微信扫码登录");
|
||||
await page.getByTestId("wecom-entry-pc").click();
|
||||
await expect.poll(() => loginEntryUrl).toContain("entrySource=pc_qr");
|
||||
expect(new URL(loginEntryUrl).searchParams.get("returnTo")).toBe("/reimbursements");
|
||||
expect(new URL(loginEntryUrl).searchParams.get("deviceFingerprint")).toBeTruthy();
|
||||
await expect(page.getByTestId("wecom-entry-browser-notice")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示映射缺失提示", async ({ page }) => {
|
||||
@@ -83,6 +94,7 @@ test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示映
|
||||
await page.goto("/login?redirect=%2Fportal&wecom=account_mapping_required");
|
||||
await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-primary")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-pc")).toHaveCount(0);
|
||||
await expect(page.getByTestId("login-wecom-notice")).toContainText("企业微信账号映射尚未完成");
|
||||
await expect(page.getByTestId("login-wecom-notice")).not.toContainText("账号密码登录");
|
||||
await expect(page.getByTestId("login-password-secondary-panel")).toBeVisible();
|
||||
|
||||
@@ -74,6 +74,7 @@ OIDC_SCOPES=openid profile email
|
||||
# ===== 企业微信入口 / OAuth(可选) =====
|
||||
WECOM_ENABLED=false
|
||||
WECOM_ENTRY_ENABLED=false
|
||||
WECOM_PC_WEB_LOGIN_ENABLED=false
|
||||
WECOM_CORP_ID=
|
||||
WECOM_AGENT_ID=
|
||||
WECOM_AGENT_SECRET=
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
- [ ] 登录页已区分普通浏览器与企业微信 WebView 场景
|
||||
- [ ] 企业微信工作台入口 URL 已配置为 `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fmobile`
|
||||
- [ ] OAuth 回调地址已配置为 `https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
- [ ] `deploy/trialrun/.env` 已补齐 `WECOM_ENABLED / WECOM_ENTRY_ENABLED / WECOM_OAUTH_ENABLED / WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_AGENT_SECRET / WECOM_REDIRECT_URI / WECOM_ALLOWED_RETURN_HOSTS`
|
||||
- [ ] `deploy/trialrun/.env` 已补齐 `WECOM_ENABLED / WECOM_ENTRY_ENABLED / WECOM_OAUTH_ENABLED / WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_AGENT_SECRET / WECOM_REDIRECT_URI / WECOM_ALLOWED_RETURN_HOSTS`;PC 托管扫码经企微后台核验后再单独设置 `WECOM_PC_WEB_LOGIN_ENABLED=true`
|
||||
- [ ] PC 托管扫码启用前,企微自建应用已开启“企业微信授权登录”,OA 域名已配置为 OAuth 可信域名或 Web 网页授权回调域名
|
||||
- [ ] 至少 1 个管理员已在 `个人设置` 成功完成企微绑定
|
||||
- [ ] 已验证:已绑定员工从企业微信工作台进入后可直接落到 `/mobile`
|
||||
- [ ] 已验证:未绑定员工会回退到登录页并看到明确绑定提示
|
||||
|
||||
@@ -590,6 +590,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml
|
||||
2. 在 `deploy/trialrun/.env` 中补齐:
|
||||
- `WECOM_ENABLED=true`
|
||||
- `WECOM_ENTRY_ENABLED=true`
|
||||
- `WECOM_PC_WEB_LOGIN_ENABLED=false`(默认关闭;确认企微授权登录和可信域名后再独立开启)
|
||||
- `WECOM_CORP_ID`
|
||||
- `WECOM_AGENT_ID`
|
||||
- `WECOM_AGENT_SECRET`
|
||||
@@ -597,6 +598,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml
|
||||
- `WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback`
|
||||
- `WECOM_ALLOWED_RETURN_HOSTS=oa.example.com`
|
||||
3. 管理员先在个人设置页完成企微绑定,再拉试点用户验证免登。
|
||||
4. 如需普通 PC 浏览器扫码登录,先在企微自建应用开启“企业微信授权登录”并核对 OAuth 可信域名或 Web 网页授权回调域名,再将 `WECOM_PC_WEB_LOGIN_ENABLED=true`;回滚时只需恢复为 `false`,不影响工作台免登、密码和邮箱验证码登录。
|
||||
4. 企微消息只用于提醒与打开 OA,不新增第二套录入页面。
|
||||
|
||||
### C. 项目与合同最小导入
|
||||
|
||||
+5
-3
@@ -653,15 +653,17 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
|
||||
4. 在 `deploy/trialrun/.env` 中补齐并开启:
|
||||
- `WECOM_ENABLED=true`
|
||||
- `WECOM_ENTRY_ENABLED=true`
|
||||
- `WECOM_PC_WEB_LOGIN_ENABLED=false`(普通 PC 浏览器扫码默认关闭)
|
||||
- `WECOM_OAUTH_ENABLED=true`
|
||||
- `WECOM_CORP_ID=...`
|
||||
- `WECOM_AGENT_ID=...`
|
||||
- `WECOM_AGENT_SECRET=...`
|
||||
- `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
- `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com`
|
||||
5. 先 dry-run 核对通讯录 userid/企业邮箱与 OA active 账户的唯一映射,再 apply 建立 binding;无 binding 时,首次企微登录会用 userid 或企微企业邮箱精确匹配既有 active OA 账户并自动补绑定。员工不需要先输入 OA 密码。
|
||||
6. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。
|
||||
7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存,WARCH2 已完成正式只读历史查询。WARCH3/WCHECK1/WEXT1 后先做自动巡检、打卡摘要留存和外出/加班 dry-run;WOPS1 后告警默认发给 `HYD000006`(`wangshunli` 的员工号)且只报新增异常。同意单击即通过需继续纳入操作提示和审计设计。外出/加班正式模板人工测试仍需后续单独批准。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
|
||||
5. 如需普通 PC 浏览器企业微信扫码登录,先在自建应用开启“企业微信授权登录”,并确认 OA 域名已配置为 OAuth 可信域名或 Web 网页授权回调域名,再单独设置 `WECOM_PC_WEB_LOGIN_ENABLED=true`。该入口跳转企微托管登录页,不在 OA 密码页加载企微脚本;关闭该开关即可独立回滚。
|
||||
6. 先 dry-run 核对通讯录 userid/企业邮箱与 OA active 账户的唯一映射,再 apply 建立 binding;无 binding 时,首次企微登录会用 userid 或企微企业邮箱精确匹配既有 active OA 账户并自动补绑定。员工不需要先输入 OA 密码。
|
||||
7. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。
|
||||
8. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存,WARCH2 已完成正式只读历史查询。WARCH3/WCHECK1/WEXT1 后先做自动巡检、打卡摘要留存和外出/加班 dry-run;WOPS1 后告警默认发给 `HYD000006`(`wangshunli` 的员工号)且只报新增异常。同意单击即通过需继续纳入操作提示和审计设计。外出/加班正式模板人工测试仍需后续单独批准。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
|
||||
|
||||
### 员工会看到的典型状态
|
||||
- 映射唯一:首次从工作台进入即可自动补 binding 并直接登录,无需 OA 密码。
|
||||
|
||||
@@ -68,9 +68,16 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
|
||||
# ===== 企业微信(入口/打卡同步,可选)=====
|
||||
# WECOM_ENABLED="false"
|
||||
# WECOM_ENTRY_ENABLED="false"
|
||||
# WECOM_PC_WEB_LOGIN_ENABLED="false"
|
||||
# WECOM_OAUTH_ENABLED="false"
|
||||
# WECOM_OAUTH_ONBOARDING_ENABLED="false"
|
||||
# WECOM_CORP_ID=""
|
||||
# WECOM_AGENT_ID=""
|
||||
# WECOM_AGENT_SECRET=""
|
||||
# WECOM_REDIRECT_URI="https://oa.example.com/api/auth/wecom/callback"
|
||||
# WECOM_ALLOWED_RETURN_HOSTS="oa.example.com"
|
||||
# WECOM_CHECKIN_SYNC_ENABLED="false"
|
||||
# WECOM_CHECKIN_SECRET=""
|
||||
# WECOM_CONTACT_SYNC_ENABLED="false"
|
||||
|
||||
@@ -253,6 +253,7 @@ function getConfig() {
|
||||
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
|
||||
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
|
||||
wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true",
|
||||
wecomPcWebLoginEnabled: process.env.WECOM_PC_WEB_LOGIN_ENABLED === "true",
|
||||
projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true",
|
||||
projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true",
|
||||
projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true",
|
||||
|
||||
+115
-7
@@ -19,13 +19,18 @@ const {
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||
const PC_LOGIN_STATE_COOKIE_NAME = "wecom_pc_login_state";
|
||||
const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint";
|
||||
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_RETURN_TO = "/mobile";
|
||||
const PC_DEFAULT_RETURN_TO = "/portal";
|
||||
const ENTRY_SOURCE_WECOM_WORKBENCH = "wecom_workbench";
|
||||
const ENTRY_SOURCE_PC_QR = "pc_qr";
|
||||
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
|
||||
const QUERY_SCHEMA = z.object({
|
||||
returnTo: z.string().optional(),
|
||||
mode: z.enum(["login", "bind", "onboard"]).optional(),
|
||||
entrySource: z.enum([ENTRY_SOURCE_WECOM_WORKBENCH, ENTRY_SOURCE_PC_QR]).optional(),
|
||||
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
@@ -70,6 +75,16 @@ function pendingLoginBindCookieOptions(config) {
|
||||
};
|
||||
}
|
||||
|
||||
function pcLoginStateCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth/wecom",
|
||||
maxAge: DATE_WINDOW_MS / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceFingerprintCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
@@ -169,6 +184,7 @@ function resolveWecomEntryConfig(config) {
|
||||
&& config.wecomRedirectUri,
|
||||
);
|
||||
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
|
||||
const pcWebLoginEnabled = Boolean(entryEnabled && config.wecomPcWebLoginEnabled);
|
||||
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
|
||||
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
|
||||
|
||||
@@ -179,6 +195,10 @@ function resolveWecomEntryConfig(config) {
|
||||
corpId: String(config.wecomCorpId ?? ""),
|
||||
agentId: String(config.wecomAgentId ?? ""),
|
||||
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
|
||||
pcWebLoginEnabled,
|
||||
pcLoginUrl: pcWebLoginEnabled
|
||||
? buildWecomLoginUrl(PC_DEFAULT_RETURN_TO, "login", ENTRY_SOURCE_PC_QR)
|
||||
: null,
|
||||
bindEnabled,
|
||||
onboardingEnabled,
|
||||
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
|
||||
@@ -212,6 +232,14 @@ function assertWecomEntryReady(config) {
|
||||
return entryConfig;
|
||||
}
|
||||
|
||||
function assertWecomPcWebLoginReady(config) {
|
||||
const entryConfig = assertWecomEntryReady(config);
|
||||
if (!entryConfig.pcWebLoginEnabled) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
return entryConfig;
|
||||
}
|
||||
|
||||
function assertWecomOnboardingReady(config) {
|
||||
assertWecomOauthReady(config);
|
||||
if (!config.wecomOauthOnboardingEnabled) {
|
||||
@@ -219,11 +247,14 @@ function assertWecomOnboardingReady(config) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
|
||||
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login", entrySource = ENTRY_SOURCE_WECOM_WORKBENCH) {
|
||||
const params = new URLSearchParams({ returnTo });
|
||||
if (mode === "bind" || mode === "onboard") {
|
||||
params.set("mode", mode);
|
||||
}
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
params.set("entrySource", ENTRY_SOURCE_PC_QR);
|
||||
}
|
||||
return `/api/auth/wecom/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -328,6 +359,48 @@ function buildWecomAuthorizeUrl(config, state, mode = "login") {
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
|
||||
}
|
||||
|
||||
function buildWecomPcAuthorizeUrl(config, state) {
|
||||
const url = new URL("https://login.work.weixin.qq.com/wwlogin/sso/login");
|
||||
url.searchParams.set("login_type", "CorpApp");
|
||||
url.searchParams.set("appid", config.wecomCorpId);
|
||||
url.searchParams.set("agentid", config.wecomAgentId);
|
||||
url.searchParams.set("redirect_uri", config.wecomRedirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function safeEqualString(left, right) {
|
||||
if (typeof left !== "string" || typeof right !== "string") return false;
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function resolveCallbackEntrySource(value) {
|
||||
if (value == null) return ENTRY_SOURCE_WECOM_WORKBENCH;
|
||||
if (value === ENTRY_SOURCE_WECOM_WORKBENCH || value === ENTRY_SOURCE_PC_QR) return value;
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
|
||||
function validatePcLoginStateCookie({ request, reply, config, ctx }) {
|
||||
const cookieToken = request.cookies?.[PC_LOGIN_STATE_COOKIE_NAME];
|
||||
const verified = verifyToken(cookieToken, config.jwtSecret);
|
||||
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(config));
|
||||
assertWecomPcWebLoginReady(config);
|
||||
if (!verified.ok) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const payload = verified.payload ?? {};
|
||||
const valid = payload.entrySource === ENTRY_SOURCE_PC_QR
|
||||
&& typeof payload.exp === "number"
|
||||
&& payload.exp > Date.now()
|
||||
&& safeEqualString(payload.nonce, ctx.nonce);
|
||||
if (!valid) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOAuthDeviceFingerprint(value) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
@@ -398,6 +471,15 @@ async function wecomAuthRoutes(fastify) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const mode = parsed.data.mode ?? "login";
|
||||
const entrySource = parsed.data.entrySource ?? ENTRY_SOURCE_WECOM_WORKBENCH;
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR && mode !== "login") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { entrySource: "unsupported_for_mode" },
|
||||
});
|
||||
}
|
||||
if (mode === "bind") {
|
||||
assertWecomOauthReady(fastify.config);
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
@@ -408,18 +490,28 @@ async function wecomAuthRoutes(fastify) {
|
||||
}
|
||||
} else if (mode === "onboard") {
|
||||
assertWecomOnboardingReady(fastify.config);
|
||||
} else if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
assertWecomPcWebLoginReady(fastify.config);
|
||||
} else {
|
||||
assertWecomEntryReady(fastify.config);
|
||||
}
|
||||
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
|
||||
const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint);
|
||||
const exp = Date.now() + DATE_WINDOW_MS;
|
||||
const nonce = crypto.randomUUID();
|
||||
const state = signPayload({
|
||||
exp: Date.now() + DATE_WINDOW_MS,
|
||||
nonce: crypto.randomUUID(),
|
||||
exp,
|
||||
nonce,
|
||||
returnTo,
|
||||
mode,
|
||||
entrySource,
|
||||
deviceFingerprint,
|
||||
}, fastify.config.jwtSecret);
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
const correlationToken = signPayload({ exp, nonce, entrySource }, fastify.config.jwtSecret);
|
||||
reply.setCookie(PC_LOGIN_STATE_COOKIE_NAME, correlationToken, pcLoginStateCookieOptions(fastify.config));
|
||||
return reply.redirect(buildWecomPcAuthorizeUrl(fastify.config, state));
|
||||
}
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
|
||||
});
|
||||
|
||||
@@ -464,14 +556,28 @@ async function wecomAuthRoutes(fastify) {
|
||||
const stateToken = String(request.query?.state ?? "").trim();
|
||||
const code = String(request.query?.code ?? "").trim();
|
||||
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
|
||||
if (!verified.ok || !code) {
|
||||
if (!verified.ok) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const ctx = verified.payload ?? {};
|
||||
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
|
||||
const entrySource = resolveCallbackEntrySource(ctx.entrySource);
|
||||
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
|
||||
}
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
if (mode !== "login") {
|
||||
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
validatePcLoginStateCookie({ request, reply, config: fastify.config, ctx });
|
||||
}
|
||||
if (!code) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
|
||||
const profile = await exchangeWecomCode(fastify.config, code);
|
||||
if (!profile.externalUserId) {
|
||||
@@ -479,6 +585,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
{
|
||||
requestId: request.id,
|
||||
mode,
|
||||
entrySource,
|
||||
hasUserId: false,
|
||||
hasOpenId: Boolean(profile.externalOpenUserId),
|
||||
hasDeviceId: Boolean(profile.deviceId),
|
||||
@@ -497,6 +604,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
wecom: "missing_user_id",
|
||||
hasOpenId: Boolean(profile.externalOpenUserId),
|
||||
mode,
|
||||
entrySource,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -645,7 +753,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
? "account_mapping_inactive"
|
||||
: "account_mapping_required";
|
||||
request.log.warn(
|
||||
{ requestId: request.id, errorCode: error.code, notice },
|
||||
{ requestId: request.id, errorCode: error.code, notice, entrySource },
|
||||
"wecom automatic login binding failed closed",
|
||||
);
|
||||
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
|
||||
@@ -664,7 +772,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
tokenVersion: loginEmployee.token_version,
|
||||
deviceFingerprint,
|
||||
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource },
|
||||
extra: { bindingSource, entrySource },
|
||||
});
|
||||
|
||||
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
|
||||
|
||||
@@ -225,6 +225,10 @@ function buildEntryOauthCheck(config) {
|
||||
evidence: {
|
||||
endpoint: "/api/auth/wecom/config",
|
||||
loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login",
|
||||
pcWebLoginEnabled: Boolean(config.wecomPcWebLoginEnabled),
|
||||
pcLoginUrl: missing.length || !config.wecomPcWebLoginEnabled
|
||||
? null
|
||||
: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind",
|
||||
returnHosts,
|
||||
enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled),
|
||||
|
||||
@@ -38,7 +38,12 @@ function evaluateWecom(config) {
|
||||
return {
|
||||
state: 'ready',
|
||||
detail: '企业微信入口与免登配置齐全',
|
||||
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`, `redirectUri=${config.wecomRedirectUri}`],
|
||||
checks: [
|
||||
`corpId=${config.wecomCorpId}`,
|
||||
`agentId=${config.wecomAgentId}`,
|
||||
`redirectUri=${config.wecomRedirectUri}`,
|
||||
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions =
|
||||
cookieSecure: true,
|
||||
wecomEnabled: true,
|
||||
wecomEntryEnabled: true,
|
||||
wecomPcWebLoginEnabled: false,
|
||||
wecomOauthEnabled: true,
|
||||
wecomOauthOnboardingEnabled: false,
|
||||
wecomCorpId: "wx-test-corp",
|
||||
@@ -138,11 +139,83 @@ describe("wecom auth routes R27", () => {
|
||||
status: "ready",
|
||||
defaultReturnTo: "/mobile",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
pcWebLoginEnabled: false,
|
||||
pcLoginUrl: null,
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns an independently gated PC Web login entry", async () => {
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
entryEnabled: true,
|
||||
pcWebLoginEnabled: true,
|
||||
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("builds the hosted PC Web login URL and browser correlation cookie", async () => {
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
const location = new URL(response.headers.location);
|
||||
expect(location.origin).toBe("https://login.work.weixin.qq.com");
|
||||
expect(location.pathname).toBe("/wwlogin/sso/login");
|
||||
expect(location.searchParams.get("login_type")).toBe("CorpApp");
|
||||
expect(location.searchParams.get("appid")).toBe("wx-test-corp");
|
||||
expect(location.searchParams.get("agentid")).toBe("1000001");
|
||||
expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback");
|
||||
const state = verifyToken(location.searchParams.get("state"), JWT_SECRET);
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
returnTo: "/reimbursements",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
deviceFingerprint: "pc-device-1",
|
||||
}),
|
||||
}));
|
||||
const setCookie = String(response.headers["set-cookie"] ?? "");
|
||||
expect(setCookie).toContain("wecom_pc_login_state=");
|
||||
expect(setCookie).toContain("Path=/api/auth/wecom");
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toContain("Secure");
|
||||
expect(setCookie).toContain("SameSite=Lax");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects PC entry for bind/onboard and while its independent switch is off", async () => {
|
||||
const disabledApp = await buildApp();
|
||||
const disabled = await disabledApp.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal",
|
||||
});
|
||||
expect(disabled.statusCode).toBe(404);
|
||||
await disabledApp.close();
|
||||
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const unsupported = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal",
|
||||
});
|
||||
expect(unsupported.statusCode).toBe(400);
|
||||
expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps onboarding disabled by default", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
@@ -560,7 +633,7 @@ describe("wecom auth routes R27", () => {
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource: "auto" },
|
||||
extra: { bindingSource: "auto", entrySource: "wecom_workbench" },
|
||||
}),
|
||||
});
|
||||
expect(authSession.create).toHaveBeenCalledWith({
|
||||
@@ -630,6 +703,229 @@ describe("wecom auth routes R27", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed before code exchange when the PC browser correlation cookie is missing", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-missing-cookie",
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
deviceFingerprint: "pc-device-missing-cookie",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().error.code).toBe("AUTH_004");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed before code exchange when the PC browser nonce does not match", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-state",
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const cookie = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-cookie",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
headers: { cookie: `wecom_pc_login_state=${cookie}` },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const nonce = "pc-nonce-terminal-failures";
|
||||
const cookie = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` };
|
||||
|
||||
const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const expiredState = signPayload({
|
||||
exp: Date.now() - 1,
|
||||
nonce,
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const expired = await enabledApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`,
|
||||
headers: cookieHeader,
|
||||
});
|
||||
expect(expired.statusCode).toBe(401);
|
||||
expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
|
||||
const validState = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const missingCode = await enabledApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`,
|
||||
headers: cookieHeader,
|
||||
});
|
||||
expect(missingCode.statusCode).toBe(401);
|
||||
expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
await enabledApp.close();
|
||||
|
||||
const disabledApp = await buildApp();
|
||||
const disabled = await disabledApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`,
|
||||
headers: cookieHeader,
|
||||
});
|
||||
expect(disabled.statusCode).toBe(404);
|
||||
expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
await disabledApp.close();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses the mapped login flow for PC Web login and records a sanitized entry source", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "access-token" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, UserId: "wecom-pc-user", OpenId: "wecom-open-pc" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "access-token-replay" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 40029, errmsg: "invalid code" }),
|
||||
});
|
||||
const authSession = {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue({ id: "session-pc" }),
|
||||
};
|
||||
const prisma = buildPrisma({
|
||||
authSession,
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
employee: {
|
||||
id: EMPLOYEE_ID,
|
||||
status: "active",
|
||||
token_version: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true }, prisma });
|
||||
const nonce = "pc-nonce-success";
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
returnTo: "/reimbursements",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
deviceFingerprint: "pc-device-success",
|
||||
}, JWT_SECRET);
|
||||
const cookie = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
headers: { cookie: `wecom_pc_login_state=${cookie}` },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements");
|
||||
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
expect(authSession.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
device_fingerprint: "pc-device-success",
|
||||
}),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource: "existing", entrySource: "pc_qr" },
|
||||
}),
|
||||
}));
|
||||
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code");
|
||||
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state);
|
||||
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user");
|
||||
|
||||
const replay = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
headers: { cookie: `wecom_pc_login_state=${cookie}` },
|
||||
});
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(replay.json().error).toEqual(expect.objectContaining({
|
||||
code: "AUTH_003",
|
||||
details: { wecom: "userinfo_exchange_failed" },
|
||||
}));
|
||||
expect(authSession.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a signed but unknown callback entry source before code exchange", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-unknown-source",
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "unknown_source",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns structured diagnostics when wecom callback has only open id", async () => {
|
||||
const logs = [];
|
||||
mockWecomFetchWithPayload({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
assertSendAllowed,
|
||||
buildApprovalConfigChecks,
|
||||
buildEntryOauthCheck,
|
||||
buildSecretMatrix,
|
||||
evaluateConclusion,
|
||||
executeRuntimeReady,
|
||||
@@ -24,6 +25,7 @@ function baseConfig(overrides = {}) {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
wecomEnabled: true,
|
||||
wecomEntryEnabled: true,
|
||||
wecomPcWebLoginEnabled: false,
|
||||
wecomOauthEnabled: true,
|
||||
wecomCorpId: "corp-runtime",
|
||||
wecomAgentId: "1000002",
|
||||
@@ -73,6 +75,22 @@ function wecomProfile(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("check-wecom-runtime-ready", () => {
|
||||
it("reports the independently gated PC Web login entry without blocking workbench readiness", () => {
|
||||
const disabled = buildEntryOauthCheck(baseConfig());
|
||||
expect(disabled.status).toBe("OK");
|
||||
expect(disabled.evidence).toEqual(expect.objectContaining({
|
||||
enabled: true,
|
||||
pcWebLoginEnabled: false,
|
||||
pcLoginUrl: null,
|
||||
}));
|
||||
|
||||
const enabled = buildEntryOauthCheck(baseConfig({ wecomPcWebLoginEnabled: true }));
|
||||
expect(enabled.status).toBe("OK");
|
||||
expect(enabled.evidence.pcLoginUrl).toBe(
|
||||
"/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses runner options and enforces message send double confirmation", () => {
|
||||
const options = parseArgs([
|
||||
"--live",
|
||||
|
||||
在新工单中引用
屏蔽一个用户