有人想把一个项目给没有账号的客户看。显而易见的方案是一个短验证码:六位数字,五分钟过期,访客输入就能进。我在一个笔记和文件共享工具里就是这么实现的,第一天就能用。问题不是它不能用。而是三个月后我不得不重做了两次。
三次迭代,而第三次本该是第一次。
短验证码能做什么,不能做什么
第一张表字面上就是这样:
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
两个不同的过期时间,这一点从一开始就做对了:code_expires_at 是没人使用时验证码多久失效(五分钟),session_duration_minutes 是进来之后这次访问能持续多久。这是两件不同的事,把它们混在一起是个经典错误。一个在 WhatsApp 里活八小时的验证码是个漏洞;一个五分钟就被切断的会话没法用。
有了这些,你就有了权限(read 或 read_write)、一张中间表来决定验证码能访问哪些项目,还有过期时间。看起来很完整。其实不是:验证码不标识任何人。它标识的是邀请,不是访客。
第一次尝试:传行的 id
第一次修正是不再在每个请求里发送六位验证码,而是发送这一行的 id。客户端保存一个 codeId,后端解析它,请求带上 Authorization: Bearer <codeId>。
这确实解决了一件真问题:六位验证码不再到处传输。它不会出现在访问日志或 URL 里。但它引入了更糟的东西。codeId 是一个自增整数。可以猜。可以枚举。任何拿到一个有效 id 的人都可以试试隔壁那个,如果那个会话还活着,就能进去。我把一个短的秘密换成了一个完全不是秘密的标识符。
而且它依然没解决最初的问题,也就是:如果两个人用同一个邀请,他们共享同一个 codeId。你没法只踢掉其中一个。你也没法知道是哪一个删了文件。
会话 token 解决了什么
第三版把纠缠了三个月的两件事分开了。六位验证码是一次兑换:只在连接端点用一次,此后再也不用。从那里产出的是一个会话 token:
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
响应不再返回 codeId,而是返回 sessionToken。从那之后整个前端都带着它:原本是 guestCodeId 的地方变成了 guestSessionToken,判断某人是不是访客的 !!guestCodeId 变成了 !!guestSessionToken。
真正改变的是:
- 吊销。 把某一行的
sessionToken置为 null,就只踢掉那一个会话。用验证码的时候你根本没有可指向的对象。 - 审计。 token 就在那一行里,所以每个动作都能归因到一次具体的兑换和一个具体的时间。
- 不可猜测。 它不再是连续整数。四个随机字节的十六进制无法枚举。
- 过期只有一个地方。
sessionExpiresAt和签发 token 写在同一次 update 里。不存在两个可能不同步的时钟。
审计发现了什么,而我没发现
几个月后我对代码做了一次审计。问题一大堆——一个完全没有认证 guard 的 PDF 导出路由是其中最糟的——但最让我思考的那条是我自己记在 TODO 里的:就算六位验证码被消耗掉了,它返回的是一个六位加四位的 token,而真正用来认证的是它。
问题就在这里。短验证码受到五分钟寿命和只能用一次这两点的保护。会话 token 两者都没有:它活好几个小时,而且每个请求都用。从验证码换成 token 去掉了一个攻击面,又打开了另一个,只有事后冷静下来再看才会意识到。四个字节对付随手枚举够用,对付一个有八小时和一个循环的人则不够。
另一个被发现的问题是教科书级别的:连接端点没有把任何东西包在事务里。从读到"验证码未被使用"到把它标记为已使用之间有一个窗口。两个带同一个验证码的并发请求会双双通过。
按 IP 封禁比看起来脆弱
失败次数计数器需要知道请求来自哪个 IP,而几乎所有人都在这里犯同样的错。在另一个项目里我做了按 IP 永久封禁,取的是 x-forwarded-for 的第一个值。这个头是一个列表 客户端, proxy1, proxy2:每个代理往右追加,但没人会清理已经写在里面的内容。取第一个,就是取客户端可以随便编造的东西。
我改成从末尾往前数,取决于前面有几层可信代理:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
前面有一层代理时,最后一个值是代理写的,客户端动不了。如果你取第一个,任何人都能通过发垃圾数据绕开自己的封禁,或者更糟,把别人封掉。
六位验证码不是难的部分。难的部分是接受:邀请和会话是两个不同的对象,把它们混在一起会让你无法吊销、无法审计,还留下一个连续的标识符在当凭证。
