找回密码看起来是一个普通功能:用户输入邮箱,系统发送链接,用户点开后设置新密码。真正实现时,最关键的是 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,更新密码和标记使用最好在事务里完成。
找回密码不是简单发一封邮件。它是账号安全流程的一部分。把随机性、过期、一次性使用和敏感信息处理做好,才是可信的基础实现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。