学习目标
学完本章你应该能够:
- 说清楚 Kratos 四层架构(service → biz → data → model)每一层负责什么,以及为什么 biz 层用接口抽象
UserRepo/Cache/Locker(依赖倒置的好处)。 - 讲清 JWT 双令牌(access + refresh)的取舍:为什么需要两个令牌、各自有效期与泄露风险窗口、Claims 里应该带哪些字段(
jti/username/token_version)。 - 设计「先查后写 + 数据库唯一索引」的并发安全注册流程,并解释为什么光靠应用层查重挡不住并发冲突;同时知道商用版还要加「用户名格式校验 + 邮箱唯一 + 邮箱验证激活」。
- 用 Redis 黑名单(JTI)解决登出 + 令牌版本号(
token_version)解决改密/封禁/登出全部设备 两套机制,讲清 JWT 撤销的完整边界,并画出版本比对流程图。 - 解释为什么密钥必须强随机且「未配置即启动失败(fail-fast)」、为什么黑名单校验必须「fail-closed」、为什么进程内限流必须升级为「Redis 分布式限流 + 账号失败锁定」。
- 在刷新令牌时补全 username(修复源码 bug),并说明 refresh token 如何被版本号统一吊销。
- 把「bcrypt 慢哈希 / 分布式限流与账号锁定 / 原子更新防存储统计漂移 / 防账号枚举 / 审计日志 / 账号注销」讲成有取舍的工程故事。
前置知识:
- 已了解 Go 基础语法与
interface、依赖注入(Google Wire)基本概念 - 知道 MySQL 唯一索引、Redis 基本命令(SET/EXISTS/TTL/INCR/EXPIRE)
- 建议先回看「JWT 与认证基础」一节
本章你会动手做的事:
- 跟读一遍注册流程,在纸上画出 service → biz → data 的调用链。
- 给
Register的 biz 层代码按「参数校验 / 查重 / 哈希 / 入库 / 发验证邮件」拆成小步注释。 - 用 Redis 模拟一次「改密 →
token_version自增 → 旧 refresh token 刷新被拒」的最小实验,并画时序图。
一、技术栈与中间件
用户模块采用经典的 Kratos 四层架构(service → biz → data → model),并借助一系列中间件完成认证、限流、缓存等能力。下表汇总了本模块用到的全部技术与中间件及其用途:
| 技术 / 中间件 | 所属层 | 用途说明 |
|---|---|---|
| Go Kratos v3 | 框架骨架 | 提供微服务框架、HTTP/gRPC 传输、中间件链、错误规范(errors 包)、日志、依赖注入(ProviderSet)等基础设施 |
| Google Wire | 依赖注入 | 编译期生成依赖注入代码,将 TokenManager、UserRepo、UserUsecase、UserService 等组装起来,避免运行时反射开销 |
| golang-jwt/jwt v5 | 认证 | 生成、解析、校验 JWT(HS256),自定义 Claims(含 jti/username/token_version),支持黑名单检查与未验证解析(ParseUnverified) |
| golang.org/x/crypto/bcrypt | 密码哈希 | 对用户密码、密保答案做慢哈希(GenerateFromPassword / CompareHashAndPassword),防御彩虹表与暴力破解 |
| Redis(通过 biz.Cache 接口) | 缓存层 | 令牌黑名单存储、登录失败计数与账号锁定、用户存储信息缓存、邮件验证码、分布式限流计数 |
| GORM | 数据访问层(data) | ORM,操作 MySQL,支持 Create、First、Where、Updates、UpdateColumn、Expr 原子表达式、Pluck、Select 聚合等 |
| MySQL | 持久化存储 | 用户表(model.User)持久化,依赖 username 唯一索引保证并发注册安全,used_storage 字段原子更新;商用版增加 token_version/email_verified/locked_until 等列 |
| JWTAuthMiddleware | 服务端中间件 | 从 Authorization: Bearer xxx 头提取令牌,校验签名、黑名单与令牌版本号,将 user_id/username/token 注入 context |
| RateLimitMiddleware | 服务端中间件 | 分布式滑动窗口限流(Redis + Lua),登录/重置按「账号 + IP」失败计数与锁定,防止撞库/爆破 |
| RecoveryMiddleware | 服务端中间件 | recover panic,统一返回 500,避免 goroutine 崩溃导致进程退出 |
| TimingMiddleware | 服务端中间件 | 记录每个请求耗时,输出到日志用于性能监控 |
| CacheControlMiddleware | 服务端中间件 | 设置 Cache-Control: no-store 响应头,防止浏览器缓存敏感的用户数据接口 |
| Locker 接口(biz 层) | 并发控制 | 抽象分布式锁能力(由 UserUsecase 持有),可用于关键路径串行化 |
二、实现思路流程(总体)
用户模块的整体实现遵循"分层 + 中间件"的设计,端到端流程如下:
(下图把「客户端 → service → biz → data → Redis → TokenManager」的调用骨架一次性画出来,后面每一节都是它的展开。)
flowchart LR
A[客户端] --> B[service 层
proto 转换 无业务逻辑]
B --> C[biz 层
校验/哈希/令牌/版本号]
C --> D[data 层
GORM + MySQL]
C --> E[Redis
黑名单/锁定/缓存/限流]
F[JWTAuthMiddleware] -->|拦截受保护路由| B
B --> G[TokenManager
签发/校验 JWT]用户注册
service.Register接收 proto 请求 → 调用biz.UserUsecase.Register。- biz 层校验用户名(格式/长度/白名单)、密码(强度策略)、邮箱(格式 + 唯一性)。
- 第一道防线:先调用
repo.FindByUsername查重;第二道防线:依赖数据库username唯一索引,捕获 MySQL 1062 冲突错误。 - 使用
bcrypt.GenerateFromPassword对密码做慢哈希,默认分配 10GB 存储配额,token_version初始为 0,再repo.Create入库。 - 入库成功后发送邮箱验证邮件(签名 + 时效 token),未激活账号禁止登录。
用户登录
service.Login→biz.Login,先查账号锁定(locked_until),被锁直接拒绝。- 根据用户名查库;用户不存在与密码错误统一返回
ErrInvalidCredentials(防账号枚举)。 - 校验账号状态与邮箱已激活;
bcrypt.CompareHashAndPassword比对密码哈希。 - 失败则按「账号 + IP」累加 Redis 失败计数,超限锁定 15 分钟;成功清零计数。
- 登录成功后由
TokenManager签发双令牌:短期accessToken(含user_id/username/token_version,默认 24h)与长期refreshToken(含userID/token_version,默认 7 天)。
JWT 签发
GenerateAccessToken组装Claims(含Issuer、Subject、Audience、ExpiresAt、NotBefore、IssuedAt、ID/JTI、TokenVersion),用 HS256 + 服务端secret签名。JTI由crypto/rand生成 16 字节随机数后 hex 编码,作为令牌唯一标识,用于黑名单精确失效。
鉴权中间件
JWTAuthMiddleware拦截受保护路由,白名单(注册、登录、密保问题查询等)直接放行。- 从
Authorization头取 Bearer token,调用TokenManager.VerifyToken校验签名 + 过期 + 黑名单 + 令牌版本号。 - 校验通过后将
user_id、username、token写入context.Context(使用自定义 key 类型防冲突),供后续 handler 读取。
登出 / 令牌撤销
service.Logout从 context 取出当前 token →biz.Logout→TokenManager.BlacklistToken,把JTI写入 Redis(blacklist:token:{jti},TTL = access token 有效期)。- 「登出全部设备」则
UPDATE users SET token_version = token_version + 1,使所有已签发令牌(含 refresh)集体失效。
密码管理
- 在线修改密码:先比对旧密码,再哈希新密码,
repo.UpdatePassword仅更新password字段,并BumpTokenVersion让所有旧令牌失效。 - 密保/邮箱重置密码:统一模糊错误防枚举;密保答案带失败计数锁定;邮箱重置走「发码 → 验码 → 改密」且验证码一次性、限时。
- 在线修改密码:先比对旧密码,再哈希新密码,
存储空间校准
GetStorageInfo采用「Redis 缓存优先 → 未命中查库 → 回写缓存」三级策略,缓存 JSON 序列化的StorageInfo,TTL 5 分钟。- 数据层
UpdateUsedStorageAtomic利用WHERE used_storage >= ?+gorm.Expr("used_storage + ?")做原子增减,防止并发上传/删除导致存储统计漂移。
三、面试常问知识点与难点
1. JWT 原理与无状态认证
JWT 由 Header.Payload.Signature 三段组成,服务端用 secret 对前两段做 HMAC 签名。验证时只需重新计算签名比对即可,不需要查库,因此天然适合水平扩展的微服务。缺点是令牌签发后默认无法主动撤销——本项目用「Redis 黑名单(登出)+ 令牌版本号(改密/封禁)」两套机制补齐,见 三.3 与 三.4。
2. bcrypt 为什么是慢哈希
bcrypt 内部采用 Blowfish 派生算法,可通过 Cost 参数控制迭代轮数(每 +1 翻倍),单次哈希耗时约几十到几百毫秒。这使得离线爆破成本极高,且每次哈希自带随机 salt,相同密码哈希结果不同,能有效抵御彩虹表攻击。代价是登录/注册时 CPU 开销较大,需注意在高并发下做限流或异步化(商用版建议 Cost=12 并放到独立 worker 池)。
3. 令牌黑名单(解决"登出"场景)
类比:JWT 像一张「无法作废的电影票」,检票只看票本身真伪。黑名单就是影院门口的「作废名单」——票本身没坏,但名字在名单上就拒入。
flowchart LR
L[用户登出] --> W["写 Redis
blacklist:token:{jti} = 1"]
V[后续请求校验] --> C{黑名单存在?}
C -->|是| R[拒绝 视为过期]
C -->|否| OK[放行]
W -. "TTL = access有效期 自动清理" .-> CJWT 无状态,登出后令牌仍然有效直到过期。登出场景以 JTI 为 key 将令牌写入 Redis,VerifyToken 时检查 blacklist:token:{jti} 是否存在。相比「服务端记录所有有效令牌」的方案,黑名单只在登出/封禁时写入,写多读少的负载下性能更优。TTL 取 access token 的有效期(本项目 tm.expire,24h),令牌自然过期后黑名单条目自动清理,避免内存膨胀。
⚠️ 黑名单的边界(务必记牢):黑名单只解决"当前 access token 登出失效"这一个场景:
- 它只能让被拉黑的 access token 失效,对 refresh token 无效——因为 refresh token 走刷新接口时并不在黑名单里,且即使把 refresh token 的 JTI 也拉黑,黑名单 TTL(24h)也远小于 refresh token 的 7 天有效期,24h 后黑名单消失、refresh token 仍可用。
- 它做不到"改密 / 封禁账号后让所有已签发令牌集体失效"——
UpdatePassword/封禁既不拉黑也不使旧 refresh token 失效。因此"改密即踢全设备 / 管理员封禁即时生效"必须靠下一节的
token_version令牌版本号,而不是黑名单。
4. 令牌版本号(解决"改密/封禁/登出全部设备")
类比:把
token_version想成「门禁系统的总版本号」。你改一次密码,总版本号 +1;所有旧门禁卡里印的版本号对不上最新的,刷卡一律被拒——不管它丢没丢、在哪台设备。
flowchart LR
A[用户改密/封禁] --> B[UPDATE users SET token_version = version + 1]
B --> C[旧令牌 claim.tv=2 库里已=3]
C --> D[下次请求 VerifyToken]
D --> E{claim.tv == 当前 version?}
E -->|否| R[拒绝 视为过期]
E -->|是| OK[放行]在 users 表加 token_version 列(默认 0)。签发时把当前 version 写入 JWT 的 Claims.TokenVersion;校验时比对「令牌里的 version == 库里当前 version」,不等即拒绝。这样:
- 用户改密 →
BumpTokenVersion→ 所有旧 access/refresh token 在下次请求(或刷新)时集体失效,实现"改密即踢全设备"。 - 管理员封禁 → 同样自增 version,已签发令牌立即失效。
- 黑名单(JTI)与版本号互补:黑名单用于"单个 access token 登出",版本号用于"账号级全部失效"。
5. 双令牌机制(access + refresh)
类比:把 access token 想成「临时门禁卡」,refresh token 想成「身份证」。门禁卡每小时过期,丢了损失小;身份证长期有效,但只能用来补门禁卡,不能直接刷门。
flowchart LR
U[用户登录成功] --> AT[accessToken
24h 含 user_id/username/tv]
U --> RT[refreshToken
7天 含 userID/tv]
AT --> API[业务接口鉴权]
RT --> REF[刷新接口
换发新双令牌]
REF --> AT
REF --> RTaccessToken:短期(默认 24h),携带user_id/username/token_version,用于业务接口鉴权,泄露风险窗口小。refreshToken:长期(默认 7 天),携带userID/token_version(可不含 username,刷新时查库补全),只能用于换发新的 access token,不能直接访问业务接口。- 两者分离后,即使 access token 泄露,攻击者也只能在短期内作恶;refresh token 通常存放在更安全的位置(如 HttpOnly Cookie),降低被盗风险。refresh token 的吊销统一由
token_version控制。
6. 数据库唯一索引的并发安全
注册时即使 biz 层先做了 FindByUsername 查重,在并发场景下仍可能两个请求同时通过查重。本项目依赖 MySQL username 唯一索引作为第二道防线,冲突时返回 1062 错误,biz 层通过 errors.IsConflict(err) 捕获并转成 ErrUserAlreadyExists。这是「先查后写」防竞态的经典做法。商用版还应对 email 加唯一索引(配合邮箱激活),防止多账号共用邮箱。
7. 分布式滑动窗口限流与账号锁定
教学版常写成「单机按 IP 计数器」,但多实例部署时各算各的、且无法防单账号撞库。商用版用 Redis + Lua 做分布式限流(令牌桶/滑动窗口),保证多实例共享计数;登录/重置接口再按「账号 + IP」维度在 Redis 累加失败次数,超限锁定一段时间(如 5 次失败锁 15 分钟)。
flowchart TD
Req[登录请求] --> L{账号被锁?}
L -->|是| R[拒绝 ACCOUNT_LOCKED]
L -->|否| V[校验密码]
V -->|失败| I[INCR 失败计数 + 设15min TTL]
I --> C{>=5次?}
C -->|是| LK[加 login_lock 锁定15min]
C -->|否| R2[返回 凭证错误]
V -->|成功| O[清零计数 签发令牌]8. 原子更新防止存储统计漂移
类比:统计已用空间像「公共计数器」,十个人同时加减,若各自先抄数再改,最后一定有人白改。正确做法是让数据库在一条 SQL 里「当场读当场改并上锁」,谁也插不了队。
flowchart LR
U[上传+ / 删除-] --> Q[UPDATE used_storage = used_storage + ?
WHERE id=? AND used_storage >= ?]
Q --> R{RowsAffected}
R -->|1| OK[更新成功 无漂移]
R -->|0| X{用户存在?}
X -->|否| N[ErrUserNotFound]
X -->|是| S[ErrStorageInsufficient 空间不足]文件上传/删除会修改 used_storage。若先读后写,并发场景下会丢失更新。本项目用 UPDATE ... SET used_storage = used_storage + ? WHERE id = ? AND used_storage >= ?(减少时带条件防超卖),依赖数据库行锁保证原子性,并通过 RowsAffected == 0 判断是用户不存在还是空间不足。
9. 分层架构与依赖倒置
类比:biz 层是「房东」,只定义「要有水电接口」(UserRepo/Cache/Locker);data 层是「施工队」,按接口接好真实水管电路(GORM/Redis)。哪天换城市(换数据库),只换施工队,房东合同不变。
flowchart TD
S[service 层
proto 转换] --> B[biz 层
定义接口 UserRepo/Cache/Locker]
B -->|接口依赖| D[data 层
GORM 实现]
B -->|接口依赖| C[Redis 缓存实现]
W[Wire 依赖注入] --> S
W --> B
W --> D
W --> Cbiz 层定义 UserRepo、Cache、Locker 接口,data 层提供 GORM 实现,由 Wire 注入。biz 不直接依赖 gorm 或具体 cache 包,便于替换底层存储(如换 Postgres、换本地内存缓存),也方便单元测试时 mock。service 层只做 proto ↔ biz 的转换,不含业务逻辑。
四、亿级流量优化思路
1. 多级缓存用户信息
GetStorageInfo 已实现 Redis 缓存(5 分钟 TTL)。亿级流量下可进一步引入本地缓存(如 bigcache/ristretto)作为 L1,Redis 作为 L2,形成 L1 本地内存 → L2 Redis → DB 三级缓存。本地缓存命中无网络开销,可承受极高 QPS;通过 Redis Pub/Sub 或版本号广播失效,保证一致性。
2. 布隆过滤器防穿透
恶意请求不存在的用户名会导致缓存未命中并穿透到 DB。可在 Redis 中维护一个用户名布隆过滤器,注册时 BF.ADD,查询时先 BF.EXISTS 过滤。对「确定不存在」的请求直接返回 404,避免 DB 压力。注意布隆过滤器有误判率,需预留扩容。
3. JWT 无状态水平扩展
JWT 验证不依赖 DB/集中式 session,理论上任意节点都能独立校验(只需共享密钥与 Redis 黑名单/版本号)。亿级流量下只需在负载均衡层无差别分发请求即可线性扩容。黑名单查询走 Redis 集群(读多写少),不会成为瓶颈。如需进一步降低 Redis 依赖,可对黑名单做本地缓存短 TTL(如 10s),容忍轻微的登出延迟——但 token_version 的版本比对必须实时查库,不能缓存。
4. 读写分离
用户信息读多写少。可将 FindByID、FindByUsername、GetUserStorage 等读请求路由到 MySQL 只读从库,Create、UpdatePassword、BumpTokenVersion、UpdateUsedStorageAtomic 等写请求走主库。注册后短暂延迟(主从同步)可通过「写后立即读主库」或缓存回写解决。
5. 限流与熔断
- 入口层:Base 限流防刷,采用 Redis + Lua 分布式限流(令牌桶),多实例共享计数。
- 接口层:登录/重置按「账号 + IP」失败计数 + 锁定(
login_fail:{key}/login_lock:{key}),挡住针对单账号的撞库/爆破;注册接口也加限流 + 邮箱验证,防止无限建号。 - 服务层:对下游 DB/Redis 调用加熔断(如
breaker),故障时快速失败返回降级响应,避免雪崩。
6. 密码哈希异步化与降级
bcrypt Cost=12 在高并发登录时会打满 CPU。可:
- 将登录密码比对放到独立 worker 池限流,避免拖垮主链路;
- 根据机器 CPU 动态调整
Cost; - 极端流量下对低风险请求降级为「缓存最近一次成功哈希结果 + 短 TTL」,牺牲少量安全性保可用。
7. 分库分表
用户表达到亿级时单库扛不住。可按 user_id 哈希分库分表(如 64 库 × 64 表),username 查询走「username → user_id 映射表」或 Redis 反向索引。存储统计 used_storage 的原子更新在分库后仍可用(同库内行锁),但跨用户聚合统计需走汇总表或离线计算。token_version 随用户行存储,分库后同库内自增即可。
8. 连接池与热点 key
- GORM/MySQL 连接池合理配置
SetMaxOpenConns/SetMaxIdleConns,避免连接耗尽。 - 热点用户(如大 V)的存储信息缓存可加本地副本 + 短 TTL,并对 Redis 热 key 做分片(如
user:storage:{id}:{shard})打散。商用版缓存 key 应加业务命名空间前缀(如cloud-disk:user:storage:%d),防跨模块冲突。
五、详细实现流程与代码解析
下面按子功能逐个讲解。所有代码片段均基于项目源文件,并按商用在线服务标准修正或补全(修正处会标注「修正点」)。
5.1 用户注册(bcrypt 哈希 + 唯一索引 + 用户名/邮箱校验 + 邮箱激活)
实现思路
service层接收RegisterRequest,透传给biz层。biz层做参数校验:用户名格式/长度/白名单、密码强度策略、邮箱格式 + 唯一性。- 第一道防线:
FindByUsername查重,已存在直接返回ErrUserAlreadyExists。 bcrypt.GenerateFromPassword哈希密码(Cost=12);昵称为空时默认用用户名;默认分配 10GB 存储配额;token_version初始 0;email_verified=0。- 第二道防线:
repo.Create入库,若返回冲突错误(MySQL 1062,含username/email唯一索引)再次转成对应错误。 - 入库成功后发送邮箱验证邮件(签名 + 时效 token),未激活账号禁止登录。
关键代码 — biz 层注册逻辑(internal/biz/user.go)
// 用户名/密码规则(商用标准)
var (
usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) // 3-32 位字母数字下划线
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
reservedWords = map[string]bool{"admin": true, "root": true, "system": true} // 保留词黑名单
)
// Register 通过数据库唯一索引保证并发安全地创建新用户账户
func (uc *UserUsecase) Register(ctx context.Context, username, password, email, phone, nickname string) (*User, error) {
// —— 参数校验阶段(修正点:补充用户名格式与保留词、密码强度、邮箱唯一)——
if username == "" || password == "" {
return nil, ErrUsernamePasswordRequired
}
if !usernameRegex.MatchString(username) {
return nil, errors.BadRequest("USERNAME_INVALID", "用户名需为3-32位字母、数字或下划线")
}
if reservedWords[strings.ToLower(username)] {
return nil, errors.BadRequest("USERNAME_RESERVED", "该用户名不可注册")
}
if len(password) < 10 { // 修正点:从 8 位提高到 10 位,生产可加复杂度规则
return nil, errors.BadRequest("PASSWORD_TOO_SHORT", "密码不能少于10位")
}
if !passwordPolicyOK(password) { // 长度+大小写+数字+特殊字符
return nil, errors.BadRequest("PASSWORD_WEAK", "密码需含大小写字母、数字与特殊字符")
}
if email == "" || !emailRegex.MatchString(email) {
return nil, errors.BadRequest("EMAIL_INVALID", "邮箱格式不正确")
}
if len(email) > 128 {
return nil, errors.BadRequest("EMAIL_TOO_LONG", "邮箱长度不能超过128个字符")
}
// —— 第一道防线:业务层查重(用户名)——
existing, err := uc.repo.FindByUsername(ctx, username)
if err != nil && !errors.IsNotFound(err) {
return nil, err
}
if existing != nil && existing.ID > 0 {
return nil, ErrUserAlreadyExists
}
// 修正点:邮箱唯一性也提前查(第二道靠 DB 唯一索引兜底)
if e2, _ := uc.repo.FindByEmail(ctx, email); e2 != nil && e2.ID > 0 {
return nil, ErrEmailAlreadyExists
}
// —— 密码慢哈希(Cost=12)——
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, err
}
if nickname == "" {
nickname = username
}
const defaultTotalStorage int64 = 10 * 1024 * 1024 * 1024
user := &User{
Username: username,
Password: string(hashedPassword),
Email: email,
Phone: phone,
Nickname: nickname,
Status: 1,
EmailVerified: 0, // 修正点:未激活
TokenVersion: 0, // 修正点:令牌版本号初始 0
StorageQuota: defaultTotalStorage,
TotalStorage: defaultTotalStorage,
}
// —— 第二道防线:数据库唯一索引(username + email)——
created, err := uc.repo.Create(ctx, user)
if err != nil {
if errors.IsConflict(err) {
return nil, ErrUserAlreadyExists // 或按冲突字段细分
}
return nil, err
}
// 修正点:发送邮箱验证邮件(签名时效 token,异步/消息队列更好)
_ = uc.SendVerificationEmail(ctx, created)
return created, nil
}
flowchart TD
A[注册请求] --> B[校验 用户名格式/密码强度/邮箱]
B --> C{用户名/邮箱已存在?}
C -->|是| E[返回 已存在错误]
C -->|否| D[bcrypt 哈希 + 入库]
D --> F[email 唯一索引兜底]
F --> G[发送验证邮件]
G --> H[返回 注册成功 待激活]5.2 用户登录(双令牌签发 + 账号锁定 + 防枚举)
实现思路
- 先查账号锁定(
locked_until > now)→ 被锁直接拒绝。 - 参数校验:用户名/密码非空。
FindByUsername查库;用户不存在与密码错误统一返回ErrInvalidCredentials(防账号枚举)。- 校验账号状态
Status == 1且email_verified == 1(未激活禁止登录)。 bcrypt.CompareHashAndPassword比对密码哈希,失败按「账号 + IP」累加失败计数,超限锁定 15 分钟;成功清零。TokenManager.GenerateAccessToken(含user_id/username/token_version)。TokenManager.GenerateRefreshToken(含userID/token_version)。- 返回用户信息、access token、refresh token、过期时间戳。
关键代码 — biz 层登录逻辑(internal/biz/user.go)
// Login 验证用户身份并返回 JWT 令牌
func (uc *UserUsecase) Login(ctx context.Context, username, password string) (*User, string, string, int64, error) {
if username == "" || password == "" {
return nil, "", "", 0, ErrUsernamePasswordRequired
}
// 修正点:先查账号锁定
if locked, _ := uc.repo.IsLocked(ctx, username); locked {
return nil, "", "", 0, errors.Forbidden("ACCOUNT_LOCKED", "账号已锁定,请15分钟后再试或找回密码")
}
user, err := uc.repo.FindByUsername(ctx, username)
if err != nil {
if errors.IsNotFound(err) {
// 关键:用户不存在也返回"用户名或密码不正确"(防枚举)
return nil, "", "", 0, ErrInvalidCredentials
}
return nil, "", "", 0, err
}
if user.Status == 0 {
return nil, "", "", 0, errors.Forbidden("USER_DISABLED", "账号已被禁用")
}
// 修正点:未激活邮箱禁止登录
if user.EmailVerified == 0 {
return nil, "", "", 0, errors.Forbidden("EMAIL_NOT_VERIFIED", "请先完成邮箱验证")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
// 修正点:失败计数 + 锁定
uc.repo.IncLoginFail(ctx, username)
if uc.repo.LoginFailCount(ctx, username) >= 5 {
uc.repo.LockAccount(ctx, username, 15*time.Minute)
}
return nil, "", "", 0, ErrInvalidCredentials
}
// 修正点:成功清零失败计数
uc.repo.ResetLoginFail(ctx, username)
// 签发双令牌:把当前 token_version 写进 claims
accessToken, err := uc.token.GenerateAccessToken(user.ID, user.Username, user.TokenVersion)
if err != nil {
return nil, "", "", 0, err
}
refreshToken, err := uc.token.GenerateRefreshToken(user.ID, user.TokenVersion)
if err != nil {
return nil, "", "", 0, err
}
return user, accessToken, refreshToken, time.Now().Add(uc.token.GetExpire()).Unix(), nil
}
关键代码 — TokenManager 签发令牌(internal/biz/auth.go,含版本号)
// Claims 自定义 JWT 载荷(修正点:新增 TokenVersion)
type Claims struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
TokenVersion int64 `json:"tv"`
jwt.RegisteredClaims
}
// GenerateAccessToken 创建签名的 JWT 访问令牌
func (tm *TokenManager) GenerateAccessToken(userID uint64, username string, tokenVersion int64) (string, error) {
now := time.Now()
claims := &Claims{
UserID: userID,
Username: username,
TokenVersion: tokenVersion, // 修正点:写入版本号
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "cloud-disk",
Subject: username,
Audience: jwt.ClaimStrings{"cloud-disk"},
ExpiresAt: jwt.NewNumericDate(now.Add(tm.expire)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: generateJTI(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(tm.secret)
}
// GenerateRefreshToken 创建签名的刷新令牌,更长有效期,只带 userID + 版本号
func (tm *TokenManager) GenerateRefreshToken(userID uint64, tokenVersion int64) (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: "cloud-disk",
Subject: fmt.Sprintf("%d", userID),
ExpiresAt: jwt.NewNumericDate(now.Add(tm.refreshExpire)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: generateJTI(),
}
// 修正点:refresh token 同样带版本号,使其可被统一吊销
// 这里把版本号塞进标准 RegisteredClaims 之外的私有 claim 即可(自行封装)
// 为简洁,改为使用自定义 Claims:
rc := &Claims{UserID: userID, TokenVersion: tokenVersion, RegisteredClaims: claims}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, rc)
return token.SignedString(tm.secret)
}
关键代码 — service 层登录响应组装(internal/service/user.go)
// Login 验证用户身份并返回 JWT 令牌。
func (s *UserService) Login(ctx context.Context, req *v1.LoginRequest) (*v1.LoginReply, error) {
user, accessToken, refreshToken, expiresAt, err := s.uc.Login(ctx, req.Username, req.Password)
if err != nil {
return nil, err
}
return &v1.LoginReply{
User: toUserInfo(user),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
}, nil
}
5.3 JWT 鉴权中间件(令牌校验 + 黑名单 + 版本号)
类比:中间件是「小区门卫」——先看是不是快递员(白名单直接进),再看有没有门禁卡(Bearer token),验卡真伪 + 查作废名单 + 对一遍总版本号,最后把你的身份写进「访客登记本」(context)交给里面的人。
flowchart TD
Req[请求到达] --> WL{在白名单?}
WL -->|是| Pass[直接放行]
WL -->|否| Has{带 Bearer token?}
Has -->|否| E1[401 MISSING_TOKEN]
Has -->|是| V[VerifyToken
签名+过期+黑名单+版本号]
V -->|失败| E2[拒绝]
V -->|成功| Inj[注入 user_id/username/token 到 context]
Inj --> H[交给后续 handler]实现思路
- 中间件构造时接收白名单,命中白名单直接放行。
- 从
transport取出Authorization头,解析Bearer前缀得到 token。 - 缺失 token 返回
MISSING_TOKEN。 TokenManager.VerifyToken校验:签名算法必须是 HMAC、签名正确、未过期、未在黑名单、版本号与库一致(fail-closed,见下)。- 校验通过后将身份写入 context(修正点:context key 改用自定义类型防冲突),后续 handler 通过
CtxUserID/CtxUsername/CtxToken读取。
关键代码 — 中间件实现(internal/server/middleware.go)
// 修正点:自定义 context key 类型,避免与其他包裸字符串 key 冲突
type ctxKey string
const (
ctxKeyUserID ctxKey = "user_id"
ctxKeyUsername ctxKey = "username"
ctxKeyToken ctxKey = "token"
)
// JWTAuthMiddleware 返回一个验证 JWT token 的中间件。
func JWTAuthMiddleware(tokenManager *biz.TokenManager, whitelist ...string) middleware.Middleware {
whitelistMap := make(map[string]bool, len(whitelist))
for _, p := range whitelist {
whitelistMap[p] = true
}
return func(handler middleware.Handler) middleware.Handler {
return func(ctx context.Context, req interface{}) (interface{}, error) {
if tr, ok := transport.FromServerContext(ctx); ok {
op := tr.Operation()
if whitelistMap[op] {
return handler(ctx, req)
}
if ht, ok := tr.(interface{ Request() *http.Request }); ok {
if whitelistMap[ht.Request().URL.Path] {
return handler(ctx, req)
}
}
}
var tokenStr string
if tr, ok := transport.FromServerContext(ctx); ok {
header := tr.RequestHeader()
auth := header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tokenStr = strings.TrimPrefix(auth, "Bearer ")
}
}
if tokenStr == "" {
return nil, errors.Unauthorized("MISSING_TOKEN", "缺少认证令牌")
}
claims, err := tokenManager.VerifyToken(ctx, tokenStr) // 修正点:传入 ctx
if err != nil {
return nil, err
}
ctx = context.WithValue(ctx, ctxKeyUserID, claims.UserID)
ctx = context.WithValue(ctx, ctxKeyUsername, claims.Username)
ctx = context.WithValue(ctx, ctxKeyToken, tokenStr)
return handler(ctx, req)
}
}
}
// CtxUserID 从 context 提取用户 ID
func CtxUserID(ctx context.Context) uint64 {
if id, ok := ctx.Value(ctxKeyUserID).(uint64); ok {
return id
}
return 0
}
// CtxToken 提取原始 token 字符串(登出用)
func CtxToken(ctx context.Context) string {
if t, ok := ctx.Value(ctxKeyToken).(string); ok {
return t
}
return ""
}
// CtxUsername 提取用户名
func CtxUsername(ctx context.Context) string {
if name, ok := ctx.Value(ctxKeyUsername).(string); ok {
return name
}
return ""
}
关键代码 — TokenManager 校验逻辑(internal/biz/auth.go,fail-closed + 版本号)
// VerifyToken 解析并验证 JWT 令牌字符串(修正点:fail-closed + 版本号)
func (tm *TokenManager) VerifyToken(ctx context.Context, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return tm.secret, nil
})
if err != nil {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
// —— 黑名单检查(修正点:fail-closed)——
if tm.cache != nil {
blacklisted, berr := tm.IsBlacklisted(ctx, tokenStr)
if berr != nil {
// Redis 故障:宁可拒绝,避免登出/封禁被绕过
log.Error("blacklist check failed", "err", berr)
return nil, ErrTokenExpired
}
if blacklisted {
return nil, ErrTokenExpired
}
}
// —— 令牌版本号检查(修正点:解决改密/封禁吊销)——
if claims.TokenVersion != 0 {
current, verr := tm.repo.GetTokenVersion(ctx, claims.UserID)
if verr != nil {
return nil, ErrTokenExpired // fail-closed
}
if claims.TokenVersion != current {
return nil, ErrTokenExpired // 版本不符 → 令牌已作废
}
}
return claims, nil
}
flowchart TD
V[VerifyToken] --> Q[查 Redis 黑名单]
Q --> E{出错?}
E -->|是| A[记录告警 + 拒绝 fail-closed]
E -->|否| C{在黑名单?}
C -->|是| R[拒绝]
C -->|否| T{版本号匹配?}
T -->|否| R
T -->|是| OK[放行]5.4 用户登出(黑名单 + 登出全部设备)
实现思路
service.Logout从 context 取出当前 token(中间件注入)。- 透传给
biz.Logout→TokenManager.BlacklistToken,把JTI写入 Redis(blacklist:token:{jti},TTL = access token 有效期)。 - 之后该令牌再被
VerifyToken校验时,IsBlacklisted返回 true,拒绝访问。 - 「登出全部设备」则调用
BumpTokenVersion,使所有已签发令牌(含 refresh)集体失效。
关键代码 — biz 层登出(internal/biz/user.go)
// Logout 使当前会话/令牌失效(加入黑名单)
func (uc *UserUsecase) Logout(ctx context.Context, tokenStr string) error {
if tokenStr == "" {
return nil // 没带 token 视为已登出,幂等返回
}
return uc.token.BlacklistToken(ctx, tokenStr)
}
// LogoutAllDevices 使该用户所有已签发令牌失效(改密/封禁复用同一机制)
func (uc *UserUsecase) LogoutAllDevices(ctx context.Context, userID uint64) error {
return uc.repo.BumpTokenVersion(ctx, userID)
}
关键代码 — 黑名单写入(internal/biz/auth.go)
// BlacklistToken 将令牌添加到黑名单
func (tm *TokenManager) BlacklistToken(ctx context.Context, tokenStr string) error {
if tm.cache == nil {
return nil // 没有缓存后端时无法实现黑名单,令牌会自然过期
}
jti, err := extractJTI(tokenStr)
if err != nil || jti == "" {
return nil
}
// TTL = access token 有效期,过期后自动清理
return tm.cache.Set(ctx, tm.blacklistPrefix+jti, "1", tm.expire)
}
5.5 令牌刷新(补全 username + 版本号校验 + 复用检测)
RefreshToken 接口允许客户端在 access token 过期后,用 refresh token 换取新的双令牌,避免用户重新登录。
实现思路
- refresh token 也走
VerifyToken校验(签名 + 过期 + 黑名单 + 版本号),因此天然享受上述所有撤销能力。 - 从
Subject解析 userID,再查库确认用户仍存在且未禁用。 - 修正点(源码 bug):刷新时必须查库取 username 再
GenerateAccessToken(userID, username, version),否则新 access token 的Subject/Username为空,依赖CtxUsername的逻辑会出错。 - 同时签发新的 access 与 refresh token(滑动续期),两者都带最新
token_version。 - 进阶:若把 refresh token 存表(jti + user_id + expire + 失效标记),可支持「复用检测」——同一 jti 第二次使用说明令牌泄露,直接吊销整族(版本号 +1)。
// RefreshToken 验证刷新令牌并返回新的访问令牌
func (uc *UserUsecase) RefreshToken(ctx context.Context, refreshTokenStr string) (string, string, int64, error) {
if refreshTokenStr == "" {
return "", "", 0, ErrInvalidToken
}
claims, err := uc.token.VerifyToken(ctx, refreshTokenStr)
if err != nil {
return "", "", 0, ErrInvalidToken
}
var userID uint64
if _, err := fmt.Sscanf(claims.Subject, "%d", &userID); err != nil || userID == 0 {
return "", "", 0, ErrInvalidToken
}
// 验证用户是否仍然存在且未禁用
user, err := uc.repo.FindByID(ctx, userID)
if err != nil {
return "", "", 0, ErrUserNotFound
}
// 修正点:查库取 username 与最新 version,避免新 access token 身份/版本缺失
accessToken, err := uc.token.GenerateAccessToken(user.ID, user.Username, user.TokenVersion)
if err != nil {
return "", "", 0, err
}
newRefreshToken, err := uc.token.GenerateRefreshToken(user.ID, user.TokenVersion)
if err != nil {
return "", "", 0, err
}
return accessToken, newRefreshToken, time.Now().Add(uc.token.GetExpire()).Unix(), nil
}
flowchart LR
R[客户端持 refreshToken 请求刷新] --> V[VerifyToken 校验 含版本号]
V --> F[FindByID 取 username + version]
F --> G[GenerateAccessToken userID + username + version]
G --> OK[新 access token 带完整身份]5.6 密码管理(改密自增版本号 + 密保重置防枚举/防爆破)
实现思路
在线修改密码(已登录状态):
- 从 context 取
user_id。 - 校验新旧密码非空、新密码满足强度策略。
FindByID查用户,bcrypt.CompareHashAndPassword验证旧密码。- 哈希新密码,
repo.UpdatePassword仅更新password字段,并BumpTokenVersion让所有旧令牌失效(含其他设备)。
密保重置密码(未登录状态):
GetSecurityQuestion返回密保问题——注意:对用户是否存在只返回模糊结果,不暴露账号是否注册。ResetPassword校验密保答案,答案失败累加计数并锁定,超限拒绝;无论"用户不存在"还是"答案错"统一返回模糊错误(防枚举)。
关键代码 — 在线修改密码(internal/biz/user.go)
// UpdatePassword 在验证旧密码后修改用户密码,并使旧令牌失效
func (uc *UserUsecase) UpdatePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) error {
if oldPassword == "" || newPassword == "" {
return ErrUsernamePasswordRequired
}
if !passwordPolicyOK(newPassword) {
return errors.BadRequest("PASSWORD_WEAK", "密码强度不足")
}
user, err := uc.repo.FindByID(ctx, userID)
if err != nil {
return ErrUserNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)); err != nil {
return ErrInvalidPassword
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
if err != nil {
return err
}
if err := uc.repo.UpdatePassword(ctx, userID, string(hashedPassword)); err != nil {
return err
}
// 修正点:改密后让所有旧令牌(含 refresh)集体失效
return uc.repo.BumpTokenVersion(ctx, userID)
}
关键代码 — 密保重置密码(防枚举 + 防爆破)
// ResetPassword 通过密保问题验证来重置密码(修正点:防枚举 + 防爆破)
func (uc *UserUsecase) ResetPassword(ctx context.Context, username, securityAnswer, newPassword string) error {
if username == "" || securityAnswer == "" || newPassword == "" {
return ErrUsernamePasswordRequired
}
// 修正点:先查锁定
if locked, _ := uc.repo.IsLocked(ctx, "sec:"+username); locked {
return errors.Forbidden("ACCOUNT_LOCKED", "尝试过于频繁,请稍后再试")
}
if !passwordPolicyOK(newPassword) {
return errors.BadRequest("PASSWORD_WEAK", "密码强度不足")
}
// 修正点:无论用户是否存在,都走同一查库路径,避免时序/错误差异枚举
user, err := uc.repo.FindByUsername(ctx, username)
if err != nil || user.SecurityAnswer == "" || user.SecurityQuestion == "" {
// 统一模糊错误,不暴露"用户不存在"或"未设密保"
return ErrSecurityAnswerMismatch
}
if err := bcrypt.CompareHashAndPassword([]byte(user.SecurityAnswer), []byte(securityAnswer)); err != nil {
uc.repo.IncLoginFail(ctx, "sec:"+username) // 复用失败计数
if uc.repo.LoginFailCount(ctx, "sec:"+username) >= 5 {
uc.repo.LockAccount(ctx, "sec:"+username, 15*time.Minute)
}
return ErrSecurityAnswerMismatch
}
uc.repo.ResetLoginFail(ctx, "sec:"+username)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
if err != nil {
return err
}
if err := uc.repo.UpdatePassword(ctx, user.ID, string(hashedPassword)); err != nil {
return err
}
// 修正点:重置密码同样让旧令牌失效
return uc.repo.BumpTokenVersion(ctx, user.ID)
}
// GetSecurityQuestion 返回用户的密保问题(修正点:不暴露账号是否存在)
func (uc *UserUsecase) GetSecurityQuestion(ctx context.Context, username string) (string, error) {
user, err := uc.repo.FindByUsername(ctx, username)
if err != nil || user.SecurityQuestion == "" {
// 统一返回空,前端不区分"不存在"与"未设密保"
return "", nil
}
return user.SecurityQuestion, nil
}
5.7 邮箱找回密码(新增,替代单一密保方式)
为什么需要:仅靠密保问题强度弱、可被爆破。生产应提供「邮箱验证码 / 链接重置」,且验证码限时、一次性、带限流。
flowchart LR
S[请求找回密码 输入邮箱] --> C[校验邮箱格式 + 限流]
C --> M[生成 6位 验证码 存 Redis TTL=10min]
M --> E[发送验证邮件]
E --> U[用户输入 验证码 + 新密码]
U --> V{验证码正确且未用?}
V -->|否| R[拒绝 重新获取]
V -->|是| OK[重置密码 + 失效验证码 + BumpTokenVersion]// SendResetCode 发送邮箱重置验证码(限流 + 一次性)
func (uc *UserUsecase) SendResetCode(ctx context.Context, email string) error {
if !emailRegex.MatchString(email) {
return errors.BadRequest("EMAIL_INVALID", "邮箱格式不正确")
}
user, err := uc.repo.FindByEmail(ctx, email)
if err != nil {
return nil // 修正点:不暴露邮箱是否注册(防枚举)
}
code := rand6()
// Redis 存 10 分钟,key 带业务前缀
if err := uc.cache.Set(ctx, "cloud-disk:reset:"+email, code, 10*time.Minute); err != nil {
return err
}
return uc.SendEmail(ctx, email, "您的重置验证码:"+code)
}
// ForgotPassword 用验证码重置密码
func (uc *UserUsecase) ForgotPassword(ctx context.Context, email, code, newPassword string) error {
if !passwordPolicyOK(newPassword) {
return errors.BadRequest("PASSWORD_WEAK", "密码强度不足")
}
saved, err := uc.cache.Get(ctx, "cloud-disk:reset:"+email)
if err != nil || saved != code {
return errors.BadRequest("CODE_WRONG", "验证码错误或已过期")
}
user, err := uc.repo.FindByEmail(ctx, email)
if err != nil {
return ErrUserNotFound
}
hashed, _ := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
if err := uc.repo.UpdatePassword(ctx, user.ID, string(hashed)); err != nil {
return err
}
uc.cache.Del(ctx, "cloud-disk:reset:"+email) // 一次性
return uc.repo.BumpTokenVersion(ctx, user.ID) // 旧令牌失效
}
5.8 用户信息查询与存储空间校准
实现思路
用户信息查询:service.GetUserInfo 从 context 取 user_id,调 biz.GetUserInfo → repo.FindByID,返回脱敏后的 UserInfo(toUserInfo 不含密码、密保答案)。
存储统计:GetStorageInfo 采用「Redis 缓存优先 → 未命中查库 → 回写缓存」策略,key 加业务前缀 cloud-disk:user:storage:{userID},TTL 5 分钟。
原子更新:UpdateUsedStorageAtomic 利用 WHERE used_storage >= ? + gorm.Expr("used_storage + ?") 做原子增减;减少操作带条件防超卖;RowsAffected == 0 区分「用户不存在」与「空间不足」。
关键代码 — service 层用户信息查询(internal/service/user.go)
// GetUserInfo 返回已认证用户的个人资料。
func (s *UserService) GetUserInfo(ctx context.Context, req *v1.GetUserInfoRequest) (*v1.GetUserInfoReply, error) {
userID := CtxUserID(ctx)
if userID == 0 {
return nil, biz.ErrInvalidToken
}
user, err := s.uc.GetUserInfo(ctx, userID)
if err != nil {
return nil, err
}
return &v1.GetUserInfoReply{User: toUserInfo(user)}, nil
}
// toUserInfo 将 biz.User 转换为 v1.UserInfo,天然脱敏(不含密码/密保答案)
func toUserInfo(user *biz.User) *v1.UserInfo {
if user == nil {
return nil
}
return &v1.UserInfo{
Id: user.ID, Username: user.Username, Email: user.Email,
Phone: user.Phone, Nickname: user.Nickname, Avatar: user.Avatar,
Status: user.Status, StorageQuota: user.StorageQuota,
UsedStorage: user.UsedStorage,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}
关键代码 — biz 层存储信息查询(internal/biz/user.go)
const (
cacheKeyUserStorage = "cloud-disk:user:storage:%d" // 修正点:加业务前缀
cacheTTLUserStorage = 300 * time.Second
)
func (uc *UserUsecase) GetStorageInfo(ctx context.Context, userID uint64) (*StorageInfo, error) {
if uc.cache != nil {
if val, err := uc.cache.Get(ctx, fmt.Sprintf(cacheKeyUserStorage, userID)); err == nil && val != "" {
var info StorageInfo
if json.Unmarshal([]byte(val), &info) == nil {
return &info, nil
}
}
}
total, used, err := uc.repo.GetUserStorage(ctx, userID)
if err != nil {
return nil, err
}
available := total - used
if available < 0 {
available = 0
}
var percent float64
if total > 0 {
percent = float64(used) / float64(total) * 100
}
info := &StorageInfo{Total: total, Used: used, Available: available, Percent: percent}
if uc.cache != nil {
if b, err := json.Marshal(info); err == nil {
_ = uc.cache.Set(ctx, fmt.Sprintf(cacheKeyUserStorage, userID), string(b), cacheTTLUserStorage)
}
}
return info, nil
}
关键代码 — data 层存储原子更新(internal/data/user.go)
// UpdateUsedStorageAtomic 原子性增减已用存储容量。delta>0 上传,delta<0 删除
func (r *userRepo) UpdateUsedStorageAtomic(ctx context.Context, userID uint64, delta int64) error {
if delta < 0 {
result := r.db.WithContext(ctx).Model(&model.User{}).
Where("id = ? AND used_storage >= ?", userID, -delta).
UpdateColumn("used_storage", gorm.Expr("used_storage + ?", delta))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
var count int64
r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Count(&count)
if count == 0 {
return biz.ErrUserNotFound
}
return biz.ErrStorageInsufficient
}
return nil
}
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("used_storage", gorm.Expr("used_storage + ?", delta))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return biz.ErrUserNotFound
}
return nil
}
5.9 账号注销与数据删除(新增,合规要求)
为什么需要:《个人信息保护法》赋予用户"删除权"。商用系统必须提供注销入口,并在注销后清理/匿名化其数据。
flowchart TD
A[用户申请注销] --> V[校验身份 需重新登录/验证码]
V --> C[标记 status=2 禁用]
C --> D[清理/匿名化 文件元数据/分享链接]
D --> E[BumpTokenVersion 旧令牌失效]
E --> F[异步删除对象存储文件]
F --> OK[返回 注销成功]// DeleteAccount 注销账号:逻辑禁用 + 异步清理
func (uc *UserUsecase) DeleteAccount(ctx context.Context, userID uint64) error {
if err := uc.repo.DisableAccount(ctx, userID); err != nil {
return err
}
uc.repo.BumpTokenVersion(ctx, userID) // 立即失效所有令牌
// 异步任务:匿名化昵称/邮箱、删除文件元数据、回收对象存储
uc.cleanupQueue.Publish(ctx, userID)
return nil
}
5.10 安全响应与审计日志(新增)
- 统一错误编码:
pkg/response.fromError对非 Kratos 错误不再返回err.Error(),对外只给通用消息(如 “internal server error”),真实原因仅入日志,避免泄露 SQL/堆栈细节。 - 审计日志:对登录失败、改密、重置、登出、注销等关键事件,写结构化日志(userID、事件、IP、时间、结果),便于安全复盘与合规。
- 安全响应头(nginx / 中间件):加
Content-Security-Policy、X-Content-Type-Options: nosniff、Referrer-Policy;移除已废弃的X-XSS-Protection;Cache-Control: no-store防敏感接口被缓存。 - CORS:开放第三方时按白名单配置,切勿
*+credentials。 - nginx 上传上限:
client_max_body_size对/api/设合理上限(如 2MB),仅上传/下载路由放开。 - 客户端 IP:限流取客户端 IP 时改用
X-Real-IP(由 nginx 填$remote_addr),避免直接信任可伪造的X-Forwarded-For首值。
5.11 健康检查与部署安全(新增)
- 增加
/healthz(存活)、/readyz(就绪,探 DB/Redis)端点,供 K8s/负载均衡探活。 - 数据库迁移用 golang-migrate / Atlas 做版本化迁移,
AutoMigrate仅本地;避免生产环境结构漂移。 - 密钥(JWT secret、DB 密码)从环境变量 / 密钥管理(Vault/KMS)注入,配置项
jwt_secret: ${JWT_SECRET}缺失即启动失败(fail-fast,见下)。
密钥 fail-fast(修正点:删除默认值)
// NewTokenManager 生产化:未配置强密钥直接启动失败
func NewTokenManager(c *conf.Auth) *TokenManager {
if c == nil || c.JwtSecret == "" {
// 修正点:绝不回退到硬编码默认值,否则任何人可伪造令牌
log.Fatalf("auth.jwt_secret is required in production")
}
expire, refreshExpire := 24*time.Hour, 7*24*time.Hour
if c.JwtExpire != nil {
expire = c.JwtExpire.AsDuration()
}
if c.JwtRefreshExpire != nil {
refreshExpire = c.JwtRefreshExpire.AsDuration()
}
return &TokenManager{
secret: []byte(c.JwtSecret),
expire: expire, refreshExpire: refreshExpire,
blacklistPrefix: "blacklist:token:",
}
}
flowchart TD
S[进程启动] --> C{配置含 jwt_secret?}
C -->|否| F[log.Fatalf 退出 绝不启动]
C -->|是| OK[正常初始化 TokenManager]
OK --> R[从密钥管理读取强随机密钥]自测题与动手练习
自测题(合上书能答出来,才算懂):
- 为什么 Kratos 项目里 biz 层只定义
UserRepo/Cache/Locker接口,而真正的 GORM/Redis 实现放在 data 层?这样做换存储(比如 PostgreSQL)时改哪里? - 注册时 biz 层已经先
FindByUsername查重了,为什么并发下仍可能两个请求都通过?最终靠什么兜底?MySQL 返回的错误号是多少?商用版还通过什么字段进一步防滥用(邮箱唯一 + 激活)? accessToken和refreshToken为什么要分开?各自默认有效期、携带的声明、泄露后的风险窗口分别是什么?refresh token 的撤销靠什么机制?- 黑名单(JTI)能解决什么、不能解决什么?为什么它对 7 天的 refresh token 只能封 24h?“改密后让所有旧令牌失效"靠哪套机制?请画一张版本比对流程图。
UpdateUsedStorageAtomic在「减少空间」时为什么要在WHERE里加used_storage >= ??RowsAffected == 0时能区分哪两种情况?- 为什么 JWT 密钥不能写默认值?生产化怎么做(fail-fast)?请画启动校验流程图。
RefreshToken里如果GenerateAccessToken(userID, "")传空 username 会导致什么具体问题?正确的改法是什么?- 当前限流方案在"多实例部署"和"防单账号撞库"两个场景上分别有什么缺陷?生产化应怎么改(分布式限流 + 账号锁定 + 验证码)?
- 黑名单校验在 Redis 故障时应该"fail-open"还是"fail-closed”?这两种选择各有什么利弊,你倾向于哪种?为什么版本号比对不能走本地缓存?
- 防账号枚举在哪些接口要特别注意(登录、密保重置、邮箱找回)?统一模糊错误 + 失败锁定是怎么配合的?
动手练习(建议真做一遍):
- 用
openssl或在线工具把一段 JWT 的 Header/Payload 做 base64url 解码,肉眼验证三段结构,再改一个字符看签名如何校验失败。 - 起一个本地 Redis,写 20 行 Go 代码模拟「登出 → 写入
blacklist:token:{jti}→ 再次IsBlacklisted返回 true」的最小流程。 - 把
Register的 biz 层代码复制到本地,故意去掉「数据库唯一索引兜底」,用两个 goroutine 并发注册同一用户名,观察是否产生重复记录(验证并发安全的必要性)。 - 给
users表加token_version字段,实现"改密即让所有旧令牌失效":写一段RefreshToken的改造代码,校验时比对claims.TokenVersion与库里当前值,并用 Mermaid 画出改密后旧令牌被拒的时序图。 - 用 Redis + 一个简单 Lua 脚本,实现「登录失败 5 次锁定 15 分钟」,再用 ab/hey 压测验证限流与锁定是否跨进程生效(开两个实例共享同一 Redis)。
本章小结
- Kratos 用户模块用 service → biz → data → model 四层 + 中间件链组织,biz 层用接口做依赖倒置,便于替换存储与单测 mock。
- 认证核心是 JWT 双令牌:access 短期业务鉴权、refresh 长期换发;两套撤销机制互补——Redis 黑名单(JTI)解决"单个 access token 登出失效",
token_version令牌版本号解决"改密/封禁/登出全部设备使所有令牌集体失效"。两者都是商用上线的必要能力。 - 密钥必须强随机且未配置即启动失败(fail-fast),绝不硬编码默认值;黑名单校验必须 fail-closed,Redis 故障时宁可拒绝,避免登出/封禁被绕过。
- 防爆破靠 Redis 分布式限流 + 账号失败锁定 + 验证码;防账号枚举靠 统一模糊错误 + 失败计数;注册还需 用户名格式校验、密码强度策略、邮箱唯一 + 激活。
- 并发安全靠「应用层查重 + 数据库唯一索引」双保险;存储统计靠
WHERE used_storage >= ?的原子更新防并发漂移与超卖。 - 合规与可观测:邮箱验证激活、账号注销与数据删除(PIPL/个保法)、审计日志、安全响应头、健康检查与版本化迁移,是商用版相对教学版的必要补全。
- 下一篇可进入「文件模块」,看上传/下载如何复用这里的鉴权中间件与
UpdateUsedStorageAtomic原子扣减能力。