02架构5 min

为什么我在不需要登录的应用里也要求登录

没有身份就没有配额,没有支出上限,也没有办法切断滥用者。注册带来的摩擦,比起没有它的代价,是便宜的。

作者 Salva Castells

封面图: Draceane, CC BY-SA 4.0.
本文目录

我某个项目里的每一次语义搜索都在花钱:一次 embedding、一次 reranking 调用、一次文本合成。每个请求几分钱。乘上一个跑循环的机器人,就不是几分钱了。第一个版本什么都不要求注册,因为为了搜番剧还要注册账号听起来很荒谬。现在听起来还是荒谬。我照做不误。

理由不是营销,也不是邮件列表。而是没有身份就没有东西可以计数。

不识别就放行的代价

我搭过一个 fake-door 验证用的架子,同时跑五个落地页,在动手做任何东西之前先测兴趣。唯一需要保护的是后台面板,所以我加了登录失败多次就封 IP。够了,我当时想。

不够。客户端 IP 来自 x-forwarded-for,而这个头是一个 客户端, 代理1, 代理2… 的列表,每个代理往右边追加,但没人清理已经写在里面的东西。取第一个值,就是取客户端想写什么就是什么。任何人发 x-forwarded-for: 1.2.3.4 就能绕开自己的封禁,顺便还能写上别人的 IP 把别人封掉。

我的修法是数跳数,而不是信左边:

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];

前面有一个代理时,真实客户端是最后一个值:那是代理写的,客户端碰不到。它能工作。但看看我刚做了什么:我引入了一个依赖部署网络拓扑的环境变量,只为了能近似地识别某个人。如果我明天在前面加一个 CDN,这个数字就变了,封禁系统会悄无声息地弄错。它不会报错:它只是弄错。

为什么 IP 当不了身份

除了在错误的代理后面可以被伪造,IP 作为一个键还有一些无法修补的毛病。同一间办公室的四个人共用一个 IP。一个人走出楼门,手机的 IP 就变了。移动网络在巨大的地址块上做 NAT:封一个运营商的 IP 就是封掉一整个街区。

你想从标识符那里得到的,是同一个人稳定、不同的人不同。IP 一条都不满足。user_id 在构造上就两条都满足。

只有知道谁是谁才能做的事

在那个搜索里,我最后做了三个层级,而三个层级都有有限的上限:

visitor:    { semanticSearchLimit: 10,  historyLimit: 0 }
registered: { semanticSearchLimit: 30,  historyLimit: 10 }
premium:    { semanticSearchLimit: 200, historyLimit: null }

我在那个类型上留的注释就是全部的判断依据:永远有限;没有任何层级豁免于搜索配额。premium 拿到的是最大的数字,不是无限。historyLimit 倒是允许 null 表示"无限"。搜索的那个不行,因为每一次搜索都是一张账单。

访客仍然存在,用一个 cookie 给他一个匿名的键。但续期窗口因人而异:注册用户按 UTC 的自然月续期,premium 按他开通的周年日续期。这就要求存一个 premiumAnchorAt,而要存任何东西就得有一行属于某个人的记录。没有账号,就没有可锚定的周年日。

按请求计费的应用的情况

当每个动作都在花真钱时,配额就不再是防御,而是记账。而记账会以很无聊的方式出错。

在那个搜索里,两次一模一样的请求被计了两次。解决办法是一次预留,哈希的是请求的内容而不是它的文本:归一化后的 query、limit、explicit 标志和排序过的过滤条件。那个类型上的注释说明了为什么顺序重要——同一组条件换个顺序选出来,还是同一次搜索——以及为什么过滤条件要包含进去:同样的文本换一个题材就是另一个请求,把它们合并会返回错误的结果。这个预留返回 reserved、duplicate、in_progress 或 exhausted。四个状态都存在,因为四个都发生过。

在一个转录 API 里,按字符计费一开始是转录文本加上处理后的文本除以十。等到量出真实成本,那一行变成了这样:

transcriptionText.length + Math.floor(processedText.length * 0.25)

从 /10 到 * 0.25:贵了两倍半。价格错了 2.5 倍,而好几个月里我一直在自己贴这个差价却不知道,因为我没有能让我察觉到的度量。

在一个图像生成的 SaaS 里,bug 是反过来的:每日额度存在,但前置检查只看 subCredits。有每日额度的用户账上有额度却收到 402。一个 dailyCredits + subCredits 就修好了。这些全都是用户那一行上的列。没有那一行,就没有 bug 可修,因为根本没有东西可计数。

当身份也不够用时

有账号不等于身份有效。在一个用 API key 的服务里,key 那一行会活得比所有者的账号状态更久:下线流程里清掉了计费记录,却没人碰 key 的 isActive。key 仍然能通过认证。修法是在 authenticateApiKey 内部重新校验所有者仍然有计费记录,而不是相信那个标志位:

if (!ownerBilling) {
  return { authenticated: false, error: "API key owner is no longer active" };
}

它放在那里而不是放在每个 endpoint 里,恰恰是为了让任何 endpoint 都不必记得这件事。

什么时候我确实允许免注册进入

当这个动作不花钱也不积累状态时:看一个条目、浏览一个目录、加载一个落地页。另外留一个配额很短的访客层级,让人可以先试再决定。注册前给十次搜索是诚实的摩擦;零次是一张敞开的账单。

问题不是登录烦不烦人。它烦人。问题是当有人越线时,你打算在 WHERE 里写什么。