学习目标
学完本文你应该能够:
- 说清楚 JWT 的三段式结构(header.payload.signature)和 HS256 签名校验原理,能手画 Access Token 与 Refresh Token 的职责边界。
- 解释云盘项目里「令牌版本号 tv 自增」与「Redis 黑名单按 JTI」两套吊销方案各自解决什么场景,以及为什么黑名单要 fail-closed、空密钥要 fail-fast。
- 把 bcrypt 的「加盐 + 自适应 cost」讲给面试官听,并且能对比 MD5/SHA 为什么不能用;能解释 cost=12 在本项目里的取舍。
- 完整复述「防爆破状态机」「防账号枚举模糊错误」「CORS 必须排在 JWT 之前」三个高频考点,最好能结合源码函数名作答。
- 讲明白分享访问令牌为什么用 HMAC-SHA256 + 15 分钟短时效,以及文件上传时
sanitizeFileName与危险扩展名黑名单如何挡住路径穿越和可执行文件。
前置知识:Go 基础语法、Kratos 框架的 middleware 链与 context.Context、Redis 基本用法、HTTP 认证头与 CORS 预检机制。建议先写过一个简单的登录接口再读本文。
本章动手 3 件:
- 在本地把
TokenManager.VerifyToken跑一遍,故意让 Redis 不可用,观察是否返回ErrTokenExpired(验证 fail-closed)。 - 把
auth.jwt_secret配成空字符串启动服务,确认进程直接log.Fatalf退出(验证 fail-fast)。 - 用
GenerateRefreshToken造一个不含 username 的 refresh token,走一遍RefreshToken看它如何回库补全username与version。
面试问答
Q1. 什么是 JWT?为什么云盘项目要用「Access Token 24h + Refresh Token 7d」双令牌?
答:
先打个生活类比。JWT 就像一张盖章的「电子门禁卡」:卡面上写着你的工号(payload),卡里有钢印(签名)。保安(服务端)不用联网查数据库,只要对照母印章(密钥)就能判断这张卡是不是我们发的、有没有被篡改。这就是「无状态认证」——服务端不需要存会话。
JWT 三段结构是 header.payload.signature,用两个点号连接:
- header:算法与令牌类型,例如
{"alg":"HS256","typ":"JWT"},Base64 编码后成为第一段。 - payload:业务自定义声明,本项目里放了
user_id、username、tv(令牌版本号)以及标准声明里的exp、jti等。 - signature:用密钥对「前两段Base64 + 点号」做 HMAC-SHA256 得到的签名,用来防篡改。
// internal/biz/auth.go
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(tm.secret)
为什么分两个令牌?看本项目的实际配置:
func NewTokenManager(c *conf.Auth) *TokenManager {
expire := 24 * time.Hour
refreshExpire := 7 * 24 * time.Hour // 7 days
...
}
- Access Token(24 小时):短命,随每个请求带上,一旦泄露窗口小。但它有效期长也不行——用户改密后旧 token 还能用一天,风险大。
- Refresh Token(7 天):只在「换发新 access」时用到,存在客户端(比如 httpOnly cookie 或本地存储)。它不进每个 API 请求,泄露面更小;而且本项目靠「令牌版本号 tv 自增」能在改密后让所有 refresh 一起失效(见 Q3)。
⚠️ 新手坑:Refresh Token 如果也像 Access 一样每次请求都带,那它和 Access 就没区别了,双令牌设计就失去意义。Refresh 只在
/refresh接口使用。
下面这张时序图串起登录拿到双令牌的完整过程:
sequenceDiagram
participant C as 客户端
participant API as Login 接口
participant UC as UserUsecase.Login
participant DB as 数据库
participant TM as TokenManager
C->>API: POST /login (username,password)
API->>UC: Login(ctx,username,password)
UC->>DB: FindByUsername
DB-->>UC: User(含 TokenVersion)
UC->>UC: bcrypt.CompareHashAndPassword
alt 密码错误
UC->>UC: incLoginFail 累加计数
UC->>DB: 计数达5次 则 LockAccount 15min
UC-->>API: ErrInvalidCredentials
else 密码正确
UC->>UC: resetLoginFail 清空计数
UC->>TM: GenerateAccessToken(用户ID,用户名,tv)
UC->>TM: GenerateRefreshToken(用户ID,tv)
TM-->>UC: accessToken / refreshToken
UC-->>API: 双令牌
end
API-->>C: 返回 access + refreshQ2. Claims 里为什么放 user_id / username / tv / JTI?Refresh Token 只含 userID 不含 username 有什么坑?
答:
还是门禁卡的比喻:卡面(payload)上写什么信息,决定了保安能直接读到什么,而不用每次跑去人事部(数据库)问。internal/biz/auth.go 里的 Claims 结构是这样定义的:
type Claims struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
TokenVersion int64 `json:"tv"`
jwt.RegisteredClaims
}
四个字段各有用途:
- user_id:业务主键,下游查数据都用它。
- username:很多接口要根据用户名展示或做权限判断,放进 token 可以少查一次库。本项目用自定义
ctxKey类型把它塞进context:
// internal/server/middleware.go
ctx = context.WithValue(ctx, ctxKeyUserID, claims.UserID)
ctx = context.WithValue(ctx, ctxKeyUsername, claims.Username)
- tv(TokenVersion 令牌版本号):这是吊销机制的核心。改密或封禁时
BumpTokenVersion让 DB 里的版本号 +1,旧 token 的tv对不上就失效(详见 Q3)。 - JTI(JWT ID):
generateJTI()用crypto/rand生成的 16 字节随机值,作为令牌的唯一指纹,黑名单按它维度存储。
关键点:Refresh Token 的坑。 看 GenerateRefreshToken 的实现——它故意不塞 username:
func (tm *TokenManager) GenerateRefreshToken(userID uint64, tokenVersion int64) (string, error) {
claims := &Claims{
UserID: userID,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID), // 只放 userID
...
},
}
}
设计意图是「refresh token 越精简越安全,用户名这种可辨识信息不暴露」。但这就埋了一个坑:用 refresh 换新 access 时,如果不回库,新签发的 access token 会缺少 username,导致依赖 CtxUsername 的逻辑全部拿不到用户名。
本项目在 RefreshToken 里做了修复——先 FindByID 回库把 username 和最新 version 补全:
// internal/biz/user.go —— RefreshToken
user, err := uc.repo.FindByID(ctx, userID)
if err != nil {
return "", "", 0, ErrUserNotFound
}
// 商用标准修复:刷新时必须查库补全 username 与最新版本号,
// 否则新签发的 access token 身份/版本缺失,依赖 CtxUsername 的逻辑会出错。
accessToken, err := uc.token.GenerateAccessToken(user.ID, user.Username, user.TokenVersion)
⚠️ 新手坑:很多人以为 refresh 换新 token 是纯本地 JWT 操作,结果漏了回库一步,上线后发现「刷新后用户名丢了、头像不显示」。记住——refresh 是校验旧凭证、发新凭证,新凭证的身份信息必须来自 DB 当前值,而不是旧 refresh token 里残缺的字段。
Q3. 令牌怎么吊销?令牌版本号自增 vs Redis 黑名单,怎么取舍?
答:
吊销的本质问题是:JWT 本身无状态、服务端不存,那怎么让一张「还合法的卡」提前作废?本项目用了两套互补方案。
方案一:令牌版本号 tv 自增(全局、跨设备失效)
User 对象上有 TokenVersion int64 字段,初始为 0。VerifyToken 在每次校验时都去 DB 比对当前版本:
// internal/biz/auth.go —— VerifyToken
if tm.tvChecker != nil {
current, verr := tm.tvChecker.GetTokenVersion(ctx, claims.UserID)
if verr != nil {
return nil, ErrTokenExpired
}
if claims.TokenVersion != current {
return nil, ErrTokenExpired
}
}
而「改密」「封禁」「一键下线所有设备」都会调 BumpTokenVersion 让版本号 +1。看这两处:
// internal/biz/user.go
func (uc *UserUsecase) LogoutAllDevices(ctx context.Context, userID uint64) error {
return uc.repo.BumpTokenVersion(ctx, userID)
}
func (uc *UserUsecase) UpdatePassword(...) error {
...
return uc.repo.BumpTokenVersion(ctx, userID) // 改密后所有旧令牌(含 refresh)集体失效
}
特点:一次写、全部失效,覆盖面广,不需要遍历每个 token。缺点是要查一次 DB,且只能做到「全有或全无」,不能精确踢掉某一台设备。
方案二:Redis 黑名单按 JTI(精准、单令牌失效)
普通的「退出登录」只需要让当前这张卡作废,没必要让其他设备掉线。于是用 JTI 当指纹写进 Redis:
// internal/biz/auth.go
func (tm *TokenManager) BlacklistToken(ctx context.Context, tokenStr string) error {
jti, err := extractJTI(tokenStr)
if err != nil || jti == "" {
return nil
}
return tm.cache.Set(ctx, tm.blacklistPrefix+jti, "1", tm.expire) // TTL = access 过期时间
}
VerifyToken 命中黑名单就拒绝。黑名单的 TTL 正好等于 access token 的过期时间——等 token 自然过期,黑名单条目也自动消失,不会无限堆积。
取舍结论:版本号解决「我要让这个用户所有会话立刻失效」(安全事件、改密);黑名单解决「我就退出这一台设备」。两者不冲突,项目里是同时启用的。
下面这张图对比两套方案的触发点与判定路径:
flowchart TD
A[用户登出 或 改密 或 封禁] --> B{吊销范围}
B -->|单设备单令牌| C[Logout: BlacklistToken
按 JTI 写入 Redis
TTL=Access 过期]
B -->|全部设备全部令牌| D[LogoutAllDevices/UpdatePassword
BumpTokenVersion 版本号加1]
C --> E[VerifyToken 命中黑名单
返回 ErrTokenExpired]
D --> F[VerifyToken 比对 claims.tv
与 DB 当前版本不一致
返回 ErrTokenExpired]Q4. 黑名单校验为什么要 fail-closed(宁可拒绝)?空密钥为什么要 fail-fast?
答:
这两个都是「安全默认」的设计哲学:宁可把正常用户挡在门外,也不能放一个危险请求进来。
fail-closed(黑名单校验)
看 VerifyToken 里这段:
if tm.cache != nil {
blacklisted, berr := tm.IsBlacklisted(ctx, tokenStr)
if berr != nil {
log.Printf("blacklist check failed: %v", berr)
return nil, ErrTokenExpired // Redis 挂了,宁可拒绝
}
...
}
如果 Redis 故障、IsBlacklisted 报错,代码不是「当没黑名单放行」,而是直接返回 ErrTokenExpired。为什么?因为黑名单的语义是「这张卡已经被用户主动登出 / 已经被封禁」。如果因为 Redis 抖动就放行,那么一个本该失效的 token 反而能继续访问,攻击者只要让 Redis 抖一下就能绕过登出。代价只是 Redis 故障时所有用户暂时登出(可用性下降),但安全性保住了。这就是 fail-closed:故障朝「拒绝」方向倒,而不是朝「放行」方向倒。
同理,tv 比对那里 Redis/DB 出错也返回 ErrTokenExpired,逻辑一致。
fail-fast(空密钥拒绝启动)
NewTokenManager 在配置里 jwt_secret 为空时直接自杀:
if secret == "" {
log.Fatalf("auth.jwt_secret is required and must be strong; refusing to start with an empty token")
}
NewShareUsecase 也一样:
if secret == "" {
stdlog.Fatalf("auth.jwt_secret is required to sign share access tokens; refusing to start with an empty secret")
}
为什么不用「给个默认值」?因为 JWT 的签名密钥一旦是写死在代码里的默认值(比如 "secret"),攻击者拿到你的二进制或源码就能伪造任意用户的 token,整个认证体系形同虚设。所以商用项目里密钥必须由运维通过环境变量/密钥管理注入,缺失就拒绝启动,绝不允许「能跑起来就行」。
⚠️ 新手坑:fail-fast 和 fail-closed 容易混。fail-fast 是「启动期发现配置不对,立刻崩溃不提供服务」;fail-closed 是「运行时依赖(Redis)故障,安全相关判定默认拒绝」。一个发生在进程生命起点,一个发生在请求处理途中。
Q5. 密码怎么存的?为什么用 bcrypt 而不是 MD5/SHA?cost=12 怎么选?
答:
先说结论:本项目密码用 bcrypt 哈希存储,cost=12,源码里两处注册/改密/重置都写死 12:
// internal/biz/user.go —— Register
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
为什么不用 MD5/SHA? 这是面试必考点。MD5/SHA 是「快哈希」,设计目标是算得快。但攻击者破解口令用的也是「算得快」——他们拿彩虹表、GPU 集群每秒能跑几十亿次 MD5。给口令加盐只能挡彩虹表,挡不住暴力穷举。
bcrypt 的原理是「加盐 + 自适应 cost」:
- 它内部用随机盐(每次哈希盐都不同),所以相同密码两次哈希结果也不同,无法用彩虹表反查。
- 它的核心是「密钥派生函数」,故意把计算做得慢且吃内存,并且通过
cost参数控制迭代次数(cost=12 表示 2^12 次轮转)。攻击者的暴力破解成本被同步放大。
bcrypt.CompareHashAndPassword 在登录时用来校验:
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
// 密码错误
}
cost=12 的取舍:cost 越高越安全,但每次登录要多算几百毫秒。本项目选 12 是在「安全性」和「登录延迟」之间的工程折中——云盘登录不是高频操作,单次多花几十毫秒可以接受;12 在 2025 年的硬件条件下对离线暴力破解仍有足够抵抗力。如果将来算力普遍提升,可以把 cost 提到 13/14,老密码在下次登录时按新 cost 重新哈希即可(bcrypt 哈希串里自带 cost 值)。
⚠️ 新手坑:不要在代码里用
md5.New()然后拼接salt+password就当安全哈希,那只是「看起来加了盐的快哈希」,挡不住 GPU 穷举。存储密码请用 bcrypt / argon2 / scrypt 这类慢哈希,本项目选 bcrypt 是稳妥之选。
Q6. 怎么防爆破?登录失败计数 + 锁账号的状态机是怎样的?
答:
生活类比:小区门禁输错 5 次就冻结 15 分钟,防止小偷挨个试密码。本项目用「缓存计数 + 超限锁账号」实现,常量定义很清晰:
// internal/biz/user.go
const (
loginFailKeyPrefix = "cloud-disk:login_fail:"
loginFailTTL = 15 * time.Minute // 失败计数与锁定时长一致
loginFailMax = 5 // 失败次数上限
)
登录失败时的逻辑:
if err := bcrypt.CompareHashAndPassword(...); err != nil {
uc.incLoginFail(ctx, username) // 计数+1
if uc.loginFailCount(ctx, username) >= loginFailMax {
_ = uc.repo.LockAccount(ctx, username, loginFailTTL) // 锁 15 分钟
}
return nil, "", "", 0, ErrInvalidCredentials
}
// 成功后清空失败计数
uc.resetLoginFail(ctx, username)
有两个工程要点面试官爱追:
- 为什么用缓存(Redis)而不是 DB 计失败次数? 因为失败计数是「高频、可丢弃、短时」的数据,用 Redis 不拖慢主库,且 TTL 让它能自动过期清零。DB 里只存「是否锁定」这种需要持久化的状态(
LockedUntil字段)。 - 为什么注释写「非严格原子,容忍并发误差」?
incLoginFail是「读-改-写」三步,没有用INCR原子命令:
func (uc *UserUsecase) incLoginFail(ctx context.Context, username string) {
val, _ := uc.cache.Get(ctx, key)
n, _ := strconv.Atoi(val)
n++
_ = uc.cache.Set(ctx, key, strconv.Itoa(n), loginFailTTL)
}
高并发下计数可能少算几次。但这是有意的取舍:防爆破本来就是「模糊防御」,少算几次顶多让用户多试一两回,不会造成安全绕过;而为了严格原子去加锁反而拖性能。安全边界由 LockAccount 兜底,不是靠精确计数。
下面这张状态机覆盖从正常到锁定的完整流转:
stateDiagram-v2
[*] --> 正常: 账号未锁定
正常 --> 正常: 登录成功 resetLoginFail
正常 --> 失败计数: 密码错误 incLoginFail
失败计数 --> 失败计数: 再次错误 计数加1
失败计数 --> 锁定: 计数达5次 调 LockAccount
锁定 --> 锁定: 15分钟内请求被拒
锁定 --> 正常: 15分钟过期 自动恢复
锁定 --> 正常: 用户找回密码重置Q7. 怎么防账号枚举?注册、登录、找回密码、密保问题各怎么处理?
答:
账号枚举(user enumeration)指攻击者通过「返回的错误信息差异」来猜哪些用户名/邮箱是真实注册过的。比如「用户名不存在」和「密码错误」如果分开返回,攻击者批量试就能画出一份「已注册用户清单」去撞库。本项目在多处统一成模糊错误:
- 登录:用户名不存在、密码错误、账号禁用,统一返回同一个
ErrInvalidCredentials(「用户名或密码不正确」):
// internal/biz/user.go —— Login
if errors.IsNotFound(err) {
return nil, "", "", 0, ErrInvalidCredentials
}
...
if user.Status == 0 {
return nil, "", "", 0, errors.Forbidden("USER_DISABLED", "账号已被禁用")
}
注:禁用的 case 返回
USER_DISABLED是本项目一个可被追问的点——严格防枚举时禁用也应混进同一个模糊错误。面试时可以坦承这是「安全与体验的取舍」,并讨论如何改进。
- 找回密码(ResetPassword):无论用户是否存在、是否设了密保,都走同一条错误
ErrSecurityAnswerMismatch:
user, err := uc.repo.FindByUsername(ctx, username)
if err != nil || user.SecurityAnswer == "" || user.SecurityQuestion == "" {
return ErrSecurityAnswerMismatch // 不区分「不存在」与「未设密保」
}
这样攻击者无法用「密保答案错误」和「用户不存在」的差异来探测账号。
- 获取密保问题(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
}
- 注册:这里反而要告诉用户「用户名已存在」以便正常注册流程,但本项目用数据库唯一索引兜底,属于「可用性优先」场景,攻击面小(注册接口本身还有验证码/限流防护,见 Q11)。
核心思路一句话:任何涉及「凭证是否正确 / 账号是否存在」的查询接口,对外只暴露一种模糊结果,把确定性判断留在服务端内部。
Q8. 密码策略有哪些?用户名有什么限制?
答:
这是「注册入口的第一道防线」。本项目在 Register 里做多重校验,函数名 passwordPolicyOK 很直白:
// internal/biz/user.go
func passwordPolicyOK(password string) bool {
if len(password) < 10 {
return false
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, c := range password {
switch {
case unicode.IsUpper(c): hasUpper = true
case unicode.IsLower(c): hasLower = true
case unicode.IsDigit(c): hasDigit = true
case unicode.IsPunct(c) || unicode.IsSymbol(c): hasSpecial = true
}
}
return hasUpper && hasLower && hasDigit && hasSpecial
}
策略是:长度 ≥ 10 位,且必须同时包含大写字母、小写字母、数字、特殊字符。这样把口令空间拉到足够大,暴力破解在现实算力下不可行。
用户名则限制格式 + 封禁保留词:
var (
usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
reservedWords = map[string]bool{"admin": true, "root": true, "system": true, "test": true}
)
...
if !usernameRegex.MatchString(username) {
return nil, errors.BadRequest("USERNAME_INVALID", "用户名需为3-32位字母、数字或下划线")
}
if reservedWords[strings.ToLower(username)] {
return nil, errors.BadRequest("USERNAME_RESERVED", "该用户名不可注册")
}
为什么封 admin/root/system/test?因为这些是攻击者最爱猜的管理员/系统账号名,禁止普通用户注册能从源头减少「撞库命中高权限账号名」的概率。邮箱也做了 RFC 简化版正则 + 长度 128 上限校验。
⚠️ 新手坑:密码策略写在业务层还不够,前端也要提示用户;更重要的是——策略再强,存储必须用 bcrypt(Q5),否则「强密码 + 明文存储」一样一锅端。两者是搭档,不是替代。
Q9. 分享访问令牌怎么设计的?HMAC-SHA256、15 分钟、base64(uuid.exp.sig) 是什么?
答:
云盘的核心是「分享」:A 把文件链接发给 B,B 不用登录就能看。这里的安全挑战是:如果只靠分享的 UUID 当钥匙,那 UUID 一旦泄露(比如被搜索引擎爬到、或猜测),任何人都能绕过提取码和密码直接下载。本项目用短期 HMAC 签名访问令牌堵这个洞。
签发函数 signShareToken:
// internal/biz/share.go
func (uc *ShareUsecase) signShareToken(shareUUID string) (string, error) {
exp := time.Now().Add(uc.shareTokenTTL).Unix() // shareTokenTTL = 15 * time.Minute
payload := fmt.Sprintf("%s.%d", shareUUID, exp)
mac := hmac.New(sha256.New, []byte(uc.shareSecret))
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
raw := fmt.Sprintf("%s.%s", payload, sig)
return base64.RawURLEncoding.EncodeToString([]byte(raw)), nil
}
拆解:
- HMAC-SHA256:用
auth.jwt_secret当密钥,对uuid.exp做带密钥的签名。没有密钥的第三方无法伪造sig,所以改不了 uuid 也改不了过期时间。 - 短时效 15 分钟:
shareTokenTTL = 15 * time.Minute。访问分享时AccessShare先校验提取码/密码,通过才签发这个短期令牌返回给客户端;后续GetShareDetail/SaveShare必须带这个令牌。 - 格式
base64(uuid.exp.sig):把三段拼成字符串再 base64,方便塞进 URL 或请求头。
校验函数 verifyShareToken 是「先验签、再验时效」:
func (uc *ShareUsecase) verifyShareToken(token string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
parts := splitToken(string(raw))
if len(parts) != 3 {
return "", ErrShareInvalidToken
}
uuid, expStr, sig := parts[0], parts[1], parts[2]
mac := hmac.New(sha256.New, []byte(uc.shareSecret))
mac.Write([]byte(fmt.Sprintf("%s.%s", uuid, expStr)))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) { // 用 hmac.Equal 防时序攻击
return "", ErrShareInvalidToken
}
exp, _ := strconv.ParseInt(expStr, 10, 64)
if time.Now().Unix() > exp {
return "", ErrShareExpired
}
return uuid, nil
}
注意 hmac.Equal 而不是 ==,这是防止时序攻击(攻击者通过响应时间差异推断签名是否逐字节匹配)。
最关键的设计在 GetShareDetail:先 verifyShareToken 验签,再 FindByUUID 查库并强制校验状态与过期。这意味着「光凭裸 UUID 直接调 GetShareDetail」这条路被堵死了——你必须先过提取码/密码拿到短期令牌,而且令牌本身也带签名和时效。
func (uc *ShareUsecase) GetShareDetail(ctx context.Context, token string) (...) {
shareUUID, err := uc.verifyShareToken(token) // 先验签与时效
if err != nil {
return nil, nil, nil, err
}
share, err := uc.shareRepo.FindByUUID(ctx, shareUUID)
...
if share.Status == 0 { return nil, nil, nil, ErrShareCancelled }
if share.ExpireAt != nil && time.Now().After(*share.ExpireAt) { return nil, nil, nil, ErrShareExpired }
...
}
下面这张图串起「访问分享 → 拿短期令牌 → 取详情」的签发与校验链路:
sequenceDiagram
participant G as 访客
participant AS as AccessShare
participant SR as ShareRepo
participant TM as signShareToken
participant GD as GetShareDetail
G->>AS: 访问分享链接(UUID,提取码,密码)
AS->>SR: FindByUUID
AS->>AS: 校验状态/过期/提取码/密码
AS->>TM: signShareToken(UUID)
TM-->>AS: base64(UUID.exp.HMAC)
AS-->>G: 返回短期访问令牌
G->>GD: GetShareDetail(访问令牌)
GD->>GD: verifyShareToken 先验签名与时效
GD->>SR: FindByUUID(UUID)
GD-->>G: 返回文件详情Q10. 文件上传怎么防攻击?sanitizeFileName 和危险扩展名黑名单做了什么?
答:
文件上传是 Web 安全重灾区,典型攻击两类:路径穿越(用 ../../etc/passwd 把文件写到系统目录)和上传可执行文件(传个 .php/.sh 然后远程触发执行)。本项目在 biz 层用两个函数挡住:
1. sanitizeFileName —— 净化文件名
// internal/biz/file.go
func sanitizeFileName(name string) string {
name = filepath.Base(name) // 只取文件名,剥掉任何路径前缀
name = strings.NewReplacer(
"\x00", "", "../", "", "..\\", "",
"<", "", ">", "", ":", "",
"\"", "", "|", "", "?", "",
"*", "",
).Replace(name)
if len(name) > 255 { // 限长 255
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
maxBaseLen := 255 - len(ext)
if maxBaseLen > 0 {
name = base[:maxBaseLen] + ext
} else {
name = name[:255]
}
}
if name == "" || name == "." || name == ".." {
name = "unnamed_file"
}
return name
}
它做了四件事:用 filepath.Base 砍掉路径只留文件名;把 ../、..\、空字节、< > : " | ? * 这些危险/非法字符清空;超长截断到 255;最后若是空//./..兜底成unnamed_file。SecUpload、InitUpload、CreateFolder、Rename` 都先过这一道。
2. isDangerousFileType —— 危险扩展名黑名单
var dangerousExtensions = map[string]bool{
".exe": true, ".bat": true, ".cmd": true, ".com": true,
".sh": true, ".bash": true, ".ps1": true, ".vbs": true,
".php": true, ".jsp": true, ".asp": true, ".aspx": true,
".py": true, ".pl": true, ".rb": true, ".cgi": true,
".so": true, ".dll": true, ".dylib": true, ".app": true,
".msi": true, ".scr": true, ".jar": true, ".war": true,
}
func isDangerousFileType(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
return dangerousExtensions[ext]
}
这是黑名单(列出明确禁止的),在 SecUpload 和 InitUpload 里都校验,命中直接返回 FILE_TYPE_FORBIDDEN:
fileName = sanitizeFileName(fileName)
if isDangerousFileType(fileName) {
return nil, perrors.BadRequest("FILE_TYPE_FORBIDDEN", "不允许上传此类型的文件")
}
⚠️ 新手坑:黑名单永远有「漏网之鱼」的风险(比如新的脚本类型、或者靠双扩展名
evil.jpg.php绕过)。更稳的做法是「白名单 + 存储层与执行环境隔离」:本项目把文件存到对象存储(不是 Web 根目录)、下载走带签名的临时 URL,即便有人传了可执行文件也无法被 Web 服务器直接执行。面试讲到这里能加分。
Q11. 中间件顺序为什么 CORS 必须排在 JWT 之前?限流中间件怎么识别真实客户端 IP?
答:
Kratos 的 middleware 是顺序链:请求从上往下穿过,前一个不调用 handler(ctx, req) 就短路。本项目自定义了 ctxKey 类型避免裸字符串 key 冲突:
// internal/server/middleware.go
type ctxKey string
const (
ctxKeyUserID ctxKey = "user_id"
ctxKeyUsername ctxKey = "username"
ctxKeyToken ctxKey = "token"
)
CORS 必须排在 JWT 之前的硬原因:浏览器发跨域请求时,对非简单请求(带自定义头 Authorization 的都算)会先发一个 OPTIONS 预检,这个预检请求不带 Authorization token。如果 JWT 中间件排在前面,预检请求因为没有 token 直接返回 401,浏览器就判定跨域失败,于是所有跨域接口全挂,连正常带 token 的请求都发不出去。
所以 CORSMiddleware 必须在链的最前面,遇到 OPTIONS 直接短路返回、不再进入 JWT:
// CORSMiddleware 注释原文:
// 必须排在 JWTAuthMiddleware 之前,否则浏览器 OPTIONS 预检不带 token 会被 JWT 直接 401,导致前端跨域全部失败。
...
if r.Method == http.MethodOptions {
return nil, nil // 预检短路,不再进入后续中间件
}
CORS 还有两个安全要点:绝不接受通配符星号与 credentials=true 共存,只对白名单可信源开凭据:
if origin == "" || allowSet[origin] {
h.ReplyHeader().Set("Access-Control-Allow-Origin", origin)
if allowSet[origin] {
h.ReplyHeader().Set("Access-Control-Allow-Credentials", "true")
}
...
}
因为一旦 Allow-Origin: * 又 Allow-Credentials: true,任意网站就能带用户凭据调你的接口,等于把登录态拱手送人。
限流中间件的真实 IP 识别:RateLimitMiddleware 基于 IP 做滑动窗口,关键是「取哪个 IP」。X-Forwarded-For 是客户端可伪造的(含多级代理链),所以本项目优先取反向代理基于真实连接填充的 X-Real-IP,XFF 仅作兜底且只取首跳:
clientIP = ht.Request().Header.Get("X-Real-IP")
if clientIP == "" {
if xff := ht.Request().Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.IndexByte(xff, ','); idx >= 0 {
clientIP = strings.TrimSpace(xff[:idx]) // 只取第一个(最原始)代理
} else {
clientIP = strings.TrimSpace(xff)
}
}
}
if clientIP == "" {
clientIP = ht.Request().RemoteAddr
}
另外它用「惰性清理」:每隔 window/2 才扫一遍 map 删过期条目,不用后台 goroutine,避免内存泄漏也省开销。
下面这张图说明 CORS 预检在中间件链里如何被前置处理:
flowchart TD
A[浏览器发起跨域请求] --> B{是否为 OPTIONS 预检}
B -->|是| C[CORSMiddleware 校验 Origin 是否在白名单]
C -->|白名单内| D[设置 Allow-Origin/Allow-Credentials
短路返回 不再进入 JWT]
C -->|不在白名单| E[不设置 CORS 头 请求被浏览器拦截]
B -->|否 普通请求| F[CORSMiddleware 设置响应头]
F --> G[JWTAuthMiddleware 校验 Bearer]
G --> H[业务 Handler]⚠️ 新手坑:限流如果信了
X-Forwarded-For全链,攻击者可以在最前面伪造一个「干净 IP」把自己伪装成新客户端绕过限流。永远信任离你最近的、由可信反向代理写入的 IP 源(X-Real-IP),而不是用户可控的头。
自测题与动手练习
自测题(5 道)
- Access Token 和 Refresh Token 在本项目里有效期分别是多少?Refresh Token 为什么故意不含 username,又为什么在
RefreshToken里要回库补全 username? VerifyToken在校验时做了哪两道「安全判定」?当 Redis 故障导致黑名单查询报错时,它返回什么错误?为什么这是「安全正确」的选择?NewTokenManager在jwt_secret为空时怎么做?如果它改成「用默认值继续启动」,会带来什么灾难性后果?- 防爆破的失败计数存在哪里(缓存还是 DB)?为什么注释里说「非严格原子、容忍并发误差」?
loginFailMax和loginFailTTL各是多少? - 分享访问令牌
signShareToken的格式是什么?GetShareDetail为什么必须「先验签再查库」,而不能仅凭裸 UUID 直接返回文件?
动手练习(3 个)
- 画吊销总览图:在一张纸上画出「改密 / 一键下线 / 单设备登出」三种场景分别走 tv 自增还是 JTI 黑名单,并标注各自失效范围。对照本文 Q3 的状态图检查。
- 本地复现 fail-closed:起一个带 Redis 依赖的测试,把 Redis 关掉再调一次
VerifyToken,确认返回ErrTokenExpired;然后用IsBlacklisted故意返回 nil 让 cache 为 nil,确认跳过黑名单校验(理解「cache==nil 时降级」与「cache 报错时拒绝」的区别)。 - 加固 CORS:找一段你自己的项目代码,检查
Access-Control-Allow-Origin是否出现了通配符星号与Allow-Credentials: true共存;如果有,改成白名单allowSet模式,并确认 OPTIONS 预检在 JWT 之前被短路。
本章小结
- 双令牌分层:Access(24h)随请求走、Refresh(7d)只在换发时用;Refresh 不含 username,但本项目在
RefreshToken回库补全,修复了「刷新后身份缺失」的坑。 - 吊销双方案:令牌版本号
tv自增做「全设备全局失效」(改密/封禁/一键下线),Redis 黑名单按 JTI 做「精准单令牌失效」(普通登出),TTL 等于 access 过期时间避免堆积。 - 安全默认:黑名单校验 fail-closed(Redis 故障宁可拒)、空密钥 fail-fast(
log.Fatalf拒绝启动),安全相关判定默认朝「拒绝」倒。 - 口令体系:bcrypt 加盐 + 自适应 cost=12 慢哈希,密码策略 ≥10 位且含四类字符,用户名限格式并封保留词;防爆破用缓存计数 + 5 次锁 15 分钟;防账号枚举靠模糊错误统一返回。
- 分享与文件安全:分享用 HMAC-SHA256 + 15 分钟
base64(uuid.exp.sig)短期令牌,GetShareDetail先验签再查库;上传用sanitizeFileName防路径穿越、dangerousExtensions黑名单挡可执行文件。 - 中间件链:CORS 必须排在 JWT 之前以正确处理 OPTIONS 预检,且绝不让通配符星号与凭据共存;限流优先信任
X-Real-IP防伪造 IP。
从「认证鉴权」过渡到「全链路安全」之后,下一站建议深入传输层与存储层加密(TLS 终止、对象存储签名 URL、静态数据加密),把「认证通过之后数据怎么安全流动」这一半拼图补全——那也是云盘类项目面试里紧接着的高频追问。
- 双令牌分层的核心逻辑:Access Token 短寿命(24h)用于请求鉴权,Refresh Token 长寿命(7d)仅用于换发;Refresh 不含 username 是为了减少 JWT payload 大小,但本项目在 RefreshToken 里回库补全以修复"刷新后身份缺失"的 bug。
- 吊销三档不同:tv 自增 → 全设备全局失效(改密/封禁);JTI 黑名单 → 精准单令牌失效(普通登出);Redis 故障时 fail-closed(宁可误拒不能放过)。
- 密码安全:bcrypt cost=12 是性能与安全的平衡点——太快容易被暴力破解,太慢影响用户体验;MD5/SHA 没有加盐、可彩虹表碰撞,绝不能直接用。
- 防爆破:缓存计数 + 5 次失败锁 15 分钟,容忍并发误差是因为精确原子不是必须的——多锁 15 秒 vs 少锁几秒对体验影响极小。
- 下一篇讲分布式锁——它和认证是"保护什么"vs"怎么保护"的关系,都是高并发场景下的基础设施。
JTI 黑名单:精准控制单个令牌失效,适合"用户主动登出"场景——只让这一个 token 作废,其他设备上的 token 仍有效。
tv 版本号自增:全局失效所有旧 token,适合"改密/封禁/一键下线"——一旦 tv 递增,所有旧 access token 都校验失败,因为 payload 里的 tv 值不匹配。
为什么不能只用一种:
① 只用黑名单:改密时要删掉库里所有用户的 token,DB 压力大且无法做到"瞬间全部失效"
② 只用 tv:无法精准删除某个特定 token(比如用户只想退出当前设备)
面试加分点:提到"黑名单要设 TTL = access token 有效期",否则 Redis 里会堆积大量过期条目浪费内存。