找回密码看起来是一个普通功能:用户输入邮箱,系统发送链接,用户点开后设置新密码。真正实现时,最关键的是 token。它必须足够随机,不能长期有效,最好一次性使用,数据库里也不应该明文保存。
本文用 Go 写一个入门版密码重置 token 流程。示例不会覆盖完整用户系统,但会把安全边界讲清楚。
生成随机 token
使用 crypto/rand:
func NewResetToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
不要用 math/rand,不要用用户 ID 加时间戳,也不要用可预测的自增值。密码重置链接拿到就能修改密码,token 必须不可猜。
数据库里保存哈希
如果数据库里明文保存 token,一旦数据库泄漏,攻击者可以直接使用未过期链接。更稳的做法是把 token 发给用户,数据库只保存哈希:
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
保存结构:
type ResetToken struct {
UserID int64
TokenHash string
ExpiresAt time.Time
UsedAt *time.Time
}
邮件里发送原始 token,数据库保存 HashToken(token)。校验时对用户提交的 token 再 hash,然后查库。
创建重置请求
func (s *Service) RequestPasswordReset(ctx context.Context, email string) error {
user, err := s.users.FindByEmail(ctx, email)
if err != nil {
// 对外不要暴露邮箱是否存在
return nil
}
token, err := NewResetToken()
if err != nil {
return err
}
record := ResetToken{
UserID: user.ID,
TokenHash: HashToken(token),
ExpiresAt: time.Now().Add(30 * time.Minute),
}
if err := s.tokens.Save(ctx, record); err != nil {
return err
}
link := "https://example.com/reset-password?token=" + url.QueryEscape(token)
return s.mailer.SendResetLink(ctx, user.Email, link)
}
注意:如果邮箱不存在,仍然返回成功。这是为了避免接口被用来枚举注册邮箱。日志里可以记录内部情况,但对用户的响应要统一。
校验并一次性使用
用户提交新密码时:
func (s *Service) ResetPassword(ctx context.Context, token string, newPassword string) error {
if len(newPassword) < 12 {
return errors.New("password too short")
}
hash := HashToken(token)
record, err := s.tokens.FindByHash(ctx, hash)
if err != nil {
return errors.New("invalid or expired token")
}
if record.UsedAt != nil || time.Now().After(record.ExpiresAt) {
return errors.New("invalid or expired token")
}
passwordHash, err := HashPassword(newPassword)
if err != nil {
return err
}
if err := s.users.UpdatePassword(ctx, record.UserID, passwordHash); err != nil {
return err
}
now := time.Now()
return s.tokens.MarkUsed(ctx, hash, now)
}
真实项目里,更新密码和标记 token 已使用最好在同一个事务里完成。否则密码更新成功但 token 没标记,可能被重复使用。
防止重复使用的事务
仓储层可以提供一个方法:
func (s *Store) UseResetToken(ctx context.Context, tokenHash string, fn func(userID int64) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var record ResetToken
err = tx.QueryRowContext(ctx, `
SELECT user_id, expires_at, used_at
FROM reset_tokens
WHERE token_hash = ?
`, tokenHash).Scan(&record.UserID, &record.ExpiresAt, &record.UsedAt)
if err != nil {
return err
}
if record.UsedAt != nil || time.Now().After(record.ExpiresAt) {
return errors.New("invalid token")
}
if err := fn(record.UserID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE reset_tokens SET used_at = ? WHERE token_hash = ?`, time.Now(), tokenHash); err != nil {
return err
}
return tx.Commit()
}
这个示例为了入门省略了一些锁和隔离级别细节,但思路是:校验和使用放在一个受控边界里。
日志不要打印 token
重置链接、验证码、session ID 都属于敏感信息。不要在日志里打印完整 token:
log.Printf("password reset requested user_id=%d", user.ID)
如果必须关联问题,可以打印 token hash 的前几位,但也要谨慎。日志系统通常被更多人访问,敏感信息一旦进入日志,很难彻底清除。
限制请求频率
找回密码接口还要防止滥用。攻击者可以不断提交同一个邮箱,让用户收到大量邮件;也可以批量提交邮箱探测系统行为。除了统一响应外,还应该按邮箱、IP 或账号做频率限制。
一个简单策略是记录最近发送时间:
func (s *Service) CanSendReset(ctx context.Context, email string) (bool, error) {
last, err := s.tokens.LastSentAt(ctx, email)
if err != nil {
return false, err
}
if time.Since(last) < time.Minute {
return false, nil
}
return true, nil
}
即使不能发送,也可以对外返回“如果邮箱存在,我们会发送邮件”。内部日志记录限流原因即可。安全流程的用户体验要克制,不能把太多判断暴露给调用方。
小结
密码重置 token 要用 crypto/rand 生成,数据库保存哈希,设置较短过期时间,使用后标记失效。请求重置时不要暴露邮箱是否存在,日志里不要打印 token,更新密码和标记使用最好在事务里完成。
找回密码不是简单发一封邮件。它是账号安全流程的一部分。把随机性、过期、一次性使用和敏感信息处理做好,才是可信的基础实现。
HTTPS 和链接安全
重置链接必须使用 HTTPS。HTTP 链接在传输过程中会被中间人截获,token 一旦泄露就能被恶意使用。如果还在用 HTTP,先升级 SSL,再谈密码重置。
链接中不要附加额外敏感信息。token 本身已经足够,不要把用户 ID、邮箱等也放进去:
// 不推荐:即使加了签名,也扩大了攻击面
link := fmt.Sprintf("https://example.com/reset?token=%s&uid=%d", token, user.ID)
token 已经足够定位用户,不需要冗余字段。
token 过期时间的权衡
30 分钟是常见的默认超时,但不同场景需要不同策略:
| 场景 | 建议过期时间 | 原因 |
|---|---|---|
| 普通用户 | 30-60 分钟 | 平衡安全和用户体验 |
| 高安全等级 | 5-15 分钟 | 银行等敏感系统 |
| 内部工具 | 2-24 小时 | 方便但需配合 MFA |
过期时间太短用户来不及操作,太长增加泄露风险。可以根据用户行为调整,比如首次点击后适当延长。
密码强度校验
重置密码时应该强制强度要求,和用户注册时保持一致:
func ValidateNewPassword(pw string) error {
if len(pw) < 12 {
return errors.New("密码至少需要 12 个字符")
}
var hasUpper, hasLower, hasDigit bool
for _, c := range pw {
switch {
case 'A' <= c && c <= 'Z':
hasUpper = true
case 'a' <= c && c <= 'z':
hasLower = true
case '0' <= c && c <= '9':
hasDigit = true
}
}
if !hasUpper || !hasLower || !hasDigit {
return errors.New("密码必须包含大小写字母和数字")
}
return nil
}
不要只在客户端校验,服务端必须再检一次。客户端校验提升体验,服务端校验保证安全。
不同 token 类型对比
| 类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 随机字符串 + hash | 简单,无状态 | 需要数据库查询 | 通用密码重置 |
| JWT | 自带过期和 claims | 无法提前撤销 | 短期临时访问 |
| 有状态 session | 可随时撤销 | 需要集中存储 | 高安全要求系统 |
| OTP(短信验证码) | 无需链接,即时 | 依赖短信服务商 | 移动端重置 |
本文介绍的"随机字符串 + hash"是最通用、最容易实现的方式。JWT 方式无法做"一次性使用"(除非额外维护黑名单),有状态 session 需要做集中清理,OTP 则依赖第三方。
审计日志
密码重置是安全敏感操作,应该记录审计日志:
type AuditLog struct {
Event string // "password_reset_requested" / "password_reset_completed"
UserID int64
IP string
UserAgent string
CreatedAt time.Time
}
审计日志不同于普通应用日志,它需要长期保留、不可篡改。生产环境应该把审计日志写到专门的存储,比如单独的数据库表或安全日志服务。
忘记密码后的完整流程检查
一个可信的密码重置系统需要覆盖以下检查点:
- 请求阶段:不暴露邮箱是否存在
- Token 生成:使用 crypto/rand,长度足够
- 存储阶段:保存 hash,不保存明文
- 发送阶段:使用 HTTPS 链接
- 校验阶段:验证 hash、检查过期、检查是否已使用
- 更新阶段:在事务中原子完成密码更新 + token 标记
- 收尾阶段:记录审计日志,通知用户密码已修改
漏掉其中任何一环,整个流程的安全性就会打折。
小结
密码重置 token 要用 crypto/rand 生成,数据库保存哈希,设置较短过期时间,使用后标记失效。请求重置时不要暴露邮箱是否存在,日志里不要打印 token,更新密码和标记使用最好在事务里完成。
找回密码不是简单发一封邮件。它是账号安全流程的一部分。把随机性、过期、一次性使用和敏感信息处理做好,才是可信的基础实现。
并发安全:同一 token 多次提交
高并发场景下,用户可能快速点击两次提交按钮。如果不做防护,两次请求可能同时通过校验,导致密码被更新两次。
更危险的是攻击者故意并发提交同一个有效 token。防范方式是在数据库层加唯一约束,或者在 token 使用前先尝试原子地更新状态。
func (s *Store) AtomicUseToken(ctx context.Context, tokenHash string) (int64, error) {
result, err := s.db.ExecContext(ctx, `
UPDATE reset_tokens
SET used_at = ?
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
`, time.Now(), tokenHash, time.Now())
if err != nil {
return 0, err
}
rows, err := result.RowsAffected()
if err != nil {
return 0, err
}
if rows == 0 {
return 0, errors.New("token already used or expired")
}
var userID int64
err = s.db.QueryRowContext(ctx,
"SELECT user_id FROM reset_tokens WHERE token_hash = ?", tokenHash).Scan(&userID)
return userID, err
}
用 UPDATE ... WHERE used_at IS NULL 把"检查"和"标记"合并成原子操作。数据库的行级锁保证并发下只有一个请求能成功。
邮件模板和交付
token 生成后要通过邮件发送给用户。邮件交付不是 100% 可靠的,需要考虑:
- 邮件进入垃圾箱:在重置页面提示用户如果没收到邮件检查垃圾箱
- 邮件服务商延迟:不要给用户承诺"立刻收到"
- 邮件地址错误:这种情况用户自然收不到,但对外仍然统一响应
邮件内容不要太复杂,就是一个明确的行动链接。加上过期时间说明和"如果不是你本人操作请忽略"的提示。
<p>你收到了这封邮件,是因为有人请求重置你在 example.com 的密码。</p>
<p><a href="{{.ResetLink}}">点击重置密码</a></p>
<p>此链接将在 {{.ExpiresIn}} 分钟后失效。</p>
<p>如果不是你本人操作,请忽略此邮件。</p>
旧密码处理
重置密码成功后,之前的所有会话应该失效。这通常意味着:
- 更新密码 hash
- 标记 token 已使用
- 删除或失效该用户的所有 session/refresh token
- 可选:强制所有设备重新登录
如果不做第 3 步,攻击者可能已经在用户重置前窃取了会话 cookie,重置后仍然能继续访问。
测试策略
密码重置涉及多个组件,测试要覆盖:
func TestRequestReset_NonExistentEmail(t *testing.T) {
err := svc.RequestPasswordReset(ctx, "notfound@example.com")
if err != nil {
t.Fatal(err)
}
}
func TestResetPassword_InvalidToken(t *testing.T) {
err := svc.ResetPassword(ctx, "invalid-token", "newpassword123")
if err == nil {
t.Fatal("expected error for invalid token")
}
}
func TestResetPassword_TooShort(t *testing.T) {
err := svc.ResetPassword(ctx, validToken, "123")
if err == nil {
t.Fatal("expected error for short password")
}
}
集成测试可以用内存数据库或 Docker 容器。单元测试则 mock 掉仓储层和邮件发送器。
常见陷阱与最佳实践
Token 哈希算法选择: SHA-256 是常用的单向哈希,如果你需要额外的安全性,可以考虑 bcrypt 或 Argon2。但注意 bcrypt 对长输入(超过 72 字节)会截断,所以对于 32 字节以上的随机 Token,直接 SHA-256 存储足够。Token 本身的不可预测性才是核心。
过期时间与时区: 保存和比较过期时间时,统一使用 UTC,避免服务器迁移时区导致 Token 突然失效或延长有效期:
record.ExpiresAt = time.Now().UTC().Add(30 * time.Minute)
不要在 URL 中暴露用户标识: 攻击者可以通过观察重置链接猜测用户 ID 的分配规律。只使用随机 Token 定位用户。
日志中的脱敏处理: 即使不打印完整 Token,也要注意间接泄露。比如错误消息里不要包含 Token 长度或其他可推断特征。
测试覆盖检查清单:
- 邮箱不存在时对外不报错
- Token 过期后无法使用
- Token 使用一次后失效
- 并发使用同一 Token 只有一个成功
- 重置成功后用户之前的 session 应该失效
小结
密码重置 token 要用 crypto/rand 生成,数据库保存哈希,设置较短过期时间,使用后标记失效。请求重置时不要暴露邮箱是否存在,日志里不要打印 token,更新密码和标记使用最好在事务里完成。找回密码的安全设计需要端到端审视,任何一个环节的疏忽都可能导致账号被接管。把随机性、过期、一次性使用和敏感信息处理做好,才是可信的基础实现。
Token 长度与熵的计算
crypto/rand 生成 32 字节,base64 URL 编码后长度大约是 43 个字符。我们来看看安全性:
- 32 字节 = 256 位
- 可能的组合数 = 2^256
- 即使攻击者每秒尝试 10 亿次,也需要远超宇宙年龄的时间才能穷举
对密码重置场景,32 字节已经过剩。缩短到 16 字节(128 位)编码后大约 22 个字符,安全性仍然足够:
func NewShortResetToken() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
URL 里 token 太长影响美观和某些邮件客户端的链接截断处理。16-24 字节是平衡安全性和用户体验的常见选择。
常见攻击与防御
| 攻击类型 | 描述 | 防御措施 |
|---|---|---|
| Token 猜测 | 暴力枚举 token | 足够长的随机数 + -hash 存储 |
| Token 截获 | 中间人截获 HTTP 流量 | 强制 HTTPS |
| 枚举邮箱 | 通过响应差异判断邮箱是否存在 | 统一响应,不区分存在与否 |
| 重放攻击 | 截获有效请求重复发送 | 一次性 token + 原子更新 |
| 暴力破解密码 | 用弱密码字典尝试 | 密码强度要求 + 服务端校验 |
| 邮件拦截 | 攻击者控制用户邮箱 | 建议用户启用 MFA,无法完全防御 |
| 社工钓鱼 | 伪造重置邮件骗取 token | 反钓鱼邮件头 + 用户教育 |
跨站点请求伪造 (CSRF) 防护
密码重置页面本身如果是 POST 表单,也要防 CSRF。不过重置流程通常是通过专用链接进入,天然带有 token,可以把这个 token 同时作为 CSRF token:
func resetFormHandler(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", http.StatusBadRequest)
return
}
// 渲染表单时把 token 写进隐藏字段
data := struct{ Token string }{Token: token}
renderTemplate(w, "reset_form.html", data)
}
用户提交表单时同时提交 URL 里的 token 和表单里的 token,两者一致即可。这比另外维护一个 CSRF session 更简洁。
密码重置的替代方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 邮件链接 + token | 简单通用,用户体验好 | 依赖邮箱安全 |
| 短信验证码 | 即时,不依赖邮件 | 依赖运营商,有成本 |
| 安全问题 | 无外部依赖 | 用户常忘记答案 |
| 生物识别 | 安全性高 | 需要设备支持 |
| 管理员重置 | 完全可控 | 用户体验差,运维成本高 |
对大多数 Web 应用,邮件链接是最实用的默认方案。可以把它和"通知已登录设备"组合使用,在安全性和体验之间取得平衡。
安全加固扩展
对安全要求更高的系统,可以在基础流程上增加以下措施:
- IP 绑定:token 和请求时 IP 关联,换 IP 后 token 失效。这能防止 token 被盗后在其他地方使用。
- 设备指纹:记录用户设备信息,异常设备访问时要求额外验证。
- 通知机制:密码重置成功或失败时,发送通知到用户绑定的手机或备用邮箱。
- 历史 token 清理:定时任务清理过期的 reset_tokens 记录,防止表无限增长。
这些措施不是入门必需,但了解它们能帮助你设计可扩展的安全架构。
常见问题(FAQ)
Q: 密码重置 token 应该多长?
A: 32 字节以上的熵足够安全。Base64 URL 编码后大约 43 个字符,完全无法被暴力破解。
Q: 为什么要用 RawURLEncoding 而不是 StdEncoding?
A: RawURLEncoding 不使用填充 =,且 URL 安全。这使得 token 可以直接放在 URL 查询参数中而不需要额外编码。
Q: 如何处理用户多次请求重置?
A: 推荐两种策略:1)保留所有未过期的 token,任意一个都有效;2)每个用户只保留最新的 token,之前的全部失效。策略1更灵活但维护成本高,策略2更安全更简洁。
Q: 邮件发送失败怎么办?
A: 记录失败的邮件地址到重试队列。不要直接返回失败给用户(除非用户输入明显的格式错误)。真实的邮件系统有延迟、退信、灰名单等情况,需要异步重试机制。
Q: 密码重置和修改密码有什么区别?
A: 重置密码是当用户忘记密码时由系统生成的流程,依赖 token 和外部联系渠道(邮件/短信)。修改密码是用户记得当前密码时主动更改,需要验证旧密码。两者不应该使用同一段代码。
Q: 数据库中是否应该清理过期的 token?
A: 生产环境应该定期清理。可以写一个后台任务每天删除已使用和已过期的 token,避免表无限增长。清理策略:已使用 7 天后删除,过期 30 天后删除。
Q: 移动端和 Web 端重置流程有何不同?
A: Web 端可以直接在浏览器中打开链接。移动端如果是 App,链接应该 scheme 到 App 的自定义协议(如 myapp://reset?token=xxx),而不是网页。移动端还需要处理 deep link 的回调。
最佳实践
- Token 只能使用一次:使用后立即标记为已使用,不要给用户第二次机会
- 过期时间不要太长:30 分钟是最常见选择,银行等系统可缩短到 5 分钟
- 不要在 URL 中附加额外信息:token 本身已足够定位用户
- 日志脱敏:永远不要记录完整 token,即使为了排查问题
- 强制密码强度:重置时的新密码必须通过和注册时相同的强度检查
- 并发安全:使用数据库的原子更新防止并发重放攻击
- 所有环节 HTTPS:不安全的传输层让一切加密努力功亏一篑
- 审计日志:记录 IP、时间、用户代理,审计安全事件
常见坑与避坑指南
- 不要信任邮件地址:攻击者可能伪造来源邮箱,重置流程不应对邮件来源做任何信任假设
- 不要泄露用户是否存在:邮箱不存在时也要返回统一的"已发送邮件"提示
- 不要复用刷新 token 机制:密码重置和 session 刷新是不同的两件事
- 不要忘记清理旧会话:重置成功后必须让所有设备重新登录
- 不要使用数据库自增ID生成token:这是可预测的,泄露一个就能推断其他
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 实现完整的密码重置流程,包括发送邮件、校验 token、更新密码
- 编写单元测试覆盖正常流程和边界情况(过期、已使用、无效 token)
- 对比 JWT 方案和本文方案的优缺点
- 了解 OWASP 密码重置安全指南
参考资源
- Go 官方网站:https://go.dev/
- crypto/rand 文档:https://pkg.go.dev/crypto/rand
- OWASP Forgot Password Best Practices
- Go 密码处理最佳实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。