用户模块实现教程

2025-07-21T14:11:02+08:00 | 32分钟阅读 | 更新于 2025-07-21T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 说清楚 Kratos 四层架构(service → biz → data → model)每一层负责什么,以及为什么 biz 层用接口抽象 UserRepo/Cache/Locker(依赖倒置的好处)。
  2. 讲清 JWT 双令牌(access + refresh)的取舍:为什么需要两个令牌、各自有效期与泄露风险窗口、Claims 里应该带哪些字段(jti / username / token_version)。
  3. 设计「先查后写 + 数据库唯一索引」的并发安全注册流程,并解释为什么光靠应用层查重挡不住并发冲突;同时知道商用版还要加「用户名格式校验 + 邮箱唯一 + 邮箱验证激活」。
  4. Redis 黑名单(JTI)解决登出 + 令牌版本号(token_version)解决改密/封禁/登出全部设备 两套机制,讲清 JWT 撤销的完整边界,并画出版本比对流程图。
  5. 解释为什么密钥必须强随机且「未配置即启动失败(fail-fast)」、为什么黑名单校验必须「fail-closed」、为什么进程内限流必须升级为「Redis 分布式限流 + 账号失败锁定」。
  6. 在刷新令牌时补全 username(修复源码 bug),并说明 refresh token 如何被版本号统一吊销。
  7. 把「bcrypt 慢哈希 / 分布式限流与账号锁定 / 原子更新防存储统计漂移 / 防账号枚举 / 审计日志 / 账号注销」讲成有取舍的工程故事。

前置知识

  • 已了解 Go 基础语法与 interface、依赖注入(Google Wire)基本概念
  • 知道 MySQL 唯一索引、Redis 基本命令(SET/EXISTS/TTL/INCR/EXPIRE)
  • 建议先回看「JWT 与认证基础」一节

本章你会动手做的事

  1. 跟读一遍注册流程,在纸上画出 service → biz → data 的调用链。
  2. Register 的 biz 层代码按「参数校验 / 查重 / 哈希 / 入库 / 发验证邮件」拆成小步注释。
  3. 用 Redis 模拟一次「改密 → token_version 自增 → 旧 refresh token 刷新被拒」的最小实验,并画时序图。

一、技术栈与中间件

用户模块采用经典的 Kratos 四层架构(servicebizdatamodel),并借助一系列中间件完成认证、限流、缓存等能力。下表汇总了本模块用到的全部技术与中间件及其用途:

技术 / 中间件所属层用途说明
Go Kratos v3框架骨架提供微服务框架、HTTP/gRPC 传输、中间件链、错误规范(errors 包)、日志、依赖注入(ProviderSet)等基础设施
Google Wire依赖注入编译期生成依赖注入代码,将 TokenManagerUserRepoUserUsecaseUserService 等组装起来,避免运行时反射开销
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,支持 CreateFirstWhereUpdatesUpdateColumnExpr 原子表达式、PluckSelect 聚合等
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]
  1. 用户注册

    • service.Register 接收 proto 请求 → 调用 biz.UserUsecase.Register
    • biz 层校验用户名(格式/长度/白名单)、密码(强度策略)、邮箱(格式 + 唯一性)。
    • 第一道防线:先调用 repo.FindByUsername 查重;第二道防线:依赖数据库 username 唯一索引,捕获 MySQL 1062 冲突错误。
    • 使用 bcrypt.GenerateFromPassword 对密码做慢哈希,默认分配 10GB 存储配额,token_version 初始为 0,再 repo.Create 入库。
    • 入库成功后发送邮箱验证邮件(签名 + 时效 token),未激活账号禁止登录。
  2. 用户登录

    • service.Loginbiz.Login,先查账号锁定(locked_until),被锁直接拒绝。
    • 根据用户名查库;用户不存在与密码错误统一返回 ErrInvalidCredentials(防账号枚举)。
    • 校验账号状态与邮箱已激活;bcrypt.CompareHashAndPassword 比对密码哈希。
    • 失败则按「账号 + IP」累加 Redis 失败计数,超限锁定 15 分钟;成功清零计数。
    • 登录成功后由 TokenManager 签发双令牌:短期 accessToken(含 user_id/username/token_version,默认 24h)与长期 refreshToken(含 userID/token_version,默认 7 天)。
  3. JWT 签发

    • GenerateAccessToken 组装 Claims(含 IssuerSubjectAudienceExpiresAtNotBeforeIssuedAtID/JTITokenVersion),用 HS256 + 服务端 secret 签名。
    • JTIcrypto/rand 生成 16 字节随机数后 hex 编码,作为令牌唯一标识,用于黑名单精确失效。
  4. 鉴权中间件

    • JWTAuthMiddleware 拦截受保护路由,白名单(注册、登录、密保问题查询等)直接放行。
    • Authorization 头取 Bearer token,调用 TokenManager.VerifyToken 校验签名 + 过期 + 黑名单 + 令牌版本号
    • 校验通过后将 user_idusernametoken 写入 context.Context(使用自定义 key 类型防冲突),供后续 handler 读取。
  5. 登出 / 令牌撤销

    • service.Logout 从 context 取出当前 token → biz.LogoutTokenManager.BlacklistToken,把 JTI 写入 Redis(blacklist:token:{jti},TTL = access token 有效期)。
    • 「登出全部设备」则 UPDATE users SET token_version = token_version + 1,使所有已签发令牌(含 refresh)集体失效。
  6. 密码管理

    • 在线修改密码:先比对旧密码,再哈希新密码,repo.UpdatePassword 仅更新 password 字段,并 BumpTokenVersion 让所有旧令牌失效。
    • 密保/邮箱重置密码:统一模糊错误防枚举;密保答案带失败计数锁定;邮箱重置走「发码 → 验码 → 改密」且验证码一次性、限时。
  7. 存储空间校准

    • 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有效期 自动清理" .-> C

