密码重置链接

一次性、会过期的链接,以及一个不会泄露谁有账号的表单。

重置链接就是一个通过邮件送达的临时密码。它的所有要求都由此而来:不可猜测、有效期短、只能用一次,并且要像存密码一样存它。

发起重置

import crypto from "node:crypto"

export async function requestReset(rawEmail) {
  const email = rawEmail.trim().toLowerCase()
  const user = await db.users.findByEmail(email)

  if (user) {
    // 32 个随机字节:和六位验证码不同,这根本猜不到。
    const token = crypto.randomBytes(32).toString("base64url")
    const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

    // 只存哈希。这张表就算被人拿走也没用——链接需要的是 token,不是它的哈希。
    await db.passwordResets.upsert({
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 30 * 60 * 1000),
    })

    const link = "https://app.acme.com/reset?token=" + token
    await fetch("https://api.rovela.dev/emails", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + process.env.ROVELA_API_KEY,
        "Content-Type": "application/json",
        "Idempotency-Key": "reset-" + tokenHash.slice(0, 32),
      },
      body: JSON.stringify({
        from: "Acme <security@mail.acme.com>",
        to: email,
        subject: "重置你的 Acme 密码",
        html:
          "<p>有人请求重置这个邮箱对应账号的密码。</p>" +
          '<p><a href="' + link + '">设置新密码</a></p>' +
          "<p>链接只能使用一次,30 分钟内有效。" +
          "如果不是你本人操作,忽略这封邮件即可——你的密码没有变。</p>",
        text:
          "有人请求重置这个邮箱对应账号的密码。\n\n" +
          "设置新密码:" + link + "\n\n" +
          "链接只能使用一次,30 分钟内有效。",
      }),
    })
  }

  // 账号存在与否,回答都一样。
  return { message: "如果这个邮箱有账号,重置链接已经发出。" }
}
响应不能取决于这个地址有没有注册——文案不能、状态码不能,最好连耗时也不能。否则重置表单就是一个免费的「查谁是你客户」的工具。把发信放进后台任务,耗时差异也就一起消除了。

使用 token

export async function resetPassword(token, newPassword) {
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

  // 删除和读取一步完成:就算两个请求同时带着它到达,token 也只会被用掉一次。
  const row = await db.query(
    `DELETE FROM password_resets
     WHERE token_hash = $1 AND expires_at > NOW()
     RETURNING user_id`,
    [tokenHash]
  )
  if (row.rowCount === 0) return false

  await db.users.setPassword(row.rows[0].user_id, newPassword)
  await db.sessions.revokeAllFor(row.rows[0].user_id)   // 所有设备都登出
  return true
}

这里用普通 SHA-256 就够了,和 六位验证码 不同:32 个随机字节无法从哈希暴力还原,所以没有什么需要额外密钥去保护的。

和邮件相关的细节

  • 点击追踪会改写链接。如果发信域名开启了追踪,重置链接会被包一层经过我们追踪服务的跳转。它照样能用,但安全类邮件可以考虑用一个关闭追踪的独立域名——token 应该直接到你手里。
  • 一定要带 text。有些企业邮件网关会去掉 HTML 里的链接,带完整 URL 的纯文本部分就是还能用的兜底。
  • 改完也要通知。重置成功后再发一封简短的「你的密码已修改」。这是真正的账号主人发现别人进来过的唯一途径。