Go 密码处理入门:为什么不能明文保存密码

本文讲解 Go 中密码哈希的基本做法,说明为什么不能明文保存密码,如何使用 bcrypt 生成和校验密码哈希。

密码不是普通字符串

做用户系统时,最危险的错误之一是把密码当普通字符串存进数据库。数据库一旦泄漏,所有用户密码都会直接暴露。更糟的是,很多用户会在多个网站重复使用密码,你的泄漏会影响他们在其他服务上的账号。

正确做法不是“加密后保存”,而是保存密码哈希。哈希是单向的:注册时把密码变成哈希存储,登录时用用户输入的密码和已有哈希比较。即使数据库泄漏,攻击者拿到的也是哈希,不是明文密码。

密码哈希不能用普通快速哈希函数,比如 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.modgo.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 校验。注册时保存哈希,登录时比较哈希,不在日志中打印密码,错误消息不要泄露账号是否存在。

安全代码的第一步不是复杂,而是避免明显错误。把密码当作敏感数据处理,是任何用户系统的底线。

继续阅读

探索更多技术文章

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

全部文章 返回首页