JWT 无状态,登出后令牌仍然有效直到过期。登出场景JTI 为 key 将令牌写入 Redis,VerifyToken 时检查 blacklist:token:{jti} 是否存在。相比「服务端记录所有有效令牌」的方案,黑名单只在登出/封禁时写入,写多读少的负载下性能更优。TTL 取 access token 的有效期(本项目 tm.expire,24h),令牌自然过期后黑名单条目自动清理,避免内存膨胀。

⚠️ 黑名单的边界(务必记牢):黑名单只解决"当前 access token 登出失效"这一个场景

  1. 它只能让被拉黑的 access token 失效,对 refresh token 无效——因为 refresh token 走刷新接口时并不在黑名单里,且即使把 refresh token 的 JTI 也拉黑,黑名单 TTL(24h)也远小于 refresh token 的 7 天有效期,24h 后黑名单消失、refresh token 仍可用。
  2. 做不到"改密 / 封禁账号后让所有已签发令牌集体失效"——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 --> RT
  • accessToken:短期(默认 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 --> C

biz 层定义 UserRepoCacheLocker 接口,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. 读写分离

用户信息读多写少。可将 FindByIDFindByUsernameGetUserStorage 等读请求路由到 MySQL 只读从库,CreateUpdatePasswordBumpTokenVersionUpdateUsedStorageAtomic 等写请求走主库。注册后短暂延迟(主从同步)可通过「写后立即读主库」或缓存回写解决。

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 哈希 + 唯一索引 + 用户名/邮箱校验 + 邮箱激活)

实现思路

  1. service 层接收 RegisterRequest,透传给 biz 层。
  2. biz 层做参数校验:用户名格式/长度/白名单密码强度策略邮箱格式 + 唯一性
  3. 第一道防线:FindByUsername 查重,已存在直接返回 ErrUserAlreadyExists
  4. bcrypt.GenerateFromPassword 哈希密码(Cost=12);昵称为空时默认用用户名;默认分配 10GB 存储配额;token_version 初始 0;email_verified=0
  5. 第二道防线:repo.Create 入库,若返回冲突错误(MySQL 1062,含 username/email 唯一索引)再次转成对应错误。
  6. 入库成功后发送邮箱验证邮件(签名 + 时效 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 用户登录(双令牌签发 + 账号锁定 + 防枚举)

实现思路

  1. 先查账号锁定(locked_until > now)→ 被锁直接拒绝。
  2. 参数校验:用户名/密码非空。
  3. FindByUsername 查库;用户不存在与密码错误统一返回 ErrInvalidCredentials(防账号枚举)。
  4. 校验账号状态 Status == 1email_verified == 1(未激活禁止登录)。
  5. bcrypt.CompareHashAndPassword 比对密码哈希,失败按「账号 + IP」累加失败计数,超限锁定 15 分钟;成功清零。
  6. TokenManager.GenerateAccessToken(含 user_id/username/token_version)。
  7. TokenManager.GenerateRefreshToken(含 userID/token_version)。
  8. 返回用户信息、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]

实现思路

  1. 中间件构造时接收白名单,命中白名单直接放行。
  2. transport 取出 Authorization 头,解析 Bearer 前缀得到 token。
  3. 缺失 token 返回 MISSING_TOKEN
  4. TokenManager.VerifyToken 校验:签名算法必须是 HMAC、签名正确、未过期、未在黑名单、版本号与库一致(fail-closed,见下)。
  5. 校验通过后将身份写入 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 用户登出(黑名单 + 登出全部设备)

实现思路

  1. service.Logout 从 context 取出当前 token(中间件注入)。
  2. 透传给 biz.LogoutTokenManager.BlacklistToken,把 JTI 写入 Redis(blacklist:token:{jti},TTL = access token 有效期)。
  3. 之后该令牌再被 VerifyToken 校验时,IsBlacklisted 返回 true,拒绝访问。
  4. 「登出全部设备」则调用 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 换取新的双令牌,避免用户重新登录。

实现思路

  1. refresh token 也走 VerifyToken 校验(签名 + 过期 + 黑名单 + 版本号),因此天然享受上述所有撤销能力。
  2. Subject 解析 userID,再查库确认用户仍存在且未禁用。
  3. 修正点(源码 bug):刷新时必须查库取 usernameGenerateAccessToken(userID, username, version),否则新 access token 的 Subject/Username 为空,依赖 CtxUsername 的逻辑会出错。
  4. 同时签发新的 access 与 refresh token(滑动续期),两者都带最新 token_version
  5. 进阶:若把 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 密码管理(改密自增版本号 + 密保重置防枚举/防爆破)

实现思路

在线修改密码(已登录状态):

  1. 从 context 取 user_id
  2. 校验新旧密码非空、新密码满足强度策略。
  3. FindByID 查用户,bcrypt.CompareHashAndPassword 验证旧密码。
  4. 哈希新密码,repo.UpdatePassword 仅更新 password 字段,BumpTokenVersion 让所有旧令牌失效(含其他设备)。

密保重置密码(未登录状态):

  1. GetSecurityQuestion 返回密保问题——注意:对用户是否存在只返回模糊结果,不暴露账号是否注册。
  2. 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.GetUserInforepo.FindByID,返回脱敏后的 UserInfotoUserInfo 不含密码、密保答案)。

存储统计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-PolicyX-Content-Type-Options: nosniffReferrer-Policy;移除已废弃的 X-XSS-ProtectionCache-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[从密钥管理读取强随机密钥]

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 为什么 Kratos 项目里 biz 层只定义 UserRepo/Cache/Locker 接口,而真正的 GORM/Redis 实现放在 data 层?这样做换存储(比如 PostgreSQL)时改哪里?
  2. 注册时 biz 层已经先 FindByUsername 查重了,为什么并发下仍可能两个请求都通过?最终靠什么兜底?MySQL 返回的错误号是多少?商用版还通过什么字段进一步防滥用(邮箱唯一 + 激活)?
  3. accessTokenrefreshToken 为什么要分开?各自默认有效期、携带的声明、泄露后的风险窗口分别是什么?refresh token 的撤销靠什么机制?
  4. 黑名单(JTI)能解决什么、不能解决什么?为什么它对 7 天的 refresh token 只能封 24h?“改密后让所有旧令牌失效"靠哪套机制?请画一张版本比对流程图。
  5. UpdateUsedStorageAtomic 在「减少空间」时为什么要在 WHERE 里加 used_storage >= ?RowsAffected == 0 时能区分哪两种情况?
  6. 为什么 JWT 密钥不能写默认值?生产化怎么做(fail-fast)?请画启动校验流程图。
  7. RefreshToken 里如果 GenerateAccessToken(userID, "") 传空 username 会导致什么具体问题?正确的改法是什么?
  8. 当前限流方案在"多实例部署"和"防单账号撞库"两个场景上分别有什么缺陷?生产化应怎么改(分布式限流 + 账号锁定 + 验证码)?
  9. 黑名单校验在 Redis 故障时应该"fail-open"还是"fail-closed”?这两种选择各有什么利弊,你倾向于哪种?为什么版本号比对不能走本地缓存?
  10. 防账号枚举在哪些接口要特别注意(登录、密保重置、邮箱找回)?统一模糊错误 + 失败锁定是怎么配合的?

动手练习(建议真做一遍)

  1. openssl 或在线工具把一段 JWT 的 Header/Payload 做 base64url 解码,肉眼验证三段结构,再改一个字符看签名如何校验失败。
  2. 起一个本地 Redis,写 20 行 Go 代码模拟「登出 → 写入 blacklist:token:{jti} → 再次 IsBlacklisted 返回 true」的最小流程。
  3. Register 的 biz 层代码复制到本地,故意去掉「数据库唯一索引兜底」,用两个 goroutine 并发注册同一用户名,观察是否产生重复记录(验证并发安全的必要性)。
  4. users 表加 token_version 字段,实现"改密即让所有旧令牌失效":写一段 RefreshToken 的改造代码,校验时比对 claims.TokenVersion 与库里当前值,并用 Mermaid 画出改密后旧令牌被拒的时序图。
  5. 用 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 原子扣减能力。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!