diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index 015816e5..dfcf73c2 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -320,6 +320,97 @@ one-time onboard -> explicit private consent -> safe unique employee-only write business role assignment -> separate administrator-audited operation ``` +## Scenario: Passwordless First WeCom Login For Existing OA Accounts + +### 1. Scope / Trigger + +- Trigger: changing ordinary `mode=login`, identity binding, directory-email + matching, the password login fallback or account-activation readiness. +- Purpose: every existing active OA employee must be able to enter from the + WeCom workbench on the first attempt without entering an OA password, while + ambiguous identities remain fail-closed. + +### 2. Signatures + +- Entry: `GET /api/auth/wecom/login?returnTo=` with + `snsapi_base`. +- OAuth callback identity: internal `userid` from `/cgi-bin/auth/getuserinfo`. +- Unbound member profile: `GET /cgi-bin/user/get?userid=` using + the in-memory app access token. +- 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`. + +### 3. Contracts + +- Existing active binding is the fast path and does not call `/user/get`. +- Without a binding, exact OA `employee_no == userid` is a valid stable match. + A normalized WeCom `biz_mail`/`email` may also match one existing active OA + `Employee.email` using case-insensitive exact equality. If userid and email + point to different employees, stop. +- Transactionally recheck both unique binding directions before create. A + concurrent unique-constraint race is a conflict, not permission to overwrite. +- Ordinary login never creates an employee, updates roles, writes the WeCom + directory, matches by name/phone, or consumes a pending cookie during OA + password login. +- Access tokens, full userid, enterprise email and raw provider payload stay in + request memory. Auto-bind audit contains only `matchBy` and + `emailMatched`; errors and redirects use stable categories. +- Missing enterprise email does not block an exact userid/employee-number + match. Without either stable match, no session or binding is created. + +### 4. Validation & Error Matrix + +- Existing binding + active employee -> direct session. +- Existing binding + inactive employee -> `account_mapping_inactive`. +- `/user/get` network/API failure or no OA match -> + `account_mapping_required`; no write. +- WeCom member inactive or matched OA employee inactive -> + `account_mapping_inactive`; no write. +- Userid/email point to different employees, either side already has a + different binding, identity mismatch or binding race -> + `account_mapping_conflict`; no overwrite. +- Password login with a legacy `wecom_login_bind` cookie -> normal password + login only, clear the cookie, return `wecomBindingStatus=null`. + +### 5. Good/Base/Bad Cases + +- Good: an unbound active member's enterprise email matches exactly one active + OA employee; callback creates one binding and audit, then redirects into the + requested OA page without a password. +- Base: a previously bound member uses the existing fast path and creates only + the normal WeCom login session/audit. +- Bad: bind by display name, overwrite another userid, create a new employee, + or ask the member to enter an OA password to complete first binding. + +### 6. Tests Required + +- Service tests: profile userid equality, member state, business-email + normalization, userid-only match, email-only match, split-key conflict, + inactive employee, cross-binding and redacted audit. +- Route tests: existing binding fast path, passwordless auto-bind + session, + provider-unavailable fail-closed redirect and no pending binding cookie. +- Password-route regression: a legacy pending cookie cannot create/update a + binding and is cleared. +- Frontend typecheck/build and focused WeCom notice tests; full backend suite, + Prisma validation, sensitive-content and Trellis governance gates. + +### 7. Wrong vs Correct + +#### Wrong + +```text +unbound OAuth userid -> password login -> consume pending cookie -> bind +``` + +#### Correct + +```text +OAuth userid -> existing binding OR exact userid/unique enterprise email match +-> transactional binding -> redacted audit -> direct WeCom session +``` + ## Scenario: WeCom Directory Export With Optional Phone ### 1. Scope / Trigger diff --git a/.trellis/tasks/07-28-all-account-login-readiness/check.jsonl b/.trellis/tasks/07-28-all-account-login-readiness/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/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-28-all-account-login-readiness/design.md b/.trellis/tasks/07-28-all-account-login-readiness/design.md new file mode 100644 index 00000000..bc69b4db --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/design.md @@ -0,0 +1,81 @@ +# 全员登录可用性设计 + +## 1. Boundary + +本任务在现有企微 OAuth 回调内补充确定性自动绑定,不新增公开认证端点、不读取或记录任何真实密码。普通企微登录只绑定既有 active OA 账户,不创建员工、不修改角色、不写企微通讯录。 + +## 1.1 WeCom Direct-login Contract + +```text +企微 OAuth callback 得到内部 userid + -> 已有唯一 binding:直接创建会话 + -> 无 binding:读取该 userid 的企微通讯录资料 + -> 企业邮箱精确匹配唯一 active OA 账户 + -> 目标账户无 binding:事务内 upsert binding + 脱敏审计 + 创建会话 + -> 任何缺失/无匹配/inactive/冲突/API 失败:失败关闭 +``` + +匹配键优先级固定为已有 userid binding,其次为企微返回的规范化企业邮箱。姓名不得参与自动决策;手机号不作为本轮普通登录兜底,避免号码复用或格式漂移扩大身份边界。 + +## 2. Login Baseline + +每个 active 账户必须同时满足: + +1. `WeCom SSO ready`:属于当前企微通讯录的员工存在唯一 binding,并由本人完成一次工作台直接登录;首次登录不需要 OA 密码。 +2. `password fallback ready`:员工本人已设置并掌握密码;它是企微异常时的独立回退,不是首次 binding 的前置条件。 + +inactive/archived 账户不进入本轮登录覆盖率分母。 + +## 3. Administrator Recovery + +```text +受控终端 -> 精确定位唯一 active 管理员 -> 静默设置一次性密码 + -> 清锁定、撤旧会话、递增 token version、失效旧验证码并留脱敏审计 +电脑浏览器 -> 使用一次性密码登录 -> 立即设置本人新密码 +手机企微工作台 -> 重新进入并完成 OAuth userid 校验 + -> 新版 OAuth 回调用 userid 或企微企业邮箱精确匹配既有 active OA 账户 + -> 自动建立唯一 binding 并直接创建会话 + -> 再次从工作台进入并验证免登 +``` + +一次性密码只允许由用户在受控终端静默输入,或写入仓库外权限为 `0600` 的临时文件供用户本机读取一次;不得出现在聊天、argv、环境变量、日志或仓库。它只用于管理员账号恢复,不参与企微首次 binding。 + +若自动匹配缺失或冲突,停止操作并只读核对 userid、企业邮箱和 OA active 账户的唯一映射;不得按姓名猜测、不得用密码登录覆盖 binding。个人设置主动绑定只保留为管理员核实后的显式维护入口。 + +## 4. Remaining Active Accounts + +其余 active 账户已有 binding,但尚无真实登录证据。每名员工先直接从企微工作台进入,验证 binding 和免登是否可用;企微免登通过后再按优先级安排密码回退,不让密码流程阻塞主入口验收。管理员只记录 `password_login_pass`、`wecom_login_pass`、`blocked_reason` 三类脱敏结果,不收集密码、验证码或完整账号。 + +邮件未送达时才进入兜底:管理员通过现有 `reset-password` 接口生成一次性密码,受控现场只显示一次并当面交付;接口强制首次改密、清除锁定、撤销旧会话、使旧凭据失效并写审计。兜底写入需在实际发生前再次获得批准。 + +## 5. Failure Matrix + +- 邮件验证码未收到:先核对投递状态和企业邮箱可用性;不反复请求导致限流。确认邮件路径不可用后才考虑管理员一次性密码。 +- 企微内无法同时看登录页和邮箱:改用电脑浏览器完成忘记密码,手机只收验证码;不要为看邮件关闭正在执行 binding 的企微 WebView。 +- 验证码过期/错误:由员工重新请求;不得让管理员代收或索取验证码。 +- 页面显示已发送但未看到新邮件:先只读确认是否真正签发新 code、有效期、失败次数和邮件发送失败计数。新签发会使旧 code 失效;不得混用旧邮件,也不得在请求上限内连续点击重发。 +- 旧验证码被拒绝:归类为“新签发使旧码失效”,证明失败发生在 `/reset-password` 成功之前;不得据此推断前端缓存或首次改密标记未清除。 +- 正确验证码重设成功后仍跳到改密页:才归类为前端状态/缓存问题,并记录跳转 URL、页面和报错;不要与旧码失效混为一类。 +- 点击登录页“忘记密码”只进入找回页面,不会签发验证码。只有填写企业邮箱并点击“发送验证码/重新发送验证码”才会请求签发;页面导航次数不能作为验证码请求次数。 +- 账号或邮箱锁定:只读确认锁定类别和窗口;使用现有解锁接口前另行批准。 +- 企微仍显示 unbound:确认是否在企微内打开、OAuth 回调是否取得内部 userid、现有 binding 是否指向另一账户;不得按姓名猜测绑定。 +- binding conflict:失败关闭,保留原映射和审计,单独核实身份后再提出精确修正。 +- 邮箱验证码登录成功但必须改密页面要求未知旧密码:说明员工走了 `/email-login` 路径;它不同于会清除首次改密标记的 `/reset-password`,返回忘记密码流程,不绕过旧密码校验。 + +## 6. Rollback + +- 员工自助重设密码后旧密码和旧会话按设计失效,无需回滚到旧密码。 +- 新 binding 若经本人验证仍错误,只能在精确确认原/目标映射后使用现有解绑/绑定路径恢复;不得直接删除数据库记录。 +- 任一员工阻断不影响其他员工逐人完成,但全员验收保持未完成并记录缺口计数。 + +## 7. Prevention + +新账号从创建/导入进入 active 后,不得只以“有密码哈希/有 binding 行”宣告可用。启用完成必须包含安全凭据交付、首次改密、密码登录和企微成员本人工作台免登四项;缺一项则保持 onboarding 未完成并由管理员跟踪。 + +## 8. Compatibility And Audit + +- 已有 binding 的 3 个账户保持原路径,不额外请求通讯录详情。 +- 自动补绑定使用现有 `wecom_identity_bindings` 唯一约束,并在同一事务内二次核对 userid 与员工两侧冲突。 +- 新审计动作只记录匹配方式和布尔字段,不记录完整 userid、邮箱或企微原始 payload。 +- 未绑定页面不再指示员工输入密码完成正常绑定;错误只显示稳定类别,完整身份资料不得进入 URL、日志或前端状态。 +- 生产回填先 dry-run 汇总唯一匹配、无匹配和冲突计数,另行批准后才 apply。 diff --git a/.trellis/tasks/07-28-all-account-login-readiness/implement.jsonl b/.trellis/tasks/07-28-all-account-login-readiness/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/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-28-all-account-login-readiness/implement.md b/.trellis/tasks/07-28-all-account-login-readiness/implement.md new file mode 100644 index 00000000..4a884863 --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/implement.md @@ -0,0 +1,51 @@ +# 全员登录可用性实施计划 + +## 1. Read-only Baseline + +- [x] 核对公网 `/ready` 和认证 capabilities;企业邮箱验证码登录、忘记密码均启用。 +- [x] 聚合核对 4 个 active 账户的邮箱/手机号、密码状态、锁定、企微 binding 和成功登录类型,不输出个人标识或认证数据。 +- [x] 确认唯一管理员缺企微 binding,其余 3 个 active 账户已有 binding;当前企微成功登录证据为 0。 +- [x] 确认 3 个账户仍需首次改密且未知随机初始密码无法作为已交付凭据。 + +## 2. Administrator Self-Recovery + +- [x] 用户批准进入实施并确认本人可访问企业邮箱。 +- [x] 首次电脑自助请求已签发新的有效验证码;旧验证码失效后被误用 1 次,账户未锁定,服务端未记录邮件发送失败。 +- [x] 失败现场已定性并保留:失败发生在 `/reset-password` 成功之前,是第二次签发使旧验证码失效;没有前端缓存跳转证据。 +- [x] 执行已批准的管理员 break-glass:精确定位唯一 active 管理员,设置受控一次性密码,清除锁定、撤销旧会话、递增 token version、失效旧验证码并写脱敏审计。 +- [x] 独立只读复核通过:必须首次改密、锁定清零、活动会话与活动验证码均为 0,审计存在;公网 `/ready` 返回 200。 +- [x] 用户以一次性密码登录并完成强制改密;只读核对首次改密标记已清除、当前仅 1 个活动会话、改密与 break-glass 审计均存在。 +- [ ] 新版部署后用户从企微工作台重新进入,由 userid/唯一企业邮箱自动补 binding 并直接登录,不输入 OA 密码。 +- [x] 首次复测仍显示 unbound;只读诊断确认管理员尚无 binding,近 30 分钟没有来自企微 WebView 的成功或失败密码登录,也没有 bind 审计,说明尚未提交页面下方的账号密码表单。 +- [ ] 用户再次从企微工作台进入,确认直接到 OA 且不再出现 unbound。 +- [ ] 只读核对管理员新增密码成功、binding 和企微登录审计,输出仅限布尔值。 + +## 3. Remaining Active Accounts + +- [ ] 为其余 3 名已有 binding 的 active 员工发送不含账号/密码的操作说明,由本人先直接测试企微工作台免登。 +- [ ] 每名企微成员从工作台真机进入,验证唯一 binding 与免登;免登通过者不因密码流程而阻塞主入口验收。 +- [ ] 企微免登验证后,再逐人完成密码回退和首次改密状态收口;管理员不接触其密码或验证码。 +- [ ] 邮件失败者单独登记稳定失败类别;确需管理员一次性密码时先给出目标数量、审计和交付方案并另行获批。 + +## 4. Verification and Prevention + +- [ ] 聚合复核 active 总数、密码登录覆盖、企微 binding 覆盖、企微成功登录覆盖和锁定数;不得输出人员明细。 +- [ ] 验证无 binding conflict、无角色变化、无企微通讯录写入、无异常会话残留。 +- [x] 更新现有管理员手册/试运行 checklist:active 账号启用必须包含安全凭据交付、首次改密、密码登录和企微本人免登。 +- [x] 运行认证聚焦测试、前端 typecheck/build、Trellis task/governance、敏感扫描、文档审计和 `git diff --check`;生产部署后的公网 smoke test 仍待独立批准后执行。 + +## 4A. Passwordless First WeCom Login + +- [x] 抽取只读企微成员资料获取能力:输入 OAuth 已确认的内部 userid,只返回规范化企业邮箱和必要状态,不记录原始 payload。 +- [x] 普通企微登录在 binding 缺失时,按 userid/唯一企业邮箱匹配既有 active OA 账户;事务内二次检查双向冲突、建立 binding、写脱敏审计并创建会话。 +- [x] 对无匹配、inactive、userid 已绑定他人、账户已绑定其他 userid 和企微 API 失败逐类失败关闭;不按姓名绑定、不创建账户、不改角色。企微邮箱缺失时仅允许 userid 精确匹配。 +- [x] 保持已有 binding 快路径不变,避免每次登录都调用通讯录详情接口;密码登录不再消费 pending bind 建立企微映射。 +- [x] 修正 unbound 页面文案:不再把账号密码作为首次企微绑定说明,显示稳定的映射缺失/冲突处理指引。 +- [x] 增加后端路由与服务测试、前端文案/交互测试;覆盖首次免密自动绑定、已有 binding、负向矩阵和审计脱敏。 +- [ ] 生成生产存量 dry-run 脱敏汇总;部署和 apply 回填分别取得明确批准后执行。 + +## 5. Stop Conditions + +- 企业邮箱不可达、验证码投递异常或账户锁定:停止该员工操作,不连续请求。 +- 企微回调缺 userid、binding conflict 或映射不唯一:停止写入并只读诊断。 +- 任何步骤要求在聊天、任务、命令参数或普通日志中暴露密码、验证码、完整账号或 userid:立即停止并改用受控现场交付。 diff --git a/.trellis/tasks/07-28-all-account-login-readiness/prd.md b/.trellis/tasks/07-28-all-account-login-readiness/prd.md new file mode 100644 index 00000000..abde24e2 --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/prd.md @@ -0,0 +1,108 @@ +# 全员登录可用性 + +## Goal + +确保每个 active OA 账户至少有一条实际可验收的登录路径,并为企业微信成员预先建立 userid/企业邮箱到 OA 账户的唯一映射;员工第一次从企微工作台进入即直接登录,不把输入 OA 用户名和密码作为首次绑定步骤。 + +## Scope + +- 当前生产 4 个 active 账户的密码回退与企业微信工作台免登基线。 +- 管理员本人优先通过已批准的受控 break-glass 恢复密码,再绑定企微并真机验收;其余 3 个已有 binding 的账户先验证企微免登,再按需完善密码回退。 +- 邮件失败时使用现有管理员一次性密码接口作为另行批准的兜底。 +- 更新现有账号启用/入职操作门禁,防止后续 active 账户再次只有未知随机密码或未经本人验证的 binding。 +- 改造企微登录缺失 binding 时的唯一企业邮箱自动匹配、事务绑定、审计和失败关闭逻辑;同步修正未绑定页面指引。 + +## What I Already Know + +- 管理员本人从企业微信工作台进入时收到“当前企业微信账号尚未绑定”,且没有已知可用密码。 +- 当前登录页的未绑定流程要求先使用账号密码登录,再到个人设置完成企业微信绑定;这对不知道随机初始密码的导入账户会形成死锁。 +- 现有管理员手册已经规定:全员应能账号密码登录,已绑定员工还应能从企业微信工作台进入。 +- OA 已有管理员重置普通账户密码接口:生成一次性密码、强制首次改密、清空锁定、撤销旧会话并写审计。 +- OA 也支持企业邮箱验证码登录,但真实邮件投递和边缘 challenge 端点是否当前可用必须现场核对,不能假定为可用兜底。 +- 企业微信普通登录只按回调 userid 查询唯一 binding;找不到 active 绑定就进入 `wecom=unbound`,不会按姓名、邮箱或手机号自动猜测账户。 +- 生产只读基线为:active 账户 4 个,全部有企业邮箱和密码哈希,3 个有手机号,当前密码/邮箱验证码均无锁定。 +- 4 个 active 账户中 3 个处于 `must_change_password=true`,但没有实际交付随机初始密码的证据;仅 1 个账户有历史成功密码登录,邮箱验证码成功登录和企微成功登录均为 0。 +- 3 个 active 账户已有企微 binding;唯一 active 管理员正是缺 binding 的账户,且该管理员已有历史密码成功登录、当前无需强制改密但本人已不知道密码。 +- 企业微信通讯录本身拥有管理员 userid,但生产只读核对显示 OA 侧没有该管理员的目录同步 userid、成功 HR 主数据映射或 identity binding;当前 OAuth 回调只能拿到 userid,无法仅凭它反推出唯一 OA 账户。 +- 现有企微 HR 主数据同步已经能够按 binding、userid/员工编号、手机号或企业邮箱解析员工并在 apply 时写入 binding,但当前普通 `mode=login` 回调只查 binding;binding 缺失时不会利用企微通讯录企业邮箱匹配既有 OA 账户。 +- 公网认证能力当前报告企业邮箱验证码登录和忘记密码均已启用。 +- `/forgot-password -> /reset-password` 是完整的验证码重设密码路径;正确验证码会设置新密码并清除 `must_change_password`,不是死路。 +- 本次实际失败发生在 `/reset-password` 成功之前:第二次签发使第一次验证码失效,用户收到并使用旧验证码后被后端拒绝;未发现“重置成功后被前端缓存再次送回改密页”的现场证据。 +- `/email-login/code -> /email-login/verify` 是另一条邮箱验证码登录路径;现有前端在验证成功后仍遵守 `must_change_password` 跳转,而修改密码页强制要求旧密码,因此它对未知随机初始密码账户可能形成死路,但与本次失败无关。 + +## Requirements (Evolving) + +- 登录基线固定为双路径:每个 active 账户都必须具备可实际交付并完成首次改密的账号密码回退;属于当前企业微信通讯录的 active 员工还必须具备唯一 binding 和工作台免登。 +- 先以只读方式核对所有 active 账户的状态、可登录标识、密码是否为强制首次改密状态、登录锁定、企业微信 binding、最近成功登录类型和企业邮箱验证码能力;只输出计数和脱敏状态。 +- 把“数据库里存在密码哈希”与“员工实际掌握可用凭据”区分开,后者必须由一次真实登录或受控的一次性密码交付证明。 +- 管理员本人先通过已批准的受控 break-glass 恢复一条安全登录路径,再完成企业微信绑定和工作台真机登录,不通过聊天、命令参数、环境变量、Trellis、日志或仓库传递密码。 +- 其余 3 个已有 binding 的 active 账户先直接验证企业微信工作台免登;只有免登不通或需要独立回退时,才进入密码找回或管理员一次性密码流程。 +- 企微成员首次工作台登录不得依赖 OA 密码:已有 userid binding 直接登录;binding 缺失时,仅允许使用企微接口返回的企业邮箱精确匹配唯一 active OA 账户,事务内创建 binding、写审计并继续登录。 +- 企业邮箱缺失、无 active OA 匹配、目标账户已绑定另一 userid、userid 已绑定另一账户或企微资料接口失败时必须失败关闭;姓名只用于人工核对,不得作为自动绑定键。 +- 自动匹配只允许绑定既有 active OA 账户,不得在普通登录中自动创建员工、修改角色、更新通讯录或覆盖冲突 binding。 +- 通讯录/HR 主数据同步应在账号启用前建立 binding;登录时自动补绑定仅作为确定性兜底,不替代入职门禁。 +- 对每个 active 账户建立可验收的账号密码回退;属于当前企业微信通讯录的 active 员工还应具备唯一 binding 和工作台免登。 +- 当前 4 个 active 账户逐人验收后,把“密码回退已完成 + 企微成员免登已完成”写入新账号启用/入职门禁,避免后续导入账户再次只有未知随机密码。 +- 密码重置必须使用现有审计接口或经单独批准的受控 break-glass 路径;生成的一次性密码仅在受控现场显示一次,首次登录立即改密。 +- 任何 binding 修正必须先证明目标 OA 员工和企微 userid 唯一对应;不得按姓名模糊匹配,不得修改企业微信通讯录成员。 +- 生产 binding、密码、账户状态或认证配置写入前另行给出精确变更清单并取得批准。 + +## Decision: Credential Recovery + +**Context**:管理员缺企微 binding 且未知密码,忘记密码现场又出现第二封验证码未送达;其余 3 个 active 账户已有企微 binding,但尚无真实企微登录记录。让管理员接触所有员工密码会扩大敏感信息处理面。 + +**Decision**:管理员先执行已批准的受控 break-glass,随后绑定企微并验证免登;其余 3 人先直接验证企微免登。只有免登不通或需要建立独立密码回退时,员工才使用忘记密码;邮件确实无法送达时,再由管理员使用审计能力生成一次性密码并线下当面交付。 + +**Consequences**:先验证系统预期主入口,避免让 3 名员工重复经历尚未稳定的邮件找回流程;最终双路径基线不变。任何一次性密码都不得经聊天、任务、邮件正文、命令参数、环境变量或普通日志转发。 + +## Decision (ADR-lite) + +**Context**:仅保证一种登录方式会在企微 binding 漂移或未知随机初始密码时再次形成登录死锁。 + +**Decision**:采用“双路径基线”。账号密码是每个 active 账户的独立回退;企业微信成员在此基础上必须完成工作台免登。 + +**Consequences**:需要逐人安全交付一次性密码并完成首次改密,也需要逐人企微真机验收;实施工作略多,但能避免任何单一入口故障把员工锁在 OA 外。 + +## Acceptance Criteria (Evolving) + +- [x] 管理员本人能使用一次性凭据完成首次改密,且凭据未进入聊天、任务材料、日志或仓库。 +- [ ] 管理员本人能从企业微信工作台直接进入 OA,不再出现未绑定提示。 +- [x] 一个“无 binding、企微企业邮箱唯一匹配 active OA 账户”的合成场景能在不输入 OA 密码的情况下自动建立 binding、写审计并直接登录。 +- [x] 企业邮箱缺失、无匹配、inactive、userid/binding 冲突等负向场景全部失败关闭,且不创建账户、不改角色、不按姓名绑定。 +- [ ] 所有 active 账户均有一次实际账号密码登录验收或等价的安全凭据交付与首次改密证据。 +- [ ] 所有属于当前企业微信通讯录的 active 账户均有唯一 binding,并完成本人企微真机登录验收。 +- [ ] 不存在 active 账户同时缺少可交付密码回退和可用企微 binding。 +- [ ] 所有重置、绑定、解锁、会话撤销均有 OA 审计且未扩角色、未修改企业微信通讯录。 +- [ ] 自动化、Trellis、敏感扫描、生产 `/ready` 和关联认证 smoke 通过后才通知员工测试。 + +## Definition of Done + +- 只读基线、写入前后差异、逐人登录验收和剩余阻断均以脱敏计数记录。 +- 相关后端测试、前端 typecheck/build、认证 smoke、Trellis 治理和敏感扫描通过。 +- 生产变更具备最小回滚:binding 修正可恢复原映射,密码重置通过 token version 和会话撤销确保旧凭据失效。 +- 现有管理员手册和试运行事实源与最终登录基线一致。 + +## Test Plan + +- 生产前后只读聚合:active 总数、必须改密数、锁定数、binding 数和各成功登录类型覆盖数。 +- 管理员本人及其余 active 员工分别完成真实密码登录与企业微信工作台免登;人工证据只记录 PASS/BLOCKED 和稳定原因。 +- 若发生管理员一次性密码兜底,核对强制改密、旧会话撤销、token version 和审计增量,不记录密码正文。 +- 运行认证聚焦后端测试、前端 typecheck/build、生产 `/ready` 与认证 capabilities、公网只读 smoke。 +- 运行 Trellis task/governance、敏感扫描、文档审计、流程合同和 `git diff --check`。 + +## Out of Scope + +- 不在 OA 中创建或修改企业微信成员。 +- 不把真实密码、完整账号、手机号、邮箱、企微 userid 或认证原始 payload 写入任务证据。 +- 不启用自助 OAuth onboarding,不重构认证架构,不放宽登录、绑定或账户管理权限。 +- 本轮不修改“邮箱验证码登录后首次改密仍要求旧密码”的认证流程。 +- 不启用会自动创建 OA 员工的通用企微 OAuth self-provisioning;本轮普通登录只允许匹配并绑定既有 active OA 账户。 +- 不顺带导入剩余通讯录成员、修改角色、主管或业务审批规则。 + +## Technical Notes + +- 企微登录入口与 binding:`server/src/routes/wecomAuth.js`。 +- 账号密码和邮箱验证码:`server/src/routes/auth.js`;普通密码登录不再消费 pending bind 建立企微映射。 +- 管理员一次性密码重置:`server/src/routes/users.js`。 +- 当前产品事实源:`docs/管理员手册.md` 第 10、12 节。 +- 安全边界:`.trellis/spec/hyb-oa/security-privacy.md`、`.trellis/spec/hyb-oa/wecom-boundary.md`。 diff --git a/.trellis/tasks/07-28-all-account-login-readiness/task.json b/.trellis/tasks/07-28-all-account-login-readiness/task.json new file mode 100644 index 00000000..72bc2101 --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/task.json @@ -0,0 +1,26 @@ +{ + "id": "all-account-login-readiness", + "name": "all-account-login-readiness", + "title": "全员登录可用性", + "description": "核对并修复 active OA 账户登录路径,实现 userid/唯一企业邮箱预绑定或自动补绑定,保证员工首次从企微工作台进入无需 OA 密码即可直接登录。", + "status": "in_progress", + "dev_type": "operations", + "scope": "authentication", + "package": "hyb-oa", + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-07-28", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "用户将首次企微免密登录设为硬性要求:OA 账户须通过 userid 或唯一企业邮箱预绑定/自动补绑定,不允许首次输入 OA 密码。已有 binding 快路径及 userid/企微企业邮箱精确匹配既有 active OA 账户的事务补绑定已实现,本地后端全量测试、前端构建、E2E 与治理验证通过;姓名不作为键,缺失/无匹配/inactive/冲突/API 失败全部关闭,不自动创建账户或改角色。等待生产部署和存量映射 dry-run/apply 的独立批准。", + "meta": {} +} diff --git a/client/src/lib/wecomEntry.ts b/client/src/lib/wecomEntry.ts index 9519d04a..eb100e0c 100644 --- a/client/src/lib/wecomEntry.ts +++ b/client/src/lib/wecomEntry.ts @@ -69,11 +69,27 @@ export function resolveWecomEntryStatus(config?: WecomEntryConfig | null) { } export function resolveWecomLoginNotice(value: string | null): WecomNotice | null { - if (value === "unbound") { + if (value === "unbound" || value === "account_mapping_required") { return { tone: "warning", - title: "当前企业微信账号尚未绑定", - description: "请先用账号密码登录,再到个人设置完成绑定。", + title: "企业微信账号映射尚未完成", + description: "系统未能用企业微信 userid 或企业邮箱唯一匹配 OA 账户,请联系管理员核对通讯录同步。", + }; + } + + if (value === "account_mapping_conflict") { + return { + tone: "warning", + title: "企业微信账号映射存在冲突", + description: "userid 与 OA 账户映射不唯一,系统已停止自动绑定,请联系管理员核对。", + }; + } + + if (value === "account_mapping_inactive") { + return { + tone: "warning", + title: "对应的 OA 账号不可用", + description: "企微成员或匹配到的 OA 账户不是启用状态,请联系管理员处理。", }; } diff --git a/client/tests/e2e/wecom-entry.spec.ts b/client/tests/e2e/wecom-entry.spec.ts index cee9ddab..88a9a60d 100644 --- a/client/tests/e2e/wecom-entry.spec.ts +++ b/client/tests/e2e/wecom-entry.spec.ts @@ -63,7 +63,7 @@ test("E2E 企微入口:普通浏览器下只提示企业微信工作台打开" await expect(page.getByTestId("wecom-entry-secondary")).toHaveCount(0); }); -test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => { +test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示映射缺失提示", async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(window.navigator, "userAgent", { configurable: true, @@ -80,9 +80,10 @@ test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未 }); }); - await page.goto("/login?redirect=%2Fportal&wecom=unbound"); + 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("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定"); + 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/client/tests/e2e/wecom-first-bind.spec.ts b/client/tests/e2e/wecom-first-bind.spec.ts index 06966c52..ac24dbbb 100644 --- a/client/tests/e2e/wecom-first-bind.spec.ts +++ b/client/tests/e2e/wecom-first-bind.spec.ts @@ -7,7 +7,7 @@ function envelope(data: unknown) { }; } -test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入目标页", async ({ page }) => { +test("E2E 企微首次登录:映射缺失时失败关闭且不要求用密码完成绑定", async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(window.navigator, "userAgent", { configurable: true, @@ -177,13 +177,8 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入 }); }); - await page.goto("/login?redirect=%2Fmobile&wecom=unbound"); - await expect(page.getByTestId("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定"); - if (await page.getByTestId("login-identifier").count() === 0) { - await page.getByRole("button", { name: "使用账号密码登录" }).click(); - } - await page.getByTestId("login-identifier").fill("zhangsan@example.com"); - await page.getByTestId("login-password").fill("ResetA1abc123def456"); - await page.getByTestId("login-submit").click(); - await expect(page).toHaveURL(/\/mobile/); + await page.goto("/login?redirect=%2Fmobile&wecom=account_mapping_required"); + await expect(page.getByTestId("login-wecom-notice")).toContainText("企业微信账号映射尚未完成"); + await expect(page.getByTestId("login-wecom-notice")).toContainText("请联系管理员核对通讯录同步"); + await expect(page.getByTestId("login-wecom-notice")).not.toContainText("先用账号密码登录"); }); diff --git a/docs/trialrun/R7用户手册与FAQ.md b/docs/trialrun/R7用户手册与FAQ.md index 98ef743d..68dd7003 100644 --- a/docs/trialrun/R7用户手册与FAQ.md +++ b/docs/trialrun/R7用户手册与FAQ.md @@ -20,7 +20,8 @@ - PC:打开 `https://oa.hyinda.com`,使用账号密码登录。 - 手机:从企业微信打开 `海音达工作台`,进入移动工作台。 -- 如果企业微信免登失败,先用账号密码登录,再到 `个人设置 -> 企业微信绑定` 检查绑定状态。 +- 第一次从企业微信进入时,系统按 userid 或唯一企业邮箱自动匹配既有 active OA 账户并直接登录,不需要先输入 OA 密码。 +- 如果提示账号映射缺失或冲突,停止重试并联系管理员核对通讯录同步;系统不会按姓名猜测或覆盖现有绑定。 ### 2.2 提交报销 diff --git a/docs/员工快速上手.md b/docs/员工快速上手.md index 0e884378..77a10987 100644 --- a/docs/员工快速上手.md +++ b/docs/员工快速上手.md @@ -68,8 +68,9 @@ - 如果公司启用了企业微信工作台入口,优先从企业微信打开海音达工作台。 - 企业微信不只是入口:后续请假、补卡、外出、加班、打卡和部分人事资料优先在企业微信完成。 - OA 保留报销、财务、采购、合同、项目、镜像查询和应急接管。企业微信不可用时,以管理员通知为准临时改用 OA 应急入口。 -- 如果企业微信免登失败,直接回到账号密码登录即可。 -- 如果你第一次从企业微信进入但看到“尚未绑定”,请不要反复重试,先用账号密码登录,再到 `个人设置 -> 企业微信绑定` 完成绑定。 +- 第一次从企业微信进入时,系统会按 userid 或企业邮箱自动匹配 OA 账户并直接登录,不需要先输入 OA 密码。 +- 如果看到“账号映射尚未完成”或“账号映射存在冲突”,请不要反复重试,也不要把密码交给他人;联系管理员核对通讯录同步和 OA 账号状态。 +- 企业微信服务不可用时,账号密码仍可作为独立应急入口,但它不是首次企微绑定步骤。 - 如果看到“登录已失效 / 已超时”,重新从企业微信工作台进入即可。 - 日常建议:手机从企业微信打开海音达工作台,PC 直接登录 OA;报销、项目更新、采购和财务事项在 OA 里完成,HR/考勤事项优先在企业微信完成。 diff --git a/docs/管理员手册.md b/docs/管理员手册.md index d1efae0a..125a833c 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -641,7 +641,7 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程 - 公司只维护一个企业微信自建应用,建议命名为 `海音达工作台`。 - 默认工作台入口在企业微信手机端落到 `/mobile`;桌面浏览器和账号密码登录仍进入 `/portal`。OA 主办业务的真正操作仍在 `hyb-oa` 内完成。 - 已上线的报销、采购、费用、合同、项目和财务类 OA 主办审批都在 OA 内提交和审批;企业微信消息只负责脱敏提醒并 deeplink 回 OA。请假、补卡、外出、加班后续按 WFOC/DBWFOC 迁往企业微信原生审批,OA 同步镜像并保留应急入口。 -- 企微免登建议先完成账号绑定,再逐步在试点部门开启;任何时候都保留账号密码回退。 +- 企微免登要求在账号启用时通过 userid 或唯一企业邮箱完成映射;员工第一次从工作台进入不需要先输入 OA 密码。账号密码只作为企微不可用时的独立回退。 - 若 OA 部署到公网,生产建议同时满足:`HTTPS`、`COOKIE_SECURE=true`、登录失败锁定、限流、审计开启。 ### 正式接入最小步骤 @@ -657,12 +657,13 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程 - `WECOM_AGENT_SECRET=...` - `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback` - `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com` -5. 先让管理员用账号密码登录,在 `个人设置` 完成一次绑定,再测试企业微信工作台直达;已绑定用户从手机企业微信进入时应直接看到移动工作台。 +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 页面完成。 ### 员工会看到的典型状态 -- 未绑定:登录页会提示“当前企业微信账号尚未绑定”,回退到账号密码登录。 +- 映射唯一:首次从工作台进入即可自动补 binding 并直接登录,无需 OA 密码。 +- 映射缺失或冲突:登录页提示联系管理员核对通讯录同步;系统不按姓名猜测、不创建账户、不覆盖冲突 binding。 - 已绑定:从企业微信工作台进入后直接落到 `/mobile`,桌面或普通浏览器场景仍可进入 `Portal`。 - 入口未开放:个人设置仍可绑定,但登录页不会把企业微信作为正式入口展示。 - 配置不完整:个人设置会明确提示缺少 `CorpID / AgentID / 回调地址` 等原因。 diff --git a/docs/需求-企业微信基础设施规约.md b/docs/需求-企业微信基础设施规约.md index 660e2a78..b51ad6f4 100644 --- a/docs/需求-企业微信基础设施规约.md +++ b/docs/需求-企业微信基础设施规约.md @@ -91,8 +91,8 @@ WFOC 后,企微/人事助手是 HR 员工主数据源;OA 是员工镜像、 | 字段 | 用途 | 要求 | | --- | --- | --- | | 企业微信 `userId` | 企业微信成员身份主键 | 与员工绑定时必须唯一 | -| 企业微信手机号 | 辅助匹配员工 | 只能作为候选,不可单独作为登录授权 | -| 企业微信邮箱 | 辅助匹配员工 | 只能作为候选,不可单独作为登录授权 | +| 企业微信手机号 | 辅助核对员工 | 不作为普通登录自动绑定键 | +| 企业微信邮箱 | 稳定匹配员工 | 规范化后精确匹配唯一 active OA 账户,可用于自动补 binding | | OA `employee_id` | OA 员工主键 | 权限和业务数据以此为准 | | 绑定时间 | 审计 | 必须记录 | | 绑定人/解绑人 | 审计 | 自助绑定和管理员处理都必须记录 | @@ -100,7 +100,9 @@ WFOC 后,企微/人事助手是 HR 员工主数据源;OA 是员工镜像、 ### 4.2 绑定规则 - 已绑定且 OA 员工账号正常:从企业微信进入后直达 `/mobile` 或 deeplink 目标页。 -- 未绑定:提示“当前企业微信账号尚未绑定”,允许账号密码登录后在个人设置完成绑定。 +- 未绑定:先按 userid/员工编号精确匹配;仍未命中时读取企微企业邮箱并精确匹配唯一 active OA 账户,事务内建立 binding、写审计并直接登录。 +- 首次企微登录不得要求员工先输入 OA 用户名和密码。姓名和手机号不得作为自动绑定键。 +- 企业邮箱缺失、无匹配、停用、userid 与邮箱指向不同账户或任一侧已有冲突 binding 时必须失败关闭,不创建 OA 账户、不修改角色、不覆盖映射。 - 一个企业微信身份只能绑定一个员工。 - 一个员工原则上只绑定一个当前有效企业微信身份;更换账号必须先解绑并留痕。 - 员工离职、账号停用或组织权限移除后,企业微信入口不得继续进入 OA 业务页面。 @@ -112,7 +114,7 @@ WFOC 后,企微/人事助手是 HR 员工主数据源;OA 是员工镜像、 | --- | --- | --- | --- | | 入口配置 | 未启用 | 企业微信入口不展示或提示不可用 | 记录配置状态,不影响账号密码登录 | | 入口配置 | 已启用 | 企业微信工作台可进入 OA | 记录启用人、启用时间和可见范围 | -| 绑定关系 | 未绑定 | 企业微信身份尚未关联 OA 员工 | 可回退账号密码登录 | +| 绑定关系 | 未绑定 | 企业微信身份尚未关联 OA 员工 | 唯一稳定键自动补绑定;否则提示管理员处理 | | 绑定关系 | 已绑定 | 企业微信身份已关联有效 OA 员工 | 记录绑定时间、绑定方式和员工 ID | | 绑定关系 | 已解绑 | 绑定关系被员工或管理员解除 | 记录解绑人、解绑时间和原因 | | 绑定关系 | 已停用 | 员工离职、停用或组织权限移除 | 阻断企业微信入口进入业务页面 | @@ -127,7 +129,8 @@ WFOC 后,企微/人事助手是 HR 员工主数据源;OA 是员工镜像、 | 异常 | 处理规则 | 用户提示 | | --- | --- | --- | -| 未绑定 | 回退账号密码登录,登录后可自助绑定 | “当前企业微信账号尚未绑定,请先使用 OA 账号登录并完成绑定” | +| 映射缺失 | 不创建会话,不要求员工用密码绑定 | “企业微信账号映射尚未完成,请联系管理员核对通讯录同步” | +| 映射冲突 | 阻断,不覆盖任一现有 binding | “企业微信账号映射存在冲突,请联系管理员核对” | | 重复绑定 | 阻断,并提示联系管理员 | “该企业微信账号已绑定其他员工” | | OA 账号停用 | 阻断进入业务页面 | “账号已停用,请联系管理员” | | 企业微信回调失败 | 不创建会话,回退登录页 | “企业微信登录失败,请重试或使用账号密码登录” | diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 61695d73..36c6848e 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -628,7 +628,6 @@ async function authRoutes(fastify, options = {}) { const now = new Date(); const pendingWecomBindToken = request.cookies?.[PENDING_WECOM_BIND_COOKIE_NAME]; - const pendingWecomBind = verifyToken(pendingWecomBindToken, fastify.config.jwtSecret); if (employee.login_locked_until && employee.login_locked_until > now) { throw new AppError({ code: "AUTH_002", @@ -728,7 +727,7 @@ async function authRoutes(fastify, options = {}) { const refreshToken = randomRefreshToken(); const refreshTokenHash = sha256Hex(refreshToken); const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); - let wecomBindingStatus = null; + const wecomBindingStatus = null; await fastify.prisma.$transaction(async (tx) => { const guardedEmployee = await tx.employee.updateMany({ @@ -765,63 +764,6 @@ async function authRoutes(fastify, options = {}) { expiresAt, }); - if (pendingWecomBind.ok) { - const payload = pendingWecomBind.payload ?? {}; - const externalUserId = - typeof payload.externalUserId === "string" && payload.externalUserId.trim() - ? payload.externalUserId.trim() - : null; - const externalOpenUserId = - typeof payload.externalOpenUserId === "string" && payload.externalOpenUserId.trim() - ? payload.externalOpenUserId.trim() - : null; - const isExpired = typeof payload.exp !== "number" || payload.exp <= Date.now(); - - if (!externalUserId || isExpired) { - wecomBindingStatus = "invalid"; - } else { - const conflictingBinding = await tx.wecomIdentityBinding.findFirst({ - where: { - external_user_id: externalUserId, - NOT: { employee_id: employee.id }, - }, - select: { employee_id: true }, - }); - - if (conflictingBinding) { - wecomBindingStatus = "conflict"; - } else { - await tx.wecomIdentityBinding.upsert({ - where: { employee_id: employee.id }, - update: { - external_user_id: externalUserId, - external_open_user_id: externalOpenUserId, - updated_by: employee.id, - }, - create: { - employee_id: employee.id, - external_user_id: externalUserId, - external_open_user_id: externalOpenUserId, - created_by: employee.id, - updated_by: employee.id, - }, - }); - await tx.auditLog.create({ - data: buildAuditLogData(request, { - actorId: employee.id, - action: "AUTH_WECOM_BIND", - targetType: "employee", - targetId: employee.id, - extra: { externalUserId }, - }), - }); - wecomBindingStatus = "bound"; - } - } - } else if (typeof pendingWecomBindToken === "string" && pendingWecomBindToken) { - wecomBindingStatus = "invalid"; - } - await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: employee.id, diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index dde0fc3d..3a41af14 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -11,6 +11,11 @@ const { fetchWecomOAuthOnboardingProfile, provisionWecomOAuthEmployee, } = require("../services/integrations/wecomOAuthOnboarding"); +const { + WecomLoginAutoBindingError, + autoBindExistingWecomEmployee, + fetchWecomLoginIdentity, +} = require("../services/integrations/wecomLoginAutoBinding"); const BIND_COOKIE_NAME = "wecom_bind"; const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind"; @@ -603,19 +608,51 @@ async function wecomAuthRoutes(fastify) { where: { external_user_id: profile.externalUserId }, select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } }, }); - if (!binding?.employee || binding.employee.status !== "active") { - const pendingBindToken = signPayload({ - exp: Date.now() + DATE_WINDOW_MS, - externalUserId: profile.externalUserId, - externalOpenUserId: profile.externalOpenUserId, - returnTo, - }, fastify.config.jwtSecret); - reply.setCookie( - PENDING_LOGIN_BIND_COOKIE_NAME, - pendingBindToken, - pendingLoginBindCookieOptions(fastify.config), + let loginEmployee = binding?.employee ?? null; + let bindingSource = "existing"; + if (loginEmployee && loginEmployee.status !== "active") { + reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); + return reply.redirect( + `${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=account_mapping_inactive`, ); - return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`); + } + if (!loginEmployee) { + try { + const identity = await fetchWecomLoginIdentity({ + accessToken: profile.accessToken, + externalUserId: profile.externalUserId, + }); + loginEmployee = await autoBindExistingWecomEmployee({ + prisma: fastify.prisma, + request, + identity, + externalOpenUserId: profile.externalOpenUserId, + }); + bindingSource = loginEmployee.autoBound ? "auto" : "concurrent_existing"; + } catch (error) { + if (!(error instanceof WecomLoginAutoBindingError)) throw error; + const conflictCodes = new Set([ + "oa_employee_conflict", + "wecom_userid_conflict", + "oa_employee_binding_conflict", + "binding_race_conflict", + "wecom_identity_mismatch", + ]); + const inactiveCodes = new Set(["wecom_member_inactive", "oa_employee_inactive"]); + const notice = conflictCodes.has(error.code) + ? "account_mapping_conflict" + : inactiveCodes.has(error.code) + ? "account_mapping_inactive" + : "account_mapping_required"; + request.log.warn( + { requestId: request.id, errorCode: error.code, notice }, + "wecom automatic login binding failed closed", + ); + reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); + return reply.redirect( + `${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${notice}`, + ); + } } const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint); @@ -623,13 +660,14 @@ async function wecomAuthRoutes(fastify) { prisma: fastify.prisma, request, config: fastify.config, - employeeId: binding.employee.id, - tokenVersion: binding.employee.token_version, + employeeId: loginEmployee.id, + tokenVersion: loginEmployee.token_version, deviceFingerprint, auditAction: "AUTH_WECOM_LOGIN_SUCCESS", - extra: { externalUserId: profile.externalUserId }, + extra: { bindingSource }, }); + reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config)); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config)); diff --git a/server/src/scripts/reset-active-admin-password.js b/server/src/scripts/reset-active-admin-password.js new file mode 100644 index 00000000..9c4edc25 --- /dev/null +++ b/server/src/scripts/reset-active-admin-password.js @@ -0,0 +1,473 @@ +#!/usr/bin/env node + +const crypto = require("node:crypto"); + +const { createPrismaClient } = require("../prismaClient"); +const { hashPassword } = require("../utils/crypto"); +const { validatePassword } = require("../utils/passwordPolicy"); + +const PROTECTED_ADMIN_ROLES = ["admin", "system_admin"]; +const APPLY_CONFIRMATION = "ACTIVE_ADMIN_BREAK_GLASS"; + +function writeResult(result) { + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +async function readPasswordFromStdin() { + if (process.stdin.isTTY) { + throw new Error("password_stdin_required"); + } + + process.stdin.setEncoding("utf8"); + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + if (input.length > 1024) { + throw new Error("password_input_too_long"); + } + } + + const password = input.replace(/[\r\n]+$/, ""); + if (!password || password !== password.trim()) { + throw new Error("password_input_invalid"); + } + + const errors = validatePassword(password); + if (errors.length > 0) { + throw new Error("password_policy_failed"); + } + return password; +} + +async function findActiveAdmins(prisma) { + return prisma.employee.findMany({ + where: { + status: "active", + archived_at: null, + roles: { some: { role_code: { in: PROTECTED_ADMIN_ROLES } } }, + }, + select: { id: true, token_version: true }, + take: 2, + }); +} + +async function preflight(prisma) { + const admins = await findActiveAdmins(prisma); + return { + status: admins.length === 1 ? "PASS" : "BLOCKED", + activeAdminMatches: admins.length, + exactTarget: admins.length === 1, + }; +} + +async function verifyState(prisma) { + const admins = await findActiveAdmins(prisma); + if (admins.length !== 1) { + return { + status: "BLOCKED", + activeAdminMatches: admins.length, + exactTarget: false, + }; + } + + const target = admins[0]; + const [ + employee, + activeSessions, + activePasswordResetCodes, + activeEmailLoginCodes, + auditEntries, + ] = await Promise.all([ + prisma.employee.findUnique({ + where: { id: target.id }, + select: { + must_change_password: true, + login_failed_count: true, + login_locked_until: true, + email_login_code_failed_count: true, + email_login_code_locked_until: true, + password_reset_failed_count: true, + password_reset_locked_until: true, + token_version: true, + }, + }), + prisma.authSession.count({ + where: { employee_id: target.id, revoked_at: null }, + }), + prisma.passwordResetCode.count({ + where: { employee_id: target.id, invalidated_at: null }, + }), + prisma.emailLoginCode.count({ + where: { employee_id: target.id, used_at: null }, + }), + prisma.auditLog.count({ + where: { + actor_id: target.id, + target_id: target.id, + action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET", + }, + }), + ]); + + const locksCleared = Boolean( + employee && + employee.login_failed_count === 0 && + employee.login_locked_until === null && + employee.email_login_code_failed_count === 0 && + employee.email_login_code_locked_until === null && + employee.password_reset_failed_count === 0 && + employee.password_reset_locked_until === null, + ); + const pass = Boolean( + employee?.must_change_password === true && + locksCleared && + activeSessions === 0 && + activePasswordResetCodes === 0 && + activeEmailLoginCodes === 0 && + auditEntries > 0, + ); + + return { + status: pass ? "PASS" : "BLOCKED", + activeAdminMatches: 1, + exactTarget: true, + mustChangePassword: employee?.must_change_password === true, + locksCleared, + activeSessions, + activePasswordResetCodes, + activeEmailLoginCodes, + auditPresent: auditEntries > 0, + }; +} + +async function verifyCompletedPasswordChange(prisma) { + const admins = await findActiveAdmins(prisma); + if (admins.length !== 1) { + return { + status: "BLOCKED", + activeAdminMatches: admins.length, + exactTarget: false, + }; + } + + const target = admins[0]; + const [employee, activeSessions, passwordChangeAudits, breakGlassAudits] = + await Promise.all([ + prisma.employee.findUnique({ + where: { id: target.id }, + select: { + must_change_password: true, + login_failed_count: true, + login_locked_until: true, + email_login_code_failed_count: true, + email_login_code_locked_until: true, + password_reset_failed_count: true, + password_reset_locked_until: true, + }, + }), + prisma.authSession.count({ + where: { employee_id: target.id, revoked_at: null }, + }), + prisma.auditLog.count({ + where: { + actor_id: target.id, + target_id: target.id, + action: "AUTH_PASSWORD_CHANGED", + }, + }), + prisma.auditLog.count({ + where: { + actor_id: target.id, + target_id: target.id, + action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET", + }, + }), + ]); + const locksCleared = Boolean( + employee && + employee.login_failed_count === 0 && + employee.login_locked_until === null && + employee.email_login_code_failed_count === 0 && + employee.email_login_code_locked_until === null && + employee.password_reset_failed_count === 0 && + employee.password_reset_locked_until === null, + ); + const pass = Boolean( + employee?.must_change_password === false && + locksCleared && + activeSessions === 1 && + passwordChangeAudits > 0 && + breakGlassAudits > 0, + ); + + return { + status: pass ? "PASS" : "BLOCKED", + activeAdminMatches: 1, + exactTarget: true, + mustChangePassword: employee?.must_change_password === true, + locksCleared, + activeSessions, + passwordChangeAuditPresent: passwordChangeAudits > 0, + breakGlassAuditPresent: breakGlassAudits > 0, + }; +} + +async function diagnoseWecomBinding(prisma, now = new Date()) { + const admins = await findActiveAdmins(prisma); + if (admins.length !== 1) { + return { + status: "BLOCKED", + activeAdminMatches: admins.length, + exactTarget: false, + }; + } + + const target = admins[0]; + const since = new Date(now.getTime() - 30 * 60 * 1000); + const [binding, recentAudits, latestDirectorySync, linkedHrSyncItems] = + await Promise.all([ + prisma.wecomIdentityBinding.findUnique({ + where: { employee_id: target.id }, + select: { employee_id: true }, + }), + prisma.auditLog.findMany({ + where: { + actor_id: target.id, + created_at: { gte: since }, + action: { + in: [ + "AUTH_LOGIN_SUCCESS", + "AUTH_LOGIN_FAILED", + "AUTH_LOGIN_LOCKED", + "AUTH_WECOM_BIND", + "AUTH_WECOM_LOGIN_SUCCESS", + ], + }, + }, + select: { action: true, user_agent: true }, + }), + prisma.wecomDirectorySyncRun.findFirst({ + where: { employee_id: target.id, external_user_id: { not: null } }, + orderBy: { created_at: "desc" }, + select: { external_user_id: true, status: true }, + }), + prisma.wecomHrMasterSyncItem.count({ + where: { employee_id: target.id, status: "success" }, + }), + ]); + const directoryUserIdConflicts = latestDirectorySync?.external_user_id + ? await prisma.wecomIdentityBinding.count({ + where: { + external_user_id: latestDirectorySync.external_user_id, + NOT: { employee_id: target.id }, + }, + }) + : 0; + const passwordLogins = recentAudits.filter( + (audit) => audit.action === "AUTH_LOGIN_SUCCESS", + ); + const wecomWebViewPasswordLogins = passwordLogins.filter((audit) => + /wxwork|micromessenger/i.test(audit.user_agent ?? ""), + ); + const failedPasswordLogins = recentAudits.filter((audit) => + ["AUTH_LOGIN_FAILED", "AUTH_LOGIN_LOCKED"].includes(audit.action), + ); + const failedWecomWebViewPasswordLogins = failedPasswordLogins.filter( + (audit) => /wxwork|micromessenger/i.test(audit.user_agent ?? ""), + ); + + return { + status: "PASS", + activeAdminMatches: 1, + exactTarget: true, + targetBound: Boolean(binding), + directoryUserIdPresent: Boolean(latestDirectorySync?.external_user_id), + latestDirectorySyncSuccessful: latestDirectorySync?.status === "success", + directoryUserIdAlreadyBoundElsewhere: directoryUserIdConflicts > 0, + linkedSuccessfulHrSyncPresent: linkedHrSyncItems > 0, + recentPasswordLogins: passwordLogins.length, + recentPasswordLoginsFromWecomWebView: wecomWebViewPasswordLogins.length, + recentFailedPasswordLogins: failedPasswordLogins.length, + recentFailedPasswordLoginsFromWecomWebView: + failedWecomWebViewPasswordLogins.length, + recentWecomBindAudits: recentAudits.filter( + (audit) => audit.action === "AUTH_WECOM_BIND", + ).length, + recentWecomLoginAudits: recentAudits.filter( + (audit) => audit.action === "AUTH_WECOM_LOGIN_SUCCESS", + ).length, + }; +} + +async function applyReset(prisma, password) { + const admins = await findActiveAdmins(prisma); + if (admins.length !== 1) { + throw new Error("active_admin_target_not_unique"); + } + + const target = admins[0]; + const passwordHash = await hashPassword(password); + const now = new Date(); + const requestId = `ops-break-glass-${crypto.randomUUID()}`; + + const result = await prisma.$transaction(async (tx) => { + const current = await tx.employee.findUnique({ + where: { id: target.id }, + select: { + status: true, + archived_at: true, + token_version: true, + roles: { select: { role_code: true } }, + }, + }); + const protectedRoleCount = + current?.roles.filter((role) => + PROTECTED_ADMIN_ROLES.includes(role.role_code), + ).length ?? 0; + if ( + !current || + current.status !== "active" || + current.archived_at !== null || + protectedRoleCount === 0 || + current.token_version !== target.token_version + ) { + throw new Error("active_admin_target_changed"); + } + + const employee = await tx.employee.update({ + where: { id: target.id }, + data: { + password_hash: passwordHash, + must_change_password: true, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, + token_version: { increment: 1 }, + updated_by: target.id, + }, + select: { must_change_password: true, token_version: true }, + }); + + const revokedSessions = await tx.authSession.updateMany({ + where: { employee_id: target.id, revoked_at: null }, + data: { revoked_at: now, last_seen_at: now }, + }); + const invalidatedPasswordResetCodes = await tx.passwordResetCode.updateMany( + { + where: { employee_id: target.id, invalidated_at: null }, + data: { invalidated_at: now }, + }, + ); + const consumedEmailLoginCodes = await tx.emailLoginCode.updateMany({ + where: { employee_id: target.id, used_at: null }, + data: { used_at: now }, + }); + await tx.auditLog.create({ + data: { + request_id: requestId, + actor_id: target.id, + action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET", + target_type: "employee", + target_id: target.id, + ip_address: null, + user_agent: "controlled-ops-script", + extra: { credentialRecorded: false }, + }, + }); + + return { + status: "PASS", + activeAdminMatches: 1, + passwordUpdated: true, + mustChangePassword: employee.must_change_password === true, + tokenVersionIncremented: + employee.token_version === target.token_version + 1, + locksCleared: true, + revokedSessions: revokedSessions.count, + invalidatedPasswordResetCodes: invalidatedPasswordResetCodes.count, + consumedEmailLoginCodes: consumedEmailLoginCodes.count, + auditCreated: true, + }; + }); + + return result; +} + +async function main() { + const { prisma, pool } = createPrismaClient(); + try { + if (process.argv.includes("--preflight")) { + writeResult(await preflight(prisma)); + return; + } + if (process.argv.includes("--verify")) { + writeResult(await verifyState(prisma)); + return; + } + if (process.argv.includes("--verify-password-changed")) { + writeResult(await verifyCompletedPasswordChange(prisma)); + return; + } + if (process.argv.includes("--diagnose-wecom-binding")) { + writeResult(await diagnoseWecomBinding(prisma)); + return; + } + const applyIndex = process.argv.indexOf("--apply"); + if (applyIndex < 0 || process.argv[applyIndex + 1] !== APPLY_CONFIRMATION) { + throw new Error("explicit_confirmation_required"); + } + + const password = await readPasswordFromStdin(); + writeResult(await applyReset(prisma, password)); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +if (require.main === module) { + main().catch((error) => { + const safeErrorCategory = + typeof error?.message === "string" && + [ + "password_stdin_required", + "password_input_too_long", + "password_input_invalid", + "password_policy_failed", + "active_admin_target_not_unique", + "active_admin_target_changed", + "explicit_confirmation_required", + ].includes(error.message) + ? error.message + : typeof error?.code === "string" + ? error.code + : typeof error?.name === "string" + ? error.name + : "runtime_error"; + writeResult({ + status: "BLOCKED", + error: "break_glass_operation_failed", + category: safeErrorCategory, + }); + process.exitCode = 1; + }); +} + +module.exports = { + applyReset, + diagnoseWecomBinding, + findActiveAdmins, + preflight, + readPasswordFromStdin, + verifyCompletedPasswordChange, + verifyState, +}; diff --git a/server/src/services/integrations/wecomLoginAutoBinding.js b/server/src/services/integrations/wecomLoginAutoBinding.js new file mode 100644 index 00000000..c993e50f --- /dev/null +++ b/server/src/services/integrations/wecomLoginAutoBinding.js @@ -0,0 +1,257 @@ +const { z } = require("zod"); + +const { buildAuditLogData } = require("../../utils/audit"); + +const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; +const EMAIL_SCHEMA = z + .string() + .trim() + .email() + .max(200) + .transform((value) => value.toLowerCase()); + +class WecomLoginAutoBindingError extends Error { + constructor(code, message) { + super(message); + this.name = "WecomLoginAutoBindingError"; + this.code = code; + } +} + +function requiredString(value, maxLength) { + return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; +} + +function normalizeEmail(value) { + const parsed = EMAIL_SCHEMA.safeParse(value); + return parsed.success ? parsed.data : ""; +} + +async function fetchWecomLoginIdentity({ + accessToken, + externalUserId, + fetchImpl = fetch, + timeoutMs = 5000, +}) { + const token = requiredString(accessToken, 2000); + const userid = requiredString(externalUserId, 128); + if (!token || !userid) { + throw new WecomLoginAutoBindingError( + "wecom_identity_invalid", + "WeCom login identity is invalid", + ); + } + + const url = new URL(`${WECOM_API_BASE}/cgi-bin/user/get`); + url.searchParams.set("access_token", token); + url.searchParams.set("userid", userid); + + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + Math.max(100, Number(timeoutMs) || 5000), + ); + let response; + try { + response = await fetchImpl(url, { + method: "GET", + signal: controller.signal, + }); + } catch { + throw new WecomLoginAutoBindingError( + "wecom_profile_unavailable", + "WeCom member profile is unavailable", + ); + } finally { + clearTimeout(timer); + } + if (!response || typeof response.json !== "function") { + throw new WecomLoginAutoBindingError( + "wecom_profile_unavailable", + "WeCom member profile is unavailable", + ); + } + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) { + throw new WecomLoginAutoBindingError( + "wecom_profile_unavailable", + "WeCom member profile is unavailable", + ); + } + + const returnedUserid = requiredString(payload.userid ?? payload.UserId, 128); + if (!returnedUserid || returnedUserid !== userid) { + throw new WecomLoginAutoBindingError( + "wecom_identity_mismatch", + "WeCom member identity does not match OAuth identity", + ); + } + const status = Number(payload.status ?? payload.enable ?? 0); + if (status !== 1) { + throw new WecomLoginAutoBindingError( + "wecom_member_inactive", + "WeCom member is inactive", + ); + } + const email = + normalizeEmail(payload.biz_mail) || normalizeEmail(payload.email); + return { externalUserId: userid, email: email || null }; +} + +async function resolveUniqueEmployee(tx, identity) { + const matches = await tx.employee.findMany({ + where: { + OR: [ + { employee_no: identity.externalUserId }, + ...(identity.email + ? [{ email: { equals: identity.email, mode: "insensitive" } }] + : []), + ], + }, + select: { + id: true, + employee_no: true, + email: true, + status: true, + archived_at: true, + token_version: true, + }, + take: 3, + }); + const uniqueMatches = new Map( + matches.map((employee) => [employee.id, employee]), + ); + if (uniqueMatches.size === 0) { + throw new WecomLoginAutoBindingError( + "oa_employee_not_found", + "No OA employee matches the WeCom identity", + ); + } + if (uniqueMatches.size !== 1) { + throw new WecomLoginAutoBindingError( + "oa_employee_conflict", + "WeCom identity matches multiple OA employees", + ); + } + + const employee = [...uniqueMatches.values()][0]; + if (employee.status !== "active" || employee.archived_at !== null) { + throw new WecomLoginAutoBindingError( + "oa_employee_inactive", + "Matched OA employee is inactive", + ); + } + const matchByUserid = employee.employee_no === identity.externalUserId; + const matchByEmail = Boolean( + identity.email && employee.email?.toLowerCase() === identity.email, + ); + if (!matchByUserid && !matchByEmail) { + throw new WecomLoginAutoBindingError( + "oa_employee_not_found", + "No OA employee matches the WeCom identity", + ); + } + return { + employee, + matchBy: + matchByUserid && matchByEmail + ? "userid_and_email" + : matchByUserid + ? "userid" + : "email", + }; +} + +async function autoBindExistingWecomEmployee({ + prisma, + request, + identity, + externalOpenUserId = null, +}) { + const externalUserId = requiredString(identity?.externalUserId, 128); + const email = normalizeEmail(identity?.email); + if (!externalUserId) { + throw new WecomLoginAutoBindingError( + "wecom_identity_invalid", + "WeCom login identity is invalid", + ); + } + + try { + return await prisma.$transaction(async (tx) => { + const { employee, matchBy } = await resolveUniqueEmployee(tx, { + externalUserId, + email, + }); + const [useridBinding, employeeBinding] = await Promise.all([ + tx.wecomIdentityBinding.findUnique({ + where: { external_user_id: externalUserId }, + select: { employee_id: true }, + }), + tx.wecomIdentityBinding.findUnique({ + where: { employee_id: employee.id }, + select: { external_user_id: true }, + }), + ]); + if (useridBinding && useridBinding.employee_id !== employee.id) { + throw new WecomLoginAutoBindingError( + "wecom_userid_conflict", + "WeCom userid is already bound to another OA employee", + ); + } + if ( + employeeBinding && + employeeBinding.external_user_id !== externalUserId + ) { + throw new WecomLoginAutoBindingError( + "oa_employee_binding_conflict", + "OA employee is already bound to another WeCom userid", + ); + } + + if (!useridBinding && !employeeBinding) { + await tx.wecomIdentityBinding.create({ + data: { + employee_id: employee.id, + external_user_id: externalUserId, + external_open_user_id: + requiredString(externalOpenUserId, 128) || null, + created_by: employee.id, + updated_by: employee.id, + }, + }); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: "AUTH_WECOM_AUTO_BOUND", + targetType: "employee", + targetId: employee.id, + extra: { matchBy, emailMatched: matchBy.includes("email") }, + }), + }); + } + + return { + id: employee.id, + status: employee.status, + token_version: employee.token_version, + autoBound: !useridBinding && !employeeBinding, + }; + }); + } catch (error) { + if (error instanceof WecomLoginAutoBindingError) throw error; + if (error?.code === "P2002") { + throw new WecomLoginAutoBindingError( + "binding_race_conflict", + "WeCom binding changed concurrently", + ); + } + throw error; + } +} + +module.exports = { + WecomLoginAutoBindingError, + autoBindExistingWecomEmployee, + fetchWecomLoginIdentity, +}; diff --git a/server/tests/unit/routes/auth.login.test.js b/server/tests/unit/routes/auth.login.test.js index f05288b5..5c312267 100644 --- a/server/tests/unit/routes/auth.login.test.js +++ b/server/tests/unit/routes/auth.login.test.js @@ -370,7 +370,7 @@ describe("auth login route", () => { }); }); - it("auto-binds pending wecom identity after password login", async () => { + it("does not use password login to bind a pending wecom identity", async () => { const passwordHash = await hashPassword("AdminPassword1234"); const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash); const pendingBindToken = signPayload({ @@ -395,38 +395,18 @@ describe("auth login route", () => { }); expect(response.statusCode).toBe(200); - expect(wecomFindFirst).toHaveBeenCalledWith({ - where: { - external_user_id: "wecom-user-1", - NOT: { employee_id: EMPLOYEE_ID }, - }, - select: { employee_id: true }, - }); - expect(wecomUpsert).toHaveBeenCalledWith({ - where: { employee_id: EMPLOYEE_ID }, - update: { - external_user_id: "wecom-user-1", - external_open_user_id: "openid-1", - updated_by: EMPLOYEE_ID, - }, - create: { - employee_id: EMPLOYEE_ID, - external_user_id: "wecom-user-1", - external_open_user_id: "openid-1", - created_by: EMPLOYEE_ID, - updated_by: EMPLOYEE_ID, - }, - }); + expect(wecomFindFirst).not.toHaveBeenCalled(); + expect(wecomUpsert).not.toHaveBeenCalled(); expect(response.json().data).toEqual( expect.objectContaining({ accessToken: "access-token", mustChangePassword: false, - wecomBindingStatus: "bound", + wecomBindingStatus: null, }), ); expect(response.headers["set-cookie"]).toEqual( expect.arrayContaining([ - expect.stringContaining("wecom_login_bind="), + expect.stringMatching(/wecom_login_bind=;/), ]), ); diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index 6fc756e2..e992e23f 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -418,7 +418,7 @@ describe("wecom auth routes R27", () => { await app.close(); }); - it("redirects unbound users back to login with wecom hint", async () => { + it("fails closed without a password-binding cookie when the WeCom profile is unavailable", async () => { mockWecomFetch({ userId: "wecom-unbound" }); const prisma = buildPrisma({ wecomIdentityBinding: { @@ -439,13 +439,15 @@ describe("wecom auth routes R27", () => { }); expect(response.statusCode).toBe(302); - expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); - expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind="); + expect(response.headers.location).toBe( + "https://oa.hyinda.com/login?redirect=%2Fportal&wecom=account_mapping_required", + ); + expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/); await app.close(); }); - it("accepts lowercase userid and redirects unbound users back to login", async () => { + it("accepts lowercase userid and fails closed when no directory profile is available", async () => { mockWecomFetchWithPayload({ errcode: 0, errmsg: "ok", @@ -470,8 +472,10 @@ describe("wecom auth routes R27", () => { }); expect(response.statusCode).toBe(302); - expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); - expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_login_bind="); + expect(response.headers.location).toBe( + "https://oa.hyinda.com/login?redirect=%2Fportal&wecom=account_mapping_required", + ); + expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/); expect(prisma.wecomIdentityBinding.findUnique).toHaveBeenCalledWith({ where: { external_user_id: "wecom-unbound-lowercase" }, select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } }, @@ -480,6 +484,93 @@ describe("wecom auth routes R27", () => { await app.close(); }); + it("auto-binds a unique active OA employee by enterprise email and logs in without a password", 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-auto-user", OpenId: "open-auto-user" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errcode: 0, + userid: "wecom-auto-user", + status: 1, + biz_mail: "member@example.test", + }), + }); + const authSession = { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + create: vi.fn().mockResolvedValue({ id: "session-1" }), + }; + const prisma = buildPrisma({ + authSession, + employee: { + findMany: vi.fn().mockResolvedValue([{ + id: EMPLOYEE_ID, + employee_no: "E0001", + email: "member@example.test", + status: "active", + archived_at: null, + token_version: 0, + }]), + }, + wecomIdentityBinding: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), + create: vi.fn().mockResolvedValue({ id: "binding-auto" }), + }, + }); + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + const app = await buildApp({ prisma }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-auto-bind", + returnTo: "/mobile", + mode: "login", + deviceFingerprint: "auto-bind-device", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("https://oa.hyinda.com/mobile"); + expect(prisma.wecomIdentityBinding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + employee_id: EMPLOYEE_ID, + external_user_id: "wecom-auto-user", + }), + }); + expect(prisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "AUTH_WECOM_AUTO_BOUND", + extra: { matchBy: "email", emailMatched: true }, + }), + }); + expect(prisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "AUTH_WECOM_LOGIN_SUCCESS", + extra: { bindingSource: "auto" }, + }), + }); + expect(authSession.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "auto-bind-device" }), + }); + expect(String(response.headers["set-cookie"] ?? "")).not.toMatch(/wecom_login_bind=[^;]/); + + await app.close(); + }); + it("sets session hint cookie after successful wecom login callback", async () => { mockWecomFetch({ userId: "wecom-bound-user" }); const authSession = { diff --git a/server/tests/unit/scripts/reset-active-admin-password.test.js b/server/tests/unit/scripts/reset-active-admin-password.test.js new file mode 100644 index 00000000..c43cc062 --- /dev/null +++ b/server/tests/unit/scripts/reset-active-admin-password.test.js @@ -0,0 +1,201 @@ +const { + applyReset, + diagnoseWecomBinding, + preflight, + verifyCompletedPasswordChange, + verifyState, +} = require("../../../src/scripts/reset-active-admin-password"); + +function createPrismaFixture({ adminCount = 1 } = {}) { + const admins = Array.from({ length: adminCount }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + token_version: 7, + })); + const tx = { + employee: { + findUnique: vi.fn().mockResolvedValue({ + status: "active", + archived_at: null, + token_version: 7, + roles: [{ role_code: "system_admin" }], + }), + update: vi + .fn() + .mockResolvedValue({ must_change_password: true, token_version: 8 }), + }, + authSession: { updateMany: vi.fn().mockResolvedValue({ count: 2 }) }, + passwordResetCode: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + emailLoginCode: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + return { + tx, + prisma: { + employee: { + findMany: vi.fn().mockResolvedValue(admins), + findUnique: vi.fn().mockResolvedValue({ + must_change_password: true, + login_failed_count: 0, + login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_locked_until: null, + token_version: 8, + }), + }, + authSession: { count: vi.fn().mockResolvedValue(0) }, + passwordResetCode: { count: vi.fn().mockResolvedValue(0) }, + emailLoginCode: { count: vi.fn().mockResolvedValue(0) }, + auditLog: { count: vi.fn().mockResolvedValue(1) }, + wecomIdentityBinding: { + findUnique: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(0), + }, + wecomDirectorySyncRun: { findFirst: vi.fn().mockResolvedValue(null) }, + wecomHrMasterSyncItem: { count: vi.fn().mockResolvedValue(0) }, + $transaction: vi.fn(async (callback) => callback(tx)), + }, + }; +} + +describe("active administrator break-glass reset", () => { + it("blocks preflight unless the active protected administrator is unique", async () => { + const none = createPrismaFixture({ adminCount: 0 }); + const multiple = createPrismaFixture({ adminCount: 2 }); + + await expect(preflight(none.prisma)).resolves.toEqual({ + status: "BLOCKED", + activeAdminMatches: 0, + exactTarget: false, + }); + await expect(preflight(multiple.prisma)).resolves.toEqual({ + status: "BLOCKED", + activeAdminMatches: 2, + exactTarget: false, + }); + }); + + it("resets only the unique active administrator and revokes authentication state", async () => { + const { prisma, tx } = createPrismaFixture(); + + const result = await applyReset(prisma, "UniqueBreakGlass9Qz!"); + + expect(result).toEqual({ + status: "PASS", + activeAdminMatches: 1, + passwordUpdated: true, + mustChangePassword: true, + tokenVersionIncremented: true, + locksCleared: true, + revokedSessions: 2, + invalidatedPasswordResetCodes: 1, + consumedEmailLoginCodes: 3, + auditCreated: true, + }); + expect(tx.employee.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "00000000-0000-4000-8000-000000000001" }, + data: expect.objectContaining({ + must_change_password: true, + login_failed_count: 0, + email_login_code_failed_count: 0, + password_reset_failed_count: 0, + token_version: { increment: 1 }, + }), + }), + ); + expect(tx.authSession.updateMany).toHaveBeenCalledTimes(1); + expect(tx.passwordResetCode.updateMany).toHaveBeenCalledTimes(1); + expect(tx.emailLoginCode.updateMany).toHaveBeenCalledTimes(1); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "SYSTEM_ADMIN_BREAK_GLASS_PASSWORD_RESET", + extra: { credentialRecorded: false }, + }), + }); + }); + + it("verifies the post-reset state without returning an account identifier", async () => { + const { prisma } = createPrismaFixture(); + + await expect(verifyState(prisma)).resolves.toEqual({ + status: "PASS", + activeAdminMatches: 1, + exactTarget: true, + mustChangePassword: true, + locksCleared: true, + activeSessions: 0, + activePasswordResetCodes: 0, + activeEmailLoginCodes: 0, + auditPresent: true, + }); + }); + + it("verifies that the administrator completed the forced password change", async () => { + const { prisma } = createPrismaFixture(); + prisma.employee.findUnique.mockResolvedValue({ + must_change_password: false, + login_failed_count: 0, + login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_locked_until: null, + }); + prisma.authSession.count.mockResolvedValue(1); + prisma.auditLog.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1); + + await expect(verifyCompletedPasswordChange(prisma)).resolves.toEqual({ + status: "PASS", + activeAdminMatches: 1, + exactTarget: true, + mustChangePassword: false, + locksCleared: true, + activeSessions: 1, + passwordChangeAuditPresent: true, + breakGlassAuditPresent: true, + }); + }); + + it("classifies whether a recent password login happened inside the WeCom webview", async () => { + const { prisma } = createPrismaFixture(); + prisma.auditLog.findMany = vi.fn().mockResolvedValue([ + { action: "AUTH_LOGIN_SUCCESS", user_agent: "wxwork MicroMessenger" }, + { action: "AUTH_LOGIN_SUCCESS", user_agent: "Desktop Browser" }, + ]); + + await expect(diagnoseWecomBinding(prisma)).resolves.toEqual({ + status: "PASS", + activeAdminMatches: 1, + exactTarget: true, + targetBound: false, + directoryUserIdPresent: false, + latestDirectorySyncSuccessful: false, + directoryUserIdAlreadyBoundElsewhere: false, + linkedSuccessfulHrSyncPresent: false, + recentPasswordLogins: 2, + recentPasswordLoginsFromWecomWebView: 1, + recentFailedPasswordLogins: 0, + recentFailedPasswordLoginsFromWecomWebView: 0, + recentWecomBindAudits: 0, + recentWecomLoginAudits: 0, + }); + }); + + it("fails closed when the selected administrator changes before the transaction", async () => { + const { prisma, tx } = createPrismaFixture(); + tx.employee.findUnique.mockResolvedValue({ + status: "active", + archived_at: null, + token_version: 8, + roles: [{ role_code: "system_admin" }], + }); + + await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow( + "active_admin_target_changed", + ); + expect(tx.employee.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/wecomLoginAutoBinding.service.test.js b/server/tests/unit/wecomLoginAutoBinding.service.test.js new file mode 100644 index 00000000..68752852 --- /dev/null +++ b/server/tests/unit/wecomLoginAutoBinding.service.test.js @@ -0,0 +1,273 @@ +const { + WecomLoginAutoBindingError, + autoBindExistingWecomEmployee, + fetchWecomLoginIdentity, +} = require("../../src/services/integrations/wecomLoginAutoBinding"); + +const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; + +function buildRequest() { + return { + id: "request-1", + ip: "127.0.0.1", + headers: { "user-agent": "test-agent" }, + }; +} + +function buildPrisma({ + employees, + useridBinding = null, + employeeBinding = null, +} = {}) { + const tx = { + employee: { + findMany: vi.fn().mockResolvedValue( + employees ?? [ + { + id: EMPLOYEE_ID, + employee_no: "E0001", + email: "member@example.test", + status: "active", + archived_at: null, + token_version: 3, + }, + ], + ), + }, + wecomIdentityBinding: { + findUnique: vi + .fn() + .mockResolvedValueOnce(useridBinding) + .mockResolvedValueOnce(employeeBinding), + create: vi.fn().mockResolvedValue({ id: "binding-1" }), + }, + auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }) }, + }; + return { + tx, + prisma: { $transaction: vi.fn(async (callback) => callback(tx)) }, + }; +} + +describe("WeCom passwordless login auto binding", () => { + it("reads and normalizes the enterprise email for the OAuth userid", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + errcode: 0, + userid: "wecom-user-1", + status: 1, + biz_mail: "Member@Example.Test", + }), + }); + + await expect( + fetchWecomLoginIdentity({ + accessToken: "access-token", + externalUserId: "wecom-user-1", + fetchImpl, + }), + ).resolves.toEqual({ + externalUserId: "wecom-user-1", + email: "member@example.test", + }); + expect(String(fetchImpl.mock.calls[0][0])).toContain("/cgi-bin/user/get"); + }); + + it.each([ + [ + "inactive member", + { + errcode: 0, + userid: "wecom-user-1", + status: 2, + biz_mail: "member@example.test", + }, + "wecom_member_inactive", + ], + [ + "identity mismatch", + { + errcode: 0, + userid: "another-user", + status: 1, + biz_mail: "member@example.test", + }, + "wecom_identity_mismatch", + ], + ])("fails closed for %s", async (_label, payload, code) => { + const fetchImpl = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => payload }); + + await expect( + fetchWecomLoginIdentity({ + accessToken: "access-token", + externalUserId: "wecom-user-1", + fetchImpl, + }), + ).rejects.toMatchObject({ code }); + }); + + it("keeps userid matching available when the directory profile has no enterprise email", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ errcode: 0, userid: "wecom-user-1", status: 1 }), + }); + + await expect( + fetchWecomLoginIdentity({ + accessToken: "access-token", + externalUserId: "wecom-user-1", + fetchImpl, + }), + ).resolves.toEqual({ externalUserId: "wecom-user-1", email: null }); + }); + + it("binds a unique active OA employee by enterprise email and writes a redacted audit", async () => { + const { prisma, tx } = buildPrisma(); + + const result = await autoBindExistingWecomEmployee({ + prisma, + request: buildRequest(), + identity: { + externalUserId: "wecom-user-1", + email: "member@example.test", + }, + externalOpenUserId: "open-user-1", + }); + + expect(result).toEqual({ + id: EMPLOYEE_ID, + status: "active", + token_version: 3, + autoBound: true, + }); + expect(tx.employee.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: [ + { employee_no: "wecom-user-1" }, + { + email: { + equals: "member@example.test", + mode: "insensitive", + }, + }, + ], + }, + }), + ); + expect(tx.wecomIdentityBinding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + employee_id: EMPLOYEE_ID, + external_user_id: "wecom-user-1", + external_open_user_id: "open-user-1", + }), + }); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "AUTH_WECOM_AUTO_BOUND", + extra: { matchBy: "email", emailMatched: true }, + }), + }); + expect(JSON.stringify(tx.auditLog.create.mock.calls)).not.toContain( + "member@example.test", + ); + expect(JSON.stringify(tx.auditLog.create.mock.calls)).not.toContain( + "wecom-user-1", + ); + }); + + it("fails closed when userid and email point to different OA employees", async () => { + const { prisma, tx } = buildPrisma({ + employees: [ + { + id: EMPLOYEE_ID, + employee_no: "wecom-user-1", + email: "first@example.test", + status: "active", + archived_at: null, + token_version: 0, + }, + { + id: "22222222-2222-4222-8222-222222222222", + employee_no: "E0002", + email: "member@example.test", + status: "active", + archived_at: null, + token_version: 0, + }, + ], + }); + + await expect( + autoBindExistingWecomEmployee({ + prisma, + request: buildRequest(), + identity: { + externalUserId: "wecom-user-1", + email: "member@example.test", + }, + }), + ).rejects.toMatchObject({ code: "oa_employee_conflict" }); + expect(tx.wecomIdentityBinding.create).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + }); + + it("binds by exact userid when the OA employee number already carries the userid", async () => { + const { prisma, tx } = buildPrisma({ + employees: [ + { + id: EMPLOYEE_ID, + employee_no: "wecom-user-1", + email: "different@example.test", + status: "active", + archived_at: null, + token_version: 1, + }, + ], + }); + + await expect( + autoBindExistingWecomEmployee({ + prisma, + request: buildRequest(), + identity: { externalUserId: "wecom-user-1", email: null }, + }), + ).resolves.toEqual( + expect.objectContaining({ id: EMPLOYEE_ID, autoBound: true }), + ); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + extra: { matchBy: "userid", emailMatched: false }, + }), + }); + }); + + it("does not overwrite an OA employee binding for another userid", async () => { + const { prisma, tx } = buildPrisma({ + employeeBinding: { external_user_id: "another-wecom-user" }, + }); + + await expect( + autoBindExistingWecomEmployee({ + prisma, + request: buildRequest(), + identity: { + externalUserId: "wecom-user-1", + email: "member@example.test", + }, + }), + ).rejects.toMatchObject({ code: "oa_employee_binding_conflict" }); + expect(tx.wecomIdentityBinding.create).not.toHaveBeenCalled(); + }); + + it("exposes only stable error codes", () => { + const error = new WecomLoginAutoBindingError( + "oa_employee_not_found", + "internal detail", + ); + expect(error.code).toBe("oa_employee_not_found"); + }); +});