给已有系统加「首登强制改密」:一个跨切面开关的设计

给一套已经上线的系统加「首次登录必须改密码」,功能本身很小——但它是个跨切面需求:登录、鉴权守卫、管理员操作、初始化数据,每一处都得知道这件事。

这类需求最容易做法是「在守卫里判断一下」,然后很快发现:登录响应没告诉前端要不要跳转、管理员重置完口令用户进不去、初始化脚本种下去的管理员第一步就被拦住。

这次的做法是先把开关的语义定死,再逐处接线。记一下过程,以及容器里踩的两个坑。

一、先定开关的三态语义

功能的开关不该只有「开 / 关」,还要有「跟随环境策略」这一档。这次定的是三态:

FORCE_PASSWORD_CHANGE 行为
空(未设置) 跟随 strictSecurity():生产环境开、开发环境关
false / 0 / off / no 显式关闭
true / 1 / on / yes 显式开启

为什么要有”跟随”这一档:生产该强制,开发天天改密码没法干活。但不能把「开发环境」的判断散落到各处——统一由 strictSecurity() 提供,开关只负责”要不要覆盖它”。

还有一条边界要划清:这个开关和密码强度策略是两件事。改密要求密码至少 12 位、至少三类字符,那是 isStrongPassword() 的事,与”要不要强制改密”相互独立。混在一起改,以后想单独调强度就得动两个地方。

二、一个开关要接进五处

实现只加了一个函数:

1
forcePasswordChange(): boolean

但要让功能真的成立,它得出现在五个位置:

  1. 登录响应——告诉前端「这次登录后必须改密」,否则用户进不去也不知道为什么;
  2. /auth/profile 返回的 mustChangePassword 标记——前端路由守卫靠它跳转;
  3. 鉴权守卫(jwt-auth.guard.ts)——在标记为真时拦住除改密接口之外的所有请求。这一处是真正的强制点,前两处只是提示;
  4. 管理员创建用户 / 重置口令——管理员重置完,用户下次登录同样要走改密流程;
  5. 初始化数据(seed)——种下去的初始管理员要带上这个标记。

漏掉任何一处的效果:

  • 漏 1、2 → 用户进不去但不知道要改密;
  • 漏 3 → 前端提示了,但绕过前端直接调 API 就没事——等于没强制;
  • 漏 4 → 管理员重置口令变成”重置成明文已知密码”,安全目标落空;
  • 漏 5 → 初始账号成了唯一的例外。

跨切面功能的第一件事,是把”所有入口”列全,而不是先写代码。

三、默认值别靠环境推断

这次顺带统一了演示口令:仓库里原来存在两个自创的演示密码(源码一处、容器编排一处),谁也不知道哪个是真的。

处理方式是收敛到一处约定,并且在容器编排里显式写默认值:

1
FORCE_PASSWORD_CHANGE: ${FORCE_PASSWORD_CHANGE:-false}

测试容器默认关闭、生产按 strictSecurity() 走,不靠 NODE_ENV 去猜。因为容器编排里常常会硬写 NODE_ENV=production(为了让前端构建走生产模式),一旦如此,”生产环境就开”这条推断会在测试容器里意外生效——用户第一次登录就被拦,还以为是 bug。

四、容器里的两个坑

坑一:运行镜像只拷产物,ts-node 执行的脚本找不到源码。

容器启动即崩,日志是 TS2307: Cannot find module '../src/common/password',然后反复重启。

根因:prisma/seed.ts 是由 ts-node 直接执行的(不是编译后的 JS),里面 import 了 ../src/...。而运行阶段的镜像只 COPY 了 node_modules、dist、prisma、package.json——没有 src。

两种修法:给运行镜像补 COPY src 和 tsconfig.json(这次的做法,3 行),或者把 seed 一起编译进 dist(更干净,但改动大)。前者的代价是镜像变大,值得在注释里写明。

坑二:--ignore-scripts 会跳过 Prisma Client 生成。

装依赖时为了跳过各种安装期脚本用了 --ignore-scripts,结果 npm run build 报 Prisma Client 未生成。手动 prisma generate 就恢复。

这条不算坑本身,算是记账:跳过安装脚本之后,要记得补跑它跳过的生成步骤。

五、截图对不上源码时

有个很典型的现象:登录页截图里有一组账号,但仓库源码里怎么搜都搜不到。

一开始以为是自己在错误的分支,或者改了没保存。实际根因是——部署的是另一份源码。

处理方式是去查上游提交历史,发现那组账号本来就在上游某次提交里。在源码里搜不到的东西,先怀疑”你看到的不是这份代码”,而不是怀疑自己的搜索。

这类判断有个通用信号:截图里的布局和仓库里的组件对不上。布局不一样,说明跑的不是同一份代码,别在源码里继续挖。

六、把验证边界写清楚

这次的验证覆盖:口令模块单测 15 项、后端全量单测 269 项、npm run build 通过、compose YAML 语法通过、重建容器后 seed 正常。

没覆盖的也要写明:

  • 本机没有运行 MySQL/Redis,没做真实数据库的端到端(登录 → 提示改密 → 改密 → 再登录);
  • 前端没重建(node_modules 未装),只验证了后端;
  • 本机缺一个只在部署机才有的 .deb,无法本地构建镜像复跑,只能采用部署环境已实测通过的方案。

把”验证到哪一步、没验证什么、为什么”写进提交说明,比写一句”已测试”有用得多——尤其当改动落在认证链路上时。

总结

加一个跨切面的开关,值得记住四条:

  1. 开关要有”跟随环境”这一档,但环境判断只放在一个函数里,不要散落;
  2. 先把所有入口列全再动手——登录响应、守卫、管理员操作、初始化数据,缺一处功能就是半残;
  3. 默认值显式写,别靠 NODE_ENV 推断(容器里那个值经常是给前端构建用的);
  4. 验证边界要写清,特别是涉及认证的改动。

最后一句:「强制」的落点必须在服务端守卫上。前端提示做得再好,绕过前端就失效——那不叫强制,叫提醒。