密码重置链接
一次性、会过期的链接,以及一个不会泄露谁有账号的表单。
重置链接就是一个通过邮件送达的临时密码。它的所有要求都由此而来:不可猜测、有效期短、只能用一次,并且要像存密码一样存它。
发起重置
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 的纯文本部分就是还能用的兜底。 - 改完也要通知。重置成功后再发一封简短的「你的密码已修改」。这是真正的账号主人发现别人进来过的唯一途径。