密码不是普通字符串
做用户系统时,最危险的错误之一是把密码当普通字符串存进数据库。数据库一旦泄漏,所有用户密码都会直接暴露。更糟的是,很多用户会在多个网站重复使用密码,你的泄漏会影响他们在其他服务上的账号。
正确做法不是“加密后保存”,而是保存密码哈希。哈希是单向的:注册时把密码变成哈希存储,登录时用用户输入的密码和已有哈希比较。即使数据库泄漏,攻击者拿到的也是哈希,不是明文密码。
密码哈希不能用普通快速哈希函数,比如 MD5、SHA1、SHA256。它们太快,攻击者可以高速尝试大量密码。更常见的是使用 bcrypt、scrypt、Argon2 这类专门为密码设计的算法。Go 标准库没有 bcrypt,但 Go 官方扩展库 golang.org/x/crypto/bcrypt 很常用。
安装依赖
引入:
import "golang.org/x/crypto/bcrypt"
添加依赖:
go get golang.org/x/crypto/bcrypt
在 2020 年的 Go Modules 项目里,这会更新 go.mod 和 go.sum。提交前要检查依赖变化。
生成密码哈希
func HashPassword(password string) (string, error) {
password = strings.TrimSpace(password)
if len(password) < 8 {
return "", fmt.Errorf("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
}
bcrypt.DefaultCost 是默认计算成本。成本越高,哈希越慢,攻击者越难暴力破解,但你的服务器登录和注册也更耗时。入门阶段使用默认值即可,生产系统可以根据机器性能做压测。
注册时:
hash, err := HashPassword(req.Password)
if err != nil {
return err
}
user := User{
Email: req.Email,
PasswordHash: hash,
}
数据库里存 PasswordHash,不要存 Password。
校验密码
func CheckPassword(hash string, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
登录:
user, err := store.FindUserByEmail(ctx, req.Email)
if err != nil {
return fmt.Errorf("invalid email or password")
}
if !CheckPassword(user.PasswordHash, req.Password) {
return fmt.Errorf("invalid email or password")
}
注意错误消息最好不要区分“邮箱不存在”和“密码错误”,否则攻击者可以用接口枚举邮箱是否注册。对用户展示统一的“邮箱或密码错误”更稳。
日志里也不要打印密码:
log.Printf("login failed email=%s", req.Email)
不要:
log.Printf("login failed password=%s", req.Password)
密码只应该在很短的请求处理过程中存在,不应该出现在日志、错误消息、事件队列或分析系统里。
不要自己设计密码算法
不要这样做:
sum := sha256.Sum256([]byte(password))
hash := hex.EncodeToString(sum[:])
这不是合适的密码哈希。SHA256 很快,适合校验数据完整性,不适合抵抗密码猜测。
也不要自己拼盐和多轮循环。密码学最忌讳自创方案。使用成熟算法和维护良好的库,才是负责任的做法。
密码重置也要按敏感流程处理
很多系统注册和登录做对了,却在“忘记密码”流程里犯错。密码重置链接本质上相当于临时登录凭证,必须足够随机、短时间有效、只能使用一次。
一个简化的重置记录可以这样建模:
type PasswordReset struct {
UserID int64
TokenHash string
ExpiresAt time.Time
Used bool
}
重置 token 本身应该发送给用户,但数据库里可以存 token 的哈希,而不是明文 token。这样数据库泄漏时,攻击者不能直接拿 token 重置密码。
func HashResetToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
这里使用 SHA256 是因为 token 已经是高强度随机值,不是用户记忆的弱密码。密码和随机 token 的处理方式不同:用户密码需要 bcrypt 这类慢哈希,随机 token 可以用普通哈希保存摘要。
校验时还要检查过期和是否已使用:
func (r PasswordReset) CanUse(now time.Time, token string) bool {
if r.Used {
return false
}
if now.After(r.ExpiresAt) {
return false
}
return r.TokenHash == HashResetToken(token)
}
这段代码只是演示核心规则。真实系统还要限制重置频率、记录审计日志、更新密码后让旧会话失效。安全流程不是一个函数能全部解决,但每一步都应该减少明文敏感信息的停留时间。
小结
密码不能明文保存,也不应该用普通快速哈希。Go 项目里可以使用 golang.org/x/crypto/bcrypt 生成密码哈希,并用 CompareHashAndPassword 校验。注册时保存哈希,登录时比较哈希,不在日志中打印密码,错误消息不要泄露账号是否存在。
安全代码的第一步不是复杂,而是避免明显错误。把密码当作敏感数据处理,是任何用户系统的底线。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。