1. 项目概述与设计思路
GoChat这个项目做到用户系统这一环,我意识到一个之前一直想回避的问题:聊天功能代码再多,登录注册这里要是漏了,整体就是纸糊的。密码不能明文存、身份认证不能靠session硬塞、改了几行代码还不敢确定会不会弄坏原有逻辑,于是我把密码处理、JWT鉴权和功能单测三件事放在同一个迭代里一次做完。这篇文章就是那一次实现过程的完整复盘——从选型权衡、代码结构,到测试用例设计和实际踩过的坑,希望能给正在做Go后端或聊天类项目的朋友一点参考。
先说项目背景。GoChat是一个用Go写的Web聊天Demo,功能上包含用户注册登录、私聊消息、好友关系等,但聊天的前提是“知道你是谁”,所以认证模块不是锦上添花,而是所有业务请求的第一道闸门。我见过不少项目把登录注册做得很糙:密码用MD5糊弄一下、登录态靠前端存一个user_id、后端完全不验证身份,这种项目就算业务逻辑写得再漂亮,上线也是提心吊胆。
所以当时我把认证拆成三个独立层次来设计,每一层只干一件事,互不干扰:
- 密码存储层:负责把用户明文密码转成不可逆哈希,并在登录时校验;
- 令牌层:负责在密码校验通过后签发JWT,同时提供解析和验证能力;
- 中间件层:负责从HTTP请求里提取JWT、校验有效性,并把用户身份注入到后续处理逻辑。
这种拆分的好处非常直接:密码层不关心HTTP请求长什么样,令牌层不关心数据库里存了什么,后续写单测也能分层打,不会牵一发动全身。目录结构也按照这个思路组织,所有认证相关代码集中在internal/auth包下:
gochat/ ├── cmd/ │ └── server/ │ └── main.go ├── internal/ │ ├── auth/ │ │ ├── password.go │ │ ├── password_test.go │ │ ├── jwt.go │ │ ├── jwt_test.go │ │ └── middleware.go │ └── user/ │ ├── model.go │ └── repository.go └── go.mod技术栈我就选了三样:Go标准库的net/http做Web层、x/crypto/bcrypt做密码哈希、golang-jwt做JWT的生成与解析。没有引入重型的Web框架,原因是这个项目的认证逻辑本身不复杂,标准库完全够用,依赖越少,后面排查问题的时候越省心。下面我按层次逐个拆开讲,每个模块为什么这么做、代码怎么写、测试怎么覆盖,都会说清楚。
2. 密码处理:从明文到不可逆哈希
2.1 为什么选bcrypt而不是MD5或SHA-256
密码存储这件事,很多新手第一反应是用SHA-256哈希一下再存数据库,觉得“反正不是明文了”。这个想法最大的问题在于:SHA-256这类通用哈希函数的设计目标是“快”,而哈希速度快在密码存储场景里恰恰是致命的。
我们可以算一笔账。一台普通机器每秒可以计算几百万次SHA-256,攻击者拿到数据库里的哈希值后,完全可以对常见密码字典做暴力枚举,几亿条组合也就是几小时的事。更不用说MD5还有彩虹表,预计算好的哈希-明文对照表可以直接查,普通用户设置的密码(比如name123这种)基本一查一个准。
bcrypt的思路正好相反:它故意让哈希计算变慢,每次生成大概几十到一百毫秒。对正常用户来说,登录慢一百毫秒毫无感知;但对攻击者来说,同样的字典暴力枚举成本会扩大几万倍,从“几小时”变成“几十年”。这就是计算成本换安全性的典型例子。
bcrypt还有一个内置特性,就是自动加盐。盐是一段随机数据,和密码混合后一起哈希,这样即使两个用户设置了完全相同的密码,生成的哈希结果也完全不同。攻击者无法通过对比哈希值判断哪些用户用了相同密码,预计算的彩虹表也直接失效。使用bcrypt不需要自己管理盐,库内部会在生成时自动带上随机盐,这个细节后面代码里会看到。
选型时我也对比过Argon2id和scrypt,它们的安全参数确实更先进,但bcrypt在Go生态里成熟度最高、使用最简单,x/crypto/bcrypt这个官方扩展包提供了开箱即用的API,对GoChat这种体量的项目完全足够。
2.2 注册与登录的密码处理实现
密码处理我封装在internal/auth/password.go里,核心只有两个函数:注册时生成哈希、登录时校验密码。
package auth import ( "golang.org/x/crypto/bcrypt" ) // HashPassword 生成密码哈希,cost 使用默认值 10 func HashPassword(password string) (string, error) { if len(password) == 0 { return "", ErrEmptyPassword } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hash), nil } // CheckPassword 校验密码与哈希是否匹配 func CheckPassword(hashedPassword, password string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) return err == nil } var ErrEmptyPassword = errors.New("password cannot be empty")注册流程里,Handler收到用户提交的明文密码后,先调HashPassword生成哈希,再把用户名和哈希写入数据库。这里有个约定必须坚持:数据库里永远只存哈希,不存明文,也不存任何可以反推出明文的中间状态。
登录流程则是相反的:根据用户名取出存储的哈希,然后调CheckPassword做比对。这个函数内部会自动从哈希字符串中提取盐和成本因子,不需要我们手动做任何拼接或拆分,这是个容易被忽略但很省心的设计——你只需要把数据库里的哈希字符串原样传进去就行。
成本因子选默认的10,是因为这个数值在安全和性能之间比较均衡,单次哈希大约50到100毫秒。我见过有人为了“更安全”把cost调到14,结果登录接口的耗时直接飙到1秒以上,在高并发下数据库连接池都会被拖垮。实际上,如果担心计算力提升,bcrypt的成本因子是可以在验证时动态升级的,后续可以在用户登录成功后检测到哈希的cost低于预设值就自动重新哈希并更新存储,这个策略不复杂,但很多项目都没做。
2.3 密码处理的隐藏细节
密码处理看起来简单,实际上有几个坑值得专门拿出来说。
第一是错误提示必须模糊化。登录接口的返回信息不能区分“用户不存在”和“密码错误”,否则攻击者可以通过注册接口或登录接口的报错来枚举系统里哪些用户名已经被注册,这属于信息泄露。GoChat里所有登录失败统一返回“用户名或密码错误”,注册成功提示也刻意避开“用户名已被占用”这种过于精确的措辞。
第二是bcrypt的72字节截断问题。bcrypt内部只处理输入密码的前72字节,超过的部分会被静默丢弃。这意味着如果用户在注册时输入了一个100字节的密码,注册时bcrypt哈希用的是前72字节,登录时输入同一个完整密码,校验仍然会通过,因为比对时也只看前72字节。这个特性本身不算漏洞,但它会让用户误以为系统接受了完整密码,实际上后面28字节是无效的。我当时在处理超长密码时统一做了长度校验,限制密码最大长度为72字节,并在注册和登录两处同时校验,避免出现服务端和客户端规则不一致的情况。
第三是不要自己拼盐。很多人喜欢在bcrypt之外再拼一段固定盐,完全没有必要。bcrypt生成的哈希字符串本身已经包含了随机盐,额外拼接反而容易引入统一盐漏洞,而且会破坏与标准bcrypt格式的兼容性。
第四是CompareHashAndPassword比先HashPassword再比对更安全。如果你为了校验而把输入密码重新哈希一遍再字符串比对,会出现两个问题:一是每次生成的盐不一样,哈希结果永远不会相等;二是你自己实现的比较过程可能存在时序泄露。直接用官方提供的比对函数,由库内部处理盐提取和恒定时间比较,才是正确姿势。
注意:密码哈希是安全底线,凡是涉及密码的代码不要追求“自己造轮子”,直接用经过验证的成熟库,剩下的精力留给业务逻辑和测试。
3. JWT鉴权:无状态身份凭证
3.1 JWT的结构与HS256的工作原理
密码校验通过之后,需要一个机制让客户端在后续请求中证明“我是我”。GoChat选的是JWT,全称JSON Web Token。它本质上是一段自包含的令牌,服务器签发时用密钥签名,后续验证时只需检查签名是否有效、是否过期,不需要像Session那样在服务端保存状态。
JWT由三部分组成,用点号拼接,形如header.payload.signature,每一部分都是Base64Url编码。Header里声明令牌类型和签名算法,比如{"alg":"HS256","typ":"JWT"};Payload里放的就是Claims,承载业务数据,比如user_id、username、过期时间;Signature则是用密钥对前两部分做签名运算得到的结果。
这里必须澄清一个误区:JWT的Payload只是Base64编码,不是加密。任何人都可以解码看到里面的内容,所以敏感信息不能往Payload里放。签名的作用是保证内容不被篡改,而不是对内容保密。
GoChat用的签名算法是HS256,即HMAC-SHA256,使用同一个对称密钥同时做签名和验证。为什么不用RS256那种非对称算法?因为GoChat是单体服务,签发和验证都在同一个进程内,对称密钥最简单直接;如果是多个微服务之间需要独立验证,且不能共享密钥的场景,再考虑RS256。算法选择上还有一层安全隐患,就是必须显式校验令牌的签名算法,防止攻击者把alg改成none或者换算法蒙混过关,这部分会在中间件实现里重点处理。
3.2 自定义Claims与Token生成
golang-jwt这个库到了v5版本之后,API有了一些调整,旧的StandardClaims被废弃,统一改用RegisteredClaims。自定义业务数据就放在自己的结构体里,然后内嵌RegisteredClaims,这样既保留了标准字段,又能扩展业务字段。
GoChat的Claims定义和Token生成逻辑在internal/auth/jwt.go里:
package auth import ( "errors" "time" "github.com/golang-jwt/jwt/v5" ) type Claims struct { UserID int64 `json:"user_id"` Username string `json:"username"` jwt.RegisteredClaims } var jwtSecret []byte // InitJWTSecret 从配置初始化密钥,后续可改为环境变量注入 func InitJWTSecret(secret string) { jwtSecret = []byte(secret) } // GenerateToken 签发JWT,有效期默认2小时 func GenerateToken(userID int64, username string) (string, error) { claims := Claims{ UserID: userID, Username: username, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "gochat", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret) }Claims里我只放了user_id和username两个业务字段,没有把整个用户对象塞进去。原因是JWT在客户端是可见的,Payload里字段越多,泄露的信息面越大;而且每次请求都会携带这个令牌,过大的JWT还会占用更多传输带宽和解析时间。拿到user_id之后,业务层如果需要完整用户资料,可以按需查库。
有效期设置成了2小时。这属于短期令牌,对聊天项目来说够用,同时把令牌泄露的风险窗口控制在合理范围内。如果后续需要更长的登录态,可以在登录时再签一个长期RefreshToken,而不是直接把JWT的有效期拉到几天甚至几个月。
签发密钥jwtSecret在真正项目里不应该这样全局变量存着,我在开发期为了方便先写成变量,部署时改从环境变量读取,后端启动时通过InitJWTSecret注入。这个内容在第5章踩坑记录里会再展开。
3.3 鉴权中间件:从Header里把身份拿出来
令牌签发完之后,怎么在每个需要登录的请求里验证令牌,靠的是HTTP中间件。Go标准库的中间件就是一个包装Handler的函数,Chain起来即可。
GoChat的中间件逻辑在internal/auth/middleware.go中:
package auth import ( "context" "fmt" "net/http" "strings" "github.com/golang-jwt/jwt/v5" ) var ErrInvalidToken = errors.New("invalid token") // AuthMiddleware 验证 Authorization: Bearer <token> func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "missing token", http.StatusUnauthorized) return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { http.Error(w, "invalid authorization header", http.StatusUnauthorized) return } tokenString := parts[1] claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { // 关键:校验签名算法,防止算法混淆攻击 if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return jwtSecret, nil }) if err != nil || !token.Valid { http.Error(w, "invalid or expired token", http.StatusUnauthorized) return } // 注入用户身份到请求上下文 ctx := context.WithValue(r.Context(), "user_id", claims.UserID) ctx = context.WithValue(ctx, "username", claims.Username) next.ServeHTTP(w, r.WithContext(ctx)) }) }中间件处理的关键点有三个。
一是Header格式解析。JWT通常放在Authorization头里,值为Bearer <token>。这里不要图省事直接字符串截取,最好先Split空格再做前缀匹配,因为有些客户端会写出格式乱七八糟的Header,严格解析能提前拦截一批无效请求。
二是签名算法的校验。这段代码是安全关键中的关键。jwt.ParseWithClaims的第二个参数keyfunc里,我们必须检查token.Method的实际类型是不是我们预期的HMAC,如果不是就返回错误。这能防止两类攻击:一类是alg=none,攻击者把签名算法改成none并去掉签名;另一类是算法混淆,比如服务端用HS256对称密钥验签,攻击者却把alg改成RS256,试图用公钥当对称密钥来伪签。检查Method类型直接掐死了这条路。
三是Context传参。解析成功之后把user_id和username写进请求Context,后续handler只需要从Context取值就可以知道当前操作人是谁,不用再去解析一次JWT。这里有个细节,Go的Context查找key时要求key类型一致,用字符串“user_id”直接做key虽然能跑,但更好的做法是定义成私有类型常量,避免和第三方库的Context key冲突。我开发时为了快速实现先用了裸字符串,代码评审时被指出过一轮,建议正式项目直接用自定义类型。
4. 功能单测的落地
4.1 为什么认证模块必须配单测
不少项目写单测的意愿很低,但我一直觉得认证模块不写单测等于裸奔。原因是认证代码是安全边界,一旦回归,轻则用户全部登录不上,重则身份被伪造,后果都是事故级别的。而且密码哈希和JWT解析这两块逻辑和数据库解耦,非常适合单测——不需要起MySQL、不需要Mock网络请求,纯函数跑一遍,毫秒级出结果,这种低成本高收益的测试没有理由不写。
GoChat这一波测试的目标很简单:核心函数的每个分支都要覆盖到。密码处理至少覆盖“正确密码能通过校验”“错误密码不能通过”“相同明文两次哈希结果不同”,JWT至少覆盖“生成后可解析”“过期被拒绝”“篡改后签名失败”“错误签名算法被拒”。
测试设计时我坚持一个原则:测试要让测试自己说话。也就是测试失败时,从报错信息能直接看出是哪个场景挂的,而不是糊里糊涂报一个Expected true, got false。Go的t.Run子测试在这个场景下特别好用,每个场景是独立名字,跑挂了直接定位。
4.2 密码处理测试用例
密码测试写在password_test.go里,表驱动是Go社区的主流写法,一个表列出所有输入和期望,跑起来非常清晰:
package auth import ( "strings" "testing" ) func TestHashPasswordAndCheck(t *testing.T) { const plain = "admin123" hash, err := HashPassword(plain) if err != nil { t.Fatalf("hash password failed: %v", err) } if hash == plain { t.Fatal("hash should not equal plaintext") } if !CheckPassword(hash, plain) { t.Fatal("correct password should be verified") } } func TestCheckPassword_WrongPassword(t *testing.T) { hash, _ := HashPassword("admin123") if CheckPassword(hash, "wrongpass") { t.Fatal("wrong password should not be verified") } } func TestHashPassword_SamePlaintextDifferentSalt(t *testing.T) { hash1, _ := HashPassword("admin123") hash2, _ := HashPassword("admin123") if hash1 == hash2 { t.Fatal("two hashes of same plaintext should differ because of random salt") } } func TestHashPassword_EmptyPassword(t *testing.T) { if _, err := HashPassword(""); err == nil { t.Fatal("empty password should return error") } } func TestCheckPassword_InvalidHash(t *testing.T) { if CheckPassword("not-a-valid-hash", "admin123") { t.Fatal("invalid hash should return false") } }测试里我特意加了一条“相同明文两次哈希不同”的用例,这条最能验证bcrypt随机盐是真随机,而不是写死的。如果哪一天有人把随机盐去掉改成固定盐,这条测试会立刻变红,是个很好的防线。
空密码和非法哈希这类边界用例也别偷懒。宁可多用几行代码,也要把这些奇奇怪怪的输入挡在门外。实际开发中确实有人会把空字符串传进来,或者数据库里存了损坏的哈希,如果这种场景没有测试兜底,上线之后真实用户遇到问题排查起来会非常痛苦。
4.3 JWT与中间件测试用例
JWT测试稍微复杂一点,因为要构造过期Token、篡改后的Token以及错误算法场景。这里有一个技巧:过期场景不用真的等2小时,直接在构造Claims时把ExpiresAt设成过去的时间即可。
package auth import ( "testing" "time" "github.com/golang-jwt/jwt/v5" ) func TestGenerateAndParseToken(t *testing.T) { InitJWTSecret("test-secret") tokenStr, err := GenerateToken(1, "alice") if err != nil { t.Fatalf("generate token failed: %v", err) } claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { return jwtSecret, nil }) if err != nil { t.Fatalf("parse token failed: %v", err) } if !token.Valid { t.Fatal("token should be valid") } if claims.UserID != 1 || claims.Username != "alice" { t.Fatalf("unexpected claims: %+v", claims) } } func TestExpiredToken(t *testing.T) { InitJWTSecret("test-secret") claims := &Claims{ UserID: 1, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), }, } tokenStr, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret) _, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { return jwtSecret, nil }) if err == nil { t.Fatal("expired token should be rejected") } } func TestTamperedToken(t *testing.T) { InitJWTSecret("test-secret") tokenStr, _ := GenerateToken(1, "alice") // 修改最后一个字符前的有效载荷中的用户ID tampered := tokenStr[:20] + "9" + tokenStr[21:] _, err := jwt.ParseWithClaims(tampered, &Claims{}, func(token *jwt.Token) (interface{}, error) { return jwtSecret, nil }) if err == nil { t.Fatal("tampered token should be rejected") } } func TestWrongSigningMethod(t *testing.T) { InitJWTSecret("test-secret") // 用错误的算法签发,比如 ES256 tokenStr, _ := jwt.NewWithClaims(jwt.SigningMethodES256, Claims{ UserID: 1, }).SignedString([]byte("test")) _, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return jwtSecret, nil }) if err == nil { t.Fatal("token signed with wrong algorithm should be rejected") } }中间件测试在该mock场景下可以构造一个httptest.NewRequest带上不同的Authorization Header,然后断言返回状态码。三个核心场景必须覆盖:没有Header返回401、Header格式错误返回401、Token无效返回401。
单元测试写到这里,执行go test ./internal/auth/... -cover可以直接看行覆盖率。我在本地上跑的时候,密码处理模块覆盖率达到100%,JWT模块的核心解析链路也超过90%,对认证模块来说这个分数算比较健康了。覆盖率只是一个数字,关键在于这些测试真正守住了安全边界,后续重构的时候心里有底,改完代码跑一遍全绿,才敢往主干上合并。
5. 实战中踩过的坑与排查记录
5.1 bcrypt的72字节截断
这个坑是我在做注册长度校验时发现的。当时我们同组的A同学写了一个“超强密码”校验器,允许用户输入128字节的长密码,结果就触发了bcrypt只处理前72字节的边界。表面看一切正常,注册成功、登录成功,但用户其实只用了前72字节作为有效密码,后面56字节不管怎么改都不影响校验。
这种问题最危险的地方在于它不会报错,而是静默截断。如果用户在注册时输入了一个超长密码,之后把多余部分改了想用新密码登录,居然还能登录——因为他改的部分根本不在有效输入内。
解决办法分两种:一种是在入口处限制密码最大长度,比如直接限制为72字节,配合前端校验提示;另一种是先对明文做一次SHA-256摘要,再把摘要传给bcrypt,这样任意长度密码都变成固定32字节输入。第二种方案有个兼容性代价,就是无法和外部系统直接用标准bcrypt互验,但对GoChat这种纯内部系统来说完全够用。我最后选了第一种,原因是简单直接,不引入额外哈希层,出错概率最小。
5.2 JWT时间戳校验的坑
JWT过期校验有时候会莫名失败,而且不是必现,排查起来挺恶心。有一次集成测试里,签发Token和验证Token之间明明只隔了几秒,却偶尔报“token is expired”。后来定位到是多台测试机器上有微小的时钟偏差,签发端认为时间是12:00:00.500,验证端本地时间已经到了12:00:00.510,而Token恰好只在ExpiresAt前两秒签发,于是被判定过期。
JWT库本身没有内置时钟容差机制,需要在解析时手动加Leeway。golang-jwt的jwt.ParseWithClaims没有直接暴露Leeway参数,但可以通过ParseOption控制,比如jwt.WithLeeway(30 * time.Second)。这样解析时会给过期时间和签发时间各加30秒的容忍窗口,有效吸收机器时钟偏移。生产环境如果使用多台服务器签发和验证JWT,这个参数几乎必配。
后来我还发现一个问题:测试里如果用time.Now()动态生成Token来测“即将过期”场景,结果很不稳定,有时挂有时过。我的经验是测试代码里永远不要依赖真实时钟做精确的临界判断,要用确定的相对时间构造场景:过期就把ExpiresAt设为过去的绝对时间点,未过期就设为未来相对时间点,这样测试才是可重复的。
5.3 密钥硬编码与管理方式
项目早期我把JWT密钥直接写死在代码里,还随着代码库一起提交了,这是严重的安全隐患。密钥一旦泄露,攻击者可以自己签发任意用户身份的Token,整个认证体系形同虚设。发现这个问题的那天,我直接在内部群里喊停了开发,然后做了三件事:
第一,彻底删除仓库历史中出现的旧密钥,把Token重新签发,强制所有在线用户重新登录;第二,把密钥改成从环境变量读取,配置文件只保留占位符,部署时由运维注入;第三,写了一个启动脚本,如果检测到密钥未设置或为默认值,则直接拒绝启动,防止低配环境把带默认密钥的服务带上线。
密钥的生成也有讲究,不要自己拍脑袋敲键盘。可以用openssl rand -base64 32生成256位随机串,这个强度对HS256来说是标准配置。泄露之后的应急响应流程也是必修课,新密钥生效后,所有旧Token必须立刻失效,线上用户重登一次的成本远低于留着漏洞的风险。
5.4 错误提示的用户枚举问题
登录接口的返回信息必须认真设计。最开始GoChat的登录逻辑是:先从数据库查用户,查不到就返回“用户不存在”;查到再比对密码,不对就返回“密码错误”。上线两天,就有人在评论区反馈说注册页有风险,我一查日志,确实看到有人用不同用户名大量尝试,通过报错信息精准判断哪些账号已注册。
修复方式很简单:把登录失败统一改为“用户名或密码错误”。即使注册接口为了用户体验还保留“该用户名已被注册”的提示,登录环节也绝对不能暴露用户是否存在。注意这个原则不止适用于登录接口,所有涉及用户查询的接口都应该检查是否泄露了用户存在性信息。
5.5 测试隔离与运行速查
单测过程中我踩过几个影响体验的坑,一并整理成速查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 测试偶尔失败,报token expired | 解析端时钟偏移过大 | 解析时加WithLeeway(30s),测试用相对时间构造 |
| 测试跑得很慢,每个用例都卡几百毫秒 | bcrypt cost设置过高 | 测试环境用bcrypt.MinCost,生产再用10 |
| 用例之间互相影响,Token解析混乱 | 全局jwtSecret被后续测试覆盖 | InitJWTSecret放到每个测试开头执行 |
| 某些分支覆盖率一直上不去 | 边界条件没覆盖 | 补空密码、非法哈希、篡改Token等用例 |
关于测试环境的成本因子,这里多说一句:bcrypt的DefaultCost大约是100毫秒级的哈希时间,单测跑几十个用例就多了好几秒,开发体验很糟糕。我当时给HashPassword加了一个可配置的成本参数,测试里传bcrypt.MinCost,生产用DefaultCost,这样既保证了安全强度,又让测试秒级跑完。实际做的时候不要为了测试专门改生产代码的复杂度,通过构造参数注入就能解决。
6. 一点个人体会
这套认证模块做完之后,最大的感受是:安全的门槛不在于会用某个库,而在于理解每个设计决策背后的“为什么”。bcrypt慢是故意的,JWT签名要校验算法是防混淆攻击的,测试要覆盖边界是给自己留退路的。我踩过的那些坑,绝大多数在动手前多想一步就能避免,但只有真踩过,下次才记得住。
另外,单测这件事真的是越早写越划算。认证模块的代码集中、逻辑独立,是新人练习单测的绝佳切入场景。如果你手头也有类似的项目,建议把密码处理和JWT这两块单独拉出来,先把测试框架搭起来,再往里面填业务逻辑。后面随着项目变大,这批测试会变成你最靠得住的安全网,改代码的时候心里不慌,上线的时候底气也足一些。