Go 密码重置 Token 入门:随机数、过期时间和一次性使用

找回密码看起来是一个普通功能:用户输入邮箱,系统发送链接,用户点开后设置新密码。真正实现时,最关键的是 token。它必须足够随机,不能长期有效,最好一次性使用,数据库里也不应该明文保存。本文用 Go 写一个入门版密码重置 token 流程。

找回密码看起来是一个普通功能:用户输入邮箱,系统发送链接,用户点开后设置新密码。真正实现时,最关键的是 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
}

审计日志不同于普通应用日志,它需要长期保留、不可篡改。生产环境应该把审计日志写到专门的存储,比如单独的数据库表或安全日志服务。

忘记密码后的完整流程检查

一个可信的密码重置系统需要覆盖以下检查点:

  1. 请求阶段:不暴露邮箱是否存在
  2. Token 生成:使用 crypto/rand,长度足够
  3. 存储阶段:保存 hash,不保存明文
  4. 发送阶段:使用 HTTPS 链接
  5. 校验阶段:验证 hash、检查过期、检查是否已使用
  6. 更新阶段:在事务中原子完成密码更新 + token 标记
  7. 收尾阶段:记录审计日志,通知用户密码已修改

漏掉其中任何一环,整个流程的安全性就会打折。

小结

密码重置 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% 可靠的,需要考虑:

  1. 邮件进入垃圾箱:在重置页面提示用户如果没收到邮件检查垃圾箱
  2. 邮件服务商延迟:不要给用户承诺"立刻收到"
  3. 邮件地址错误:这种情况用户自然收不到,但对外仍然统一响应

邮件内容不要太复杂,就是一个明确的行动链接。加上过期时间说明和"如果不是你本人操作请忽略"的提示。

<p>你收到了这封邮件,是因为有人请求重置你在 example.com 的密码。</p>
<p><a href="{{.ResetLink}}">点击重置密码</a></p>
<p>此链接将在 {{.ExpiresIn}} 分钟后失效。</p>
<p>如果不是你本人操作,请忽略此邮件。</p>

旧密码处理

重置密码成功后,之前的所有会话应该失效。这通常意味着:

  1. 更新密码 hash
  2. 标记 token 已使用
  3. 删除或失效该用户的所有 session/refresh token
  4. 可选:强制所有设备重新登录

如果不做第 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 长度或其他可推断特征。

测试覆盖检查清单:

  1. 邮箱不存在时对外不报错
  2. Token 过期后无法使用
  3. Token 使用一次后失效
  4. 并发使用同一 Token 只有一个成功
  5. 重置成功后用户之前的 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 应用,邮件链接是最实用的默认方案。可以把它和"通知已登录设备"组合使用,在安全性和体验之间取得平衡。

安全加固扩展

对安全要求更高的系统,可以在基础流程上增加以下措施:

  1. IP 绑定:token 和请求时 IP 关联,换 IP 后 token 失效。这能防止 token 被盗后在其他地方使用。
  2. 设备指纹:记录用户设备信息,异常设备访问时要求额外验证。
  3. 通知机制:密码重置成功或失败时,发送通知到用户绑定的手机或备用邮箱。
  4. 历史 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 的回调。

最佳实践

  1. Token 只能使用一次:使用后立即标记为已使用,不要给用户第二次机会
  2. 过期时间不要太长:30 分钟是最常见选择,银行等系统可缩短到 5 分钟
  3. 不要在 URL 中附加额外信息:token 本身已足够定位用户
  4. 日志脱敏:永远不要记录完整 token,即使为了排查问题
  5. 强制密码强度:重置时的新密码必须通过和注册时相同的强度检查
  6. 并发安全:使用数据库的原子更新防止并发重放攻击
  7. 所有环节 HTTPS:不安全的传输层让一切加密努力功亏一篑
  8. 审计日志:记录 IP、时间、用户代理,审计安全事件

常见坑与避坑指南

  1. 不要信任邮件地址:攻击者可能伪造来源邮箱,重置流程不应对邮件来源做任何信任假设
  2. 不要泄露用户是否存在:邮箱不存在时也要返回统一的"已发送邮件"提示
  3. 不要复用刷新 token 机制:密码重置和 session 刷新是不同的两件事
  4. 不要忘记清理旧会话:重置成功后必须让所有设备重新登录
  5. 不要使用数据库自增ID生成token:这是可预测的,泄露一个就能推断其他

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 实现完整的密码重置流程,包括发送邮件、校验 token、更新密码
  2. 编写单元测试覆盖正常流程和边界情况(过期、已使用、无效 token)
  3. 对比 JWT 方案和本文方案的优缺点
  4. 了解 OWASP 密码重置安全指南

参考资源

  • Go 官方网站:https://go.dev/
  • crypto/rand 文档:https://pkg.go.dev/crypto/rand
  • OWASP Forgot Password Best Practices
  • Go 密码处理最佳实践

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南