Skip to content

批量导入用户:默认走 invite,temporary(临时密码)降级为不可投递行的兜底 #3236

Description

@os-zhuang

背景

审查 seed-loader 的「失败被静默吞掉却报告成功」类问题(#2805)时,顺带审计到 admin-import-users 的一个同类隐患:temporary 策略下 engine.update('sys_user', { must_change_password: true }) 失败时只打 warn、不 surface,账号仍以 created/success 返回,且临时密码照常下发——结果是一个带临时密码却未被强制轮换的账号静默存在。

单用户路由 admin-user-endpoints.tsstampMustChangePassword 已经把这条设计立场写成文档:闸是 fail-open、best-effort,失败不阻断操作,但要 surface 真实状态admin-import-users 只是没对齐这条立场(连状态都没 surface)——那部分作为 bug 单独修。

本 issue 记录的是更上一层的产品/架构建议:与其在最脆弱的 temporary 路径上继续加固,不如减少对它的依赖

问题定位:temporary 是最弱且负债最重的策略

导入已支持三种 policyadmin-import-users.ts):

策略机制是否分发长期共享秘密
temporary生成临时密码、返回一次、靠 must_change_password 强制轮换(经不安全渠道分发)
invite无凭证账号 + 一次性邀请链接(邮件/短信)→ 用户自设密码
none仅身份,凭证事后经重置流按需铸造

临时密码是 95+ bit 高熵,所以风险不是被爆破,而是:

  • 分发渠道暴露:临时密码必然经不安全渠道(Slack / 邮件 / 口头 / 便签)交到用户手里,整个安全模型全押在「用一次就被强制换掉」;
  • 一旦强制轮换的闸没落下(就是上面那个 bug),它就退化成经弱渠道分发、却永久有效的凭证;
  • 批量放大:一次 DB 抖动可能让一整段连续行盖闸失败,几十个账号同时留永久临时密码,现场只有 warn 日志、无人察觉。

invite/none(以及已挂载的 magicLink 插件)全程不分发任何长期共享秘密,因此根本不需要 must_change_password 这道会 fail-open 的事后补写闸——那道闸本质是 temporary 专属的负债。

建议

把导入默认策略从 temporary 挪到 invitetemporary 只对不可投递的行兜底。

  • 有可投递的邮箱 / 手机号 → 走 invite(一次性链接,用户自设密码);
  • 仅对 placeholder 邮箱 / 纯手机号 / 明确不可投递的行 → 回退 temporary

这一步不改任何安全机制,只改策略选择逻辑,却能把临时密码的爆炸半径从「整批」收缩到「真正没渠道的少数行」,随之大幅缩小上述 bug 的影响面。

待确认(决定该不该直接落地)

部署环境是否普遍具备可用的邮件 / 短信投递?

  • 若普遍具备 → 本建议直接成立,temporary 应退居兜底;
  • 若很多是内网 / 无投递 → temporary 是刚需,方向应转为「把临时性下沉到凭证自过期(credential_expires_at),从架构上让它 fail-closed」,作为独立 issue 跟进。

关联

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions