Go 密码重置 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,更新密码和标记使用最好在事务里完成。

找回密码不是简单发一封邮件。它是账号安全流程的一部分。把随机性、过期、一次性使用和敏感信息处理做好,才是可信的基础实现。

继续阅读

探索更多技术文章

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

全部文章 返回首页