diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index dfcf73c2..4803aedc 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -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=&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=&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 diff --git a/.trellis/tasks/07-30-pc-wecom-qr-login/check.jsonl b/.trellis/tasks/07-30-pc-wecom-qr-login/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-30-pc-wecom-qr-login/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-30-pc-wecom-qr-login/design.md b/.trellis/tasks/07-30-pc-wecom-qr-login/design.md new file mode 100644 index 00000000..cbaf8fa8 --- /dev/null +++ b/.trellis/tasks/07-30-pc-wecom-qr-login/design.md @@ -0,0 +1,143 @@ +# PC 企业微信扫码登录设计 + +## 1. Design Goal + +在普通 PC 浏览器提供企业微信托管 Web 登录入口,同时复用现有身份映射、会话签发和回调错误处理。首版不向 OA 登录页注入企微脚本,不改变工作台免登、绑定或 onboard 流程。 + +官方依据: + +- 企业微信《开始开发》:新版 Web 登录支持移动端企微扫码和企微桌面端快速登录,并要求自建应用开启“企业微信授权登录”。 + - +- 企业微信《Web登录组件》:托管页链接为 `https://login.work.weixin.qq.com/wwlogin/sso/login`,自建应用使用 `login_type=CorpApp`、CorpID、AgentID、URL 编码后的回调地址和 state。 + - +- 企业微信《获取用户登录身份》:Web 登录 code 继续由 `/cgi-bin/auth/getuserinfo` 消费,code 一次有效且 5 分钟过期。 + - + +## 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= + &agentid= + &redirect_uri= + &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。 diff --git a/.trellis/tasks/07-30-pc-wecom-qr-login/implement.jsonl b/.trellis/tasks/07-30-pc-wecom-qr-login/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-30-pc-wecom-qr-login/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/07-30-pc-wecom-qr-login/implement.md b/.trellis/tasks/07-30-pc-wecom-qr-login/implement.md new file mode 100644 index 00000000..699b0b8a --- /dev/null +++ b/.trellis/tasks/07-30-pc-wecom-qr-login/implement.md @@ -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 保持未勾选。 diff --git a/.trellis/tasks/07-30-pc-wecom-qr-login/prd.md b/.trellis/tasks/07-30-pc-wecom-qr-login/prd.md new file mode 100644 index 00000000..2ffba813 --- /dev/null +++ b/.trellis/tasks/07-30-pc-wecom-qr-login/prd.md @@ -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 基线另行治理。 diff --git a/.trellis/tasks/07-30-pc-wecom-qr-login/task.json b/.trellis/tasks/07-30-pc-wecom-qr-login/task.json new file mode 100644 index 00000000..af9f5d3f --- /dev/null +++ b/.trellis/tasks/07-30-pc-wecom-qr-login/task.json @@ -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": {} +} \ No newline at end of file diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index a6636356..1f8aa66a 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -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"; diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 0eca57f4..ee5f6ee8 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -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) { event.preventDefault(); setEmailCodeError(""); @@ -370,7 +380,9 @@ export function LoginPage() {
{!showWecomPrimary ? (
- 员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。 + {pcWecomEntry + ? "可使用企业微信扫码登录;企业邮箱验证码和账号密码作为备用方式。" + : "员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。"}
) : 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) ? (
@@ -440,7 +452,17 @@ export function LoginPage() {
- {showBrowserWecomNotice ? ( + {pcWecomEntry ? ( + + ) : showBrowserWecomNotice ? (
{ +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(); diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 0af11ad7..9a83a239 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -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= diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index e197e61a..471ba0cd 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -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` - [ ] 已验证:未绑定员工会回退到登录页并看到明确绑定提示 diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 76219279..6137cde8 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -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. 项目与合同最小导入 diff --git a/docs/管理员手册.md b/docs/管理员手册.md index 643a1eb0..4bf8d7f1 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -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 密码。 diff --git a/server/.env.example b/server/.env.example index 49534f86..a936a304 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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" diff --git a/server/src/config.js b/server/src/config.js index 72e5c000..09bde015 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -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", diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index 3a41af14..49ed2112 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -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)); diff --git a/server/src/scripts/check-wecom-runtime-ready.js b/server/src/scripts/check-wecom-runtime-ready.js index e4609f58..57398fb2 100644 --- a/server/src/scripts/check-wecom-runtime-ready.js +++ b/server/src/scripts/check-wecom-runtime-ready.js @@ -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), diff --git a/server/src/scripts/smoke-external-deps.js b/server/src/scripts/smoke-external-deps.js index 954bb970..9cf4eb97 100644 --- a/server/src/scripts/smoke-external-deps.js +++ b/server/src/scripts/smoke-external-deps.js @@ -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'}`, + ], }; } diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index e992e23f..0e8c3212 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -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({ diff --git a/server/tests/unit/scripts/check-wecom-runtime-ready.test.js b/server/tests/unit/scripts/check-wecom-runtime-ready.test.js index 2fa5ffd2..d31cf898 100644 --- a/server/tests/unit/scripts/check-wecom-runtime-ready.test.js +++ b/server/tests/unit/scripts/check-wecom-runtime-ready.test.js @@ -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",