密码不是普通字符串
做用户系统时,最危险的错误之一是把密码当普通字符串存进数据库。一旦数据库泄漏,所有用户密码直接暴露。更糟的是,很多用户会在多个网站重复使用同一个密码,你的安全事件可能波及他们在其他服务上的账号。
正确做法不是加密后保存,而是保存密码哈希。哈希是单向的:注册时把用户输入的密码变成哈希值存储,登录时用用户输入的密码重新计算哈希并与存储值比较。即使数据库泄漏,攻击者拿到的只是哈希值,无法直接得知原始密码。
密码哈希不能用普通的快速哈希函数,比如 MD5、SHA1、SHA256。这些函数设计目标是计算速度越快越好,而密码哈希恰恰相反:它应该足够慢,让暴力破解不可行。 bcrypt、scrypt、Argon2 这类专门为密码设计的算法才是正确选择。Go 生态中,golang.org/x/crypto/bcrypt 是最常用的密码哈希库,Argon2 和 scrypt 同样在 x/crypto 扩展库中提供。
四种密码哈希算法对比
| 算法 | 主要特点 | 推荐场景 | 时间成本可调 |
|---|---|---|---|
| bcrypt | 基于 Blowfish 密码,最成熟 | 通用 Web 应用 | 通过 cost 参数 |
| scrypt | 内存困难型,抗 GPU/ASIC | 加密货币钱包 | 通过 N/r/p 参数 |
| Argon2 | 密码哈希竞赛冠军,现代首选 | 新系统首选 | 通过 time/memory/parallelism |
| pbkdf2 | NIST 推荐,简单广泛 | 需要标准合规 | 通过迭代次数 |
对于 2024 年的新项目,Argon2 是密码哈希算法委员会推荐的首选。bcrypt 仍然完全可用,生态成熟稳定。scrypt 适合需要额外内存困难性的场景。pbkdf2 主要在需要 NIST/FIPS 合规时使用。
bcrypt 实战详解
安装依赖
go get golang.org/x/crypto/bcrypt
生成密码哈希
package auth
import (
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
password = strings.TrimSpace(password)
if len(password) < 8 {
return "", fmt.Errorf("password must be at least 8 characters")
}
if len(password) > 72 {
return "", fmt.Errorf("password exceeds maximum length")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
}
bcrypt.DefaultCost 当前值为 10,表示进行 2^10 = 1024 轮哈希计算。成本参数可以调整:数值越高,计算越慢,安全性越高,但服务器登录响应也越慢。bcrypt 的设计将成本参数直接编码在哈希输出中,因此即使后期调整默认值,旧哈希仍然可以正常校验。
校验密码
func CheckPassword(hash string, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
完整注册与登录流程:
type AuthService struct {
store UserStore
}
func (s *AuthService) Register(ctx context.Context, req RegisterRequest) error {
hash, err := HashPassword(req.Password)
if err != nil {
return fmt.Errorf("invalid password: %w", err)
}
user := User{
Email: req.Email,
PasswordHash: hash,
CreatedAt: time.Now(),
}
return s.store.Create(ctx, user)
}
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (string, error) {
user, err := s.store.FindByEmail(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")
}
return s.createSession(ctx, user)
}
关键安全实践:错误消息不要区分邮箱不存在和密码错误,否则攻击者可以通过接口枚举已注册邮箱。
Argon2 实战详解
Argon2 是 2015 年密码哈希竞赛(Password Hashing Competition)的冠军,在 CPU 和内存资源消耗上都有良好的可配置性。Go 的 x/crypto/argon2 包提供了三种变体:Argon2d(优化抵抗 GPU 破解)、Argon2i(优化抵抗侧信道攻击)、Argon2id(混合推荐)。
Argon2id 注册实现
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
type Argon2Params struct {
Memory uint32
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
var DefaultParams = Argon2Params{
Memory: 64 * 1024, // 64 MB
Iterations: 3,
Parallelism: 4,
SaltLength: 16,
KeyLength: 32,
}
func HashPasswordArgon2(password string) (string, error) {
password = strings.TrimSpace(password)
if len(password) < 8 {
return "", fmt.Errorf("password must be at least 8 characters")
}
salt := make([]byte, DefaultParams.SaltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt,
DefaultParams.Iterations,
DefaultParams.Memory,
DefaultParams.Parallelism,
DefaultParams.KeyLength)
// 编码为 modular crypt format
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
DefaultParams.Memory,
DefaultParams.Iterations,
DefaultParams.Parallelism,
b64Salt,
b64Hash)
return encodedHash, nil
}
Argon2id 校验实现
func CheckPasswordArgon2(hash string, password string) (bool, error) {
parts := strings.Split(hash, "$")
if len(parts) != 6 {
return false, fmt.Errorf("invalid hash format")
}
var version int
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return false, err
}
var memory uint32
var iterations uint32
var parallelism uint8
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism)
if err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
computedHash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(expectedHash)))
if subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 {
return true, nil
}
return false, nil
}
scrypt 和 pbkdf2 的使用
scrypt
import "golang.org/x/crypto/scrypt"
func HashPasswordScrypt(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
if err != nil {
return "", err
}
// 存储 salt 和 hash
return fmt.Sprintf("%s:%s",
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(hash)), nil
}
pbkdf2
import (
"golang.org/x/crypto/pbkdf2"
"crypto/sha256"
)
func HashPasswordPBKDF2(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := pbkdf2.Key([]byte(password), salt, 100000, 32, sha256.New)
return fmt.Sprintf("%s:%s",
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(hash)), nil
}
盐值(Salt)原理与生成
盐值是每次哈希时随机生成的数据,与密码混合后再计算哈希。它的核心作用是防御彩虹表攻击。
没有盐值的情况下,相同密码的哈希值完全相同。攻击者可以预先计算海量密码的哈希值(彩虹表),然后用哈希值直接反查。有了盐值后,即使两个用户使用完全相同的密码,它们的哈希值也不同。
Go 中生成盐值的标准做法:
func generateSalt(length int) ([]byte, error) {
salt := make([]byte, length)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
return salt, nil
}
注意:盐值不需要保密,通常与哈希值一起存储。但每个密码应该有独立的随机盐值。
Pepper 值的应用
Pepper 是存储在代码或配置中的全局密钥,与密码一起参与哈希计算。与盐值不同:
- 盐值:每个用户不同,与哈希一起存储
- Pepper:全系统统一,不存储在数据库中
Pepper 的作用是:即使数据库泄漏,没有 pepper 的攻击者也无法验证密码。
var pepper = os.Getenv("PASSWORD_PEPPER")
func HashPasswordWithPepper(password string) (string, error) {
peppered := password + pepper
return HashPassword(peppered)
}
func CheckPasswordWithPepper(hash string, password string) bool {
peppered := password + pepper
return CheckPassword(hash, peppered)
}
Pepper 丢失(如配置变更未同步)会导致所有密码无法验证。因此 pepper 应该与备份策略一起考虑。
时序攻击(Timing Attack)防范
时序攻击是密码验证中的一个微妙漏洞。如果校验函数在发现字符不匹配时提前返回,攻击者可以通过测量响应时间差异来逐字节推断密码。
bcrypt 的 CompareHashAndPassword 内部已经实现了常数时间比较。但如果你手动比较哈希值,务必使用 crypto/subtle 包:
import "crypto/subtle"
// 正确:常数时间比较
if subtle.ConstantTimeCompare(computedHash, storedHash) == 1 {
// 匹配
}
// 错误:非常数时间,存在时序攻击风险
if string(computedHash) == string(storedHash) {
// 匹配
}
对于早期返回的情况,可以强制固定响应时间:
func CheckPasswordWithDelay(hash string, password string) bool {
start := time.Now()
result := CheckPassword(hash, password)
// 保证至少 100ms 的响应时间
elapsed := time.Since(start)
if elapsed < 100*time.Millisecond {
time.Sleep(100*time.Millisecond - elapsed)
}
return result
}
密码策略与会话管理
密码复杂度策略
func ValidatePassword(password string) error {
if len(password) < 12 {
return fmt.Errorf("password must be at least 12 characters")
}
if len(password) > 128 {
return fmt.Errorf("password is too long")
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, c := range password {
switch {
case c >= 'A' && c <= 'Z':
hasUpper = true
case c >= 'a' && c <= 'z':
hasLower = true
case c >= '0' && c <= '9':
hasDigit = true
default:
hasSpecial = true
}
}
score := 0
if hasUpper { score++ }
if hasLower { score++ }
if hasDigit { score++ }
if hasSpecial { score++ }
if score < 3 {
return fmt.Errorf("password must contain at least 3 of: uppercase, lowercase, digits, special characters")
}
return nil
}
会话管理最佳实践
type Session struct {
ID string
UserID int64
CreatedAt time.Time
ExpiresAt time.Time
IP string
UserAgent string
}
func (s *AuthService) CreateSession(ctx context.Context, user User) (string, error) {
token := generateSecureToken(32)
session := Session{
ID: token,
UserID: user.ID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
IP: getClientIP(ctx),
UserAgent: getUserAgent(ctx),
}
if err := s.store.SaveSession(ctx, session); err != nil {
return "", err
}
return token, nil
}
func generateSecureToken(length int) string {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(b)
}
密码重置的安全流程:
type PasswordReset struct {
UserID int64
TokenHash string
ExpiresAt time.Time
Used bool
}
func (s *AuthService) CreatePasswordReset(ctx context.Context, userID int64) (string, error) {
token := generateSecureToken(32)
tokenHash := sha256.Sum256([]byte(token))
reset := PasswordReset{
UserID: userID,
TokenHash: hex.EncodeToString(tokenHash[:]),
ExpiresAt: time.Now().Add(1 * time.Hour),
Used: false,
}
if err := s.store.SavePasswordReset(ctx, reset); err != nil {
return "", err
}
return token, nil
}
OAuth2/OIDC 替代方案
自建密码系统始终存在风险。对于面向用户的应用,考虑使用 OAuth2/OIDC 委托认证:
func oauth2Callback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
token, err := oauthConfig.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "exchange failed", http.StatusBadRequest)
return
}
client := oauthConfig.Client(r.Context(), token)
resp, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
if err != nil {
http.Error(w, "get userinfo failed", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var userInfo struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
http.Error(w, "decode failed", http.StatusInternalServerError)
return
}
// 查找或创建用户,创建会话
}
使用 OAuth2/OIDC 的好处包括:
- 不存储用户密码
- 利用身份提供商的安全基础设施
- 支持多因素认证(MFA)
- 简化密码重置流程
日志与错误处理
密码永远不应该出现在日志中:
// 正确:只记录安全信息
log.Printf("login failed email=%s ip=%s", req.Email, clientIP)
// 错误:将密码写入日志
log.Printf("login failed password=%s", req.Password)
密码只应该在请求处理的极短时间内存在于内存中,不应进入:
- 日志系统
- 错误消息(返回给客户端)
- 事件队列
- 分析系统
- 内存 dump
不要自己设计密码算法
以下做法绝对不可取:
// 危险:快速哈希不适合密码
sum := sha256.Sum256([]byte(password))
hash := hex.EncodeToString(sum[:])
// 危险:自创新方案
hash := sha256(password + salt + "mysecret" + strconv.Itoa(iterations))
密码学的黄金法则是:不要自己发明密码算法,使用经过充分审查和维护的成熟库。
小结
密码处理是每个用户系统的安全底线。使用 bcrypt 或 Argon2 进行密码哈希,为每个密码生成独立的随机盐值,考虑使用 pepper 增加额外安全层,使用常数时间比较防止时序攻击,实施合理的密码复杂度策略和会话管理。
安全代码的第一步不是复杂,而是避免明显错误。把密码当作最敏感的数据处理,使用成熟的库而不是自创方案,不在日志中暴露密码,统一错误消息防止信息泄漏。这些基础实践做好了,你的用户系统的安全基础就建立起来了。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。