认证安全

Odoo 邮箱验证码二次验证为什么不是“发封邮件输入 6 位码”:totp_mail、小时级 code 与重认证链路讲透

结合 auth_totp_mail 与 auth_timeout 源码,讲清 Odoo 邮箱验证码如何用 login_date 派生密钥、小时窗口验证、发送/校验双限流,以及在会话重认证中如何和 password / TOTP 协作。

Odoo 开发 框架
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

看到“邮箱验证码二次验证”,很多人会把它想得很简单:

发一封邮件,用户把 6 位数填回去。

但 Odoo 的 auth_totp_mail 不是一个普通验证码模块。 它做的是一套完整的邮件版 TOTP / 重认证机制,并且要和 auth_timeout 的会话重验逻辑协作。

这意味着它要同时解决四件事:

  1. 验证码怎么生成,才不会被轻易复用?
  2. 一小时内的 code 如何和会话状态绑定?
  3. 邮件发送和验证码校验如何限流?
  4. 在 session 需要重认证时,怎样和 password / TOTP / passkey 共存?

1. 邮箱验证码不是“随机数”,而是和用户状态绑定的 HMAC + HOTP

关键方法在 addons/auth_totp_mail/models/res_users.py

  • _get_totp_mail_key()
  • _get_totp_mail_code()
  • _check_credentials()

这里的思路不是简单 random.randint(),而是先构造一个和用户状态绑定的 key:

  • 用户 id
  • login
  • login_date

这些值通过 HMAC 组合在一起。

这带来一个很重要的效果:

只要用户的登录态发生变化,验证码的底座就会变化。

也就是说,这不是“发出去的一串码可以长期存在”的设计。 它天然带着会话边界和身份状态。

然后 _get_totp_mail_code() 用这个 key 生成一个 HOTP,并把 counter 设为“当前时间按小时切分的序号”:

  • counter = int(datetime.timestamp(now) / 3600)
  • expiration = timedelta(seconds=3600)

所以它本质上是小时级 OTP,不是按 30 秒轮转的传统 TOTP。

这也是为什么用户看到的验证码会有“1 小时有效”的语义。


2. 发送邮件和校验验证码,都有自己的限流

Odoo 这里做了两层限流:

  • send_email
  • code_check

TOTP_RATE_LIMITS 里,两者默认都是 5 / 3600

这意味着:

  • 你不能无限触发发码邮件
  • 你也不能无限试验证码

限流日志存在 auth.totp.rate.limit.log,并且是按:

  • user_id
  • limit_type
  • 时间窗口

来统计的。

成功验证后,系统会清掉对应限流日志:

  • 验证成功后 purge code_check
  • 发送成功验证后还会 purge send_email

这说明 Odoo 的意图不是“让限流成为永久惩罚”,而是把它当作防爆破和防刷邮件的防线。


3. /web/login/totp 不是普通表单,而是“先发码,再等人输入”的入口

addons/auth_totp_mail/controllers/home.py 里,web_totp() 会先调用父类逻辑。

只有当:

  • 响应是正常页面
  • 当前用户的 _mfa_type()totp_mail

才会继续执行发送邮件。

也就是说,邮件发码不是提前乱发,而是绑定在“用户刚进入二次验证页”这个动作上。

而且发送动作被包在 savepoint() 里:

  • 如果发邮件失败,只会把错误放回页面上下文
  • 不会把整个认证流程直接炸掉

这很符合 Odoo 的风格:

表单先打开,失败信息先展示,用户再决定重试。

这比“接口一失败就抛异常退出”更适合真实登录场景。


4. auth_timeout 把它放进了“重认证”流程,而不是独立登录流程

真正让这个模块完整起来的,是 auth_timeout/models/ir_http.py

它的 _must_check_identity() 会判断两种超时:

  • lock_timeout:会话总寿命到了,必须重新登录
  • lock_timeout_inactivity:用户闲置太久,需要重新确认身份

一旦需要重认证,_check_identity() 会根据当前可用的 auth methods 走流程。

注意这里它会把 totptotp_mail 统一当作可选 second factor 之一处理:

  • 先取 auth methods
  • 如果已有第一因素,就把同一种方法从候选里去掉
  • 再用 request.session["identity-check-1fa"] 避免 1FA/2FA 复用混乱

这说明 totp_mail 不是单独的“邮箱登录”,而是重认证链路中的一种 MFA 形式

如果用户先前已经用过 password,再来验证邮箱码,本质上是在完成“重新证明你还是你”的步骤。


5. totp_mail 和普通 TOTP 的差别,不只是“介质不同”

它和手机 TOTP 的差别至少有三层:

  1. 生成方式不同 - 手机 TOTP 更像稳定密钥 + 时间窗口 - totp_maillogin_date 也纳入 key

  2. 时间窗口不同 - 手机 TOTP 常见是 30 秒 - totp_mail 是小时窗口

  3. 用户体验不同 - 手机 TOTP 适合高频验证 - totp_mail 更适合“低频、重认证、没有认证器 App”的场景

所以它不是“弱化版 TOTP”,而是为邮件通道定制的 MFA 方案


6. 这个模块最容易被误解的点

最容易误解的地方有两个:

  • 第一,以为它只是“发一个一次性验证码邮件”
  • 第二,以为它是登录时的可选校验,和会话重认证无关

实际上,auth_totp_mail 是和 auth_timeout 深度耦合的。 它既能作为初次登录的 MFA,也能作为会话过期后的身份确认手段。

如果你在排查问题,建议按这个顺序看:

  1. 用户是否真的属于 totp_mail 这类 MFA
  2. 邮件是否发送成功
  3. 验证码是否还在 1 小时窗口内
  4. 发送/校验限流是否触发
  5. auth_timeout 是否正在要求你进入重认证流程

结论

Odoo 的邮箱验证码不是“发一封验证码邮件”这么简单。

它是一个把:

  • 用户状态
  • 时间窗口
  • 限流机制
  • 会话重认证

全部串起来的 MFA 方案。

也正因为这样,它看上去很轻,实际上边界一点都不轻。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。