我运营的网站注册流程要求用户证明自己拥有该邮箱地址,随后又要求了一次。
首先是六位数字验证码,通过邮件发送后由用户输入。仅在验证码通过后,账户才会被创建。创建账户后,认证服务又发送了一封包含链接的确认邮件。
两封邮件,同一地址,同一个问题被问了两次。
这看似周全,实际上却造成了数据分叉,并已将真实用户置于系统无法合理处理的境地。
无人设计的状态
有人完成了注册。他们收到了验证码,正确输入后账户即被创建。应用按设计立即让用户登录。
他们从未点击第二个链接。他们为什么要点击?他们刚从收件箱输入了验证码,现在正看着已登录的页面。在他们看来,任务已完成。
于是记录显示:账户存在、密码已设置、会话有效、已登录——而 email_confirmed_at 为空。
这一行记录并非描述一个未完成的注册,而是一个已完成的注册,加上了一个无人需要、也未被强制执行的残留标记。但一个名为邮箱已确认却显示否的标记,终究会被某些东西读取。任何支持工具、任何审计、任何基于确认状态的未来功能,都会查看该用户并得出他们从未证明其地址的结论。
他们确实证明了。六周前。另一个表中有一条标记为已使用的验证码,记录着他们的名字。
两条记录,一个事实
这个重构思路,我花了很长时间才理解。
我一直把第二次检查视为冗余——双重保险,无害无弊。但它不是冗余。它是同一事实被记录在第二个位置,而一个事实的两条记录终将出现不一致。
事实是“此人控制此地址”。我的 OTP 表通过一条标记为已使用的验证码知晓这一事实。认证服务则通过一个时间戳知晓。没有任何机制让两者保持同步,因为它们从未被设计为同一件事——一个是应用逻辑,另一个是默认开启的平台功能。
当用户满足其中一项而非另一项时,该事实就有了两个值。而当一个事实有多个值时,所有下游消费者都必须选择相信哪一个,通常还不知道自己正在做选择。
那个未强制执行任何操作的检查
存在一种场景:第二次检查是真正的检查,第一次只是方便之举。那至少是自洽的。
实际情况并非如此。链接并未强制执行任何操作。
用户在确认邮件可能被打开前就已登录,因为应用在创建账户后立即调用了登录功能。登录过程并未检查该标记。产品中没有任何部分检查该标记。无论是否点击链接,账户的工作方式完全相同。
因此,第二次检查无法阻止任何操作,无法限制任何功能,也无法以用户能察觉的方式失败。它唯一可观察到的效果,就是设置了一个与同一事实的另一条记录相矛盾的列。
这就是值得应用的测试:如果一个检查无法拒绝任何操作,它就不是检查。它只是一个带有意见的日志条目。
它为何开启
没人选择这样做。确认邮件是平台默认设置,而这是一个合理的默认——针对“创建账户,我们会通过邮件发送链接”的应用流程。那是它被设计的流程。
我们用自己的流程替换了这一流程,却从未关闭旧流程。原始代码甚至有注释说明:
// Create the user account with auto-confirm (since we verified email via OTP)
Enter fullscreen mode Exit fullscreen mode
意图已记录。它所引用的设置从未被更改。于是,这个注释描述了一个并不存在的系统,直接位于造成问题的代码行之上,存在了数月之久。
我对这条注释深表同情。这正是当你构建替代方案并合理地假设被替代物已让位时会发生的情况。
修复,以及已损坏的行
关闭平台确认只需一个设置。新注册用户现在只会收到一封邮件,包含一个验证码,并在登录时拥有一个对事实的单一连贯记录。
那个被困的用户需要单独处理,这比看起来更有趣。选项包括:
- 保持其标记为未验证,这是不真实的。
- 将标记设置为现在,这也是不真实的——它表示他们今天证明了所有权,而他们并没有。
- 将其设置为他们实际输入验证码的时刻。
我选择了第三种。他们的确认时间戳现在是账户创建前三十三秒,这看起来像数据错误,但实际上是最准确的陈述:他们证明了自己拥有该地址,然后账户才被创建。
我宁愿要一个看起来奇怪但真实的时间戳,也不愿要一个看起来整洁但不真实的时间戳。任何调查该行的人都会在另一个表中找到相应的已使用验证码,序列将变得合理。
下次我会在更早的时候询问
当你用自己的流程替换平台流程时,当你的流程正常工作时,工作并未完成。只有当平台版本被关闭时,工作才算完成。
在此之前,你没有一个已验证的邮箱地址。你有两条关于已验证邮箱地址的声明,由不同的系统维护,仅因巧合而一致——而这种巧合会一直持续,直到用户做出完全合理的行为,比如只读一次收件箱而非两次。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.