GoChat认证模块实战:bcrypt密码哈希与JWT鉴权全解析 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如果不是就返回错误。这能防止两类攻击一类是algnone攻击者把签名算法改成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这两块单独拉出来先把测试框架搭起来再往里面填业务逻辑。后面随着项目变大这批测试会变成你最靠得住的安全网改代码的时候心里不慌上线的时候底气也足一些。