跳到正文
知识库 / Go 博客教程 / 第 6 课返回主站 ↗

Go 博客教程 · 第 6 课 / 10

第 6 课:用户注册登录与 JWT 认证

到现在为止,你的博客系统里任何人都能删任何文章posts.author_id 从第 3 课起就在 schema 里躺着,一直是 NULL。这一课把它填上。

这也是整套教程里唯一一课,写错了会造成真实损失。密码存储和 token 校验属于「跑起来了不代表是对的」的领域——一个能正常登录的系统,可能同时把全站密码送给了拖库的人。所以每一处安全设计我都会先说攻击场景,再说怎么防。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 5 课:中间件与错误下一课:第 7 课:迁移到 Gin →

① 本课目标

写出注册(bcrypt 哈希 + 唯一约束冲突友好化)、登录(时序攻击防护 + 签发 JWT)、受保护接口 /api/auth/meRequireAuthOptionalAuth 两个鉴权中间件,以及「只有作者能改自己的文章」这条授权规则。

并能回答:为什么 MD5 加盐存密码仍然是错的?为什么同一密码每次 bcrypt 结果不同却还能验证?JWT 的 payload 是加密的吗?alg: none 攻击 v5 帮我挡住了吗(答案有反转)?用户点了「退出登录」,他手上那个 token 还能用吗?

② 前置检查

cd ~/go-blog && go build ./... && go run ./cmd/server
# 另一个终端 —— 看到 X-Request-Id 说明第 5 课的中间件生效了
curl -i -s localhost:8080/api/posts | grep -i x-request-id
mysql -uroot -p123456 -e "DESC blog_dev.users;"

装依赖

go get golang.org/x/[email protected]
go get github.com/golang-jwt/jwt/v5
go list -m golang.org/x/crypto github.com/golang-jwt/jwt/v5
# golang.org/x/crypto v0.55.0
# github.com/golang-jwt/jwt/v5 v5.3.1

易错点:x/crypto 最新版会强制升级 Go 工具链。 直接 go get golang.org/x/crypto 拉到 v0.56.0,它声明 go >= 1.26.0。你的 Go 是 1.25.0,于是:

go: golang.org/x/[email protected] requires go >= 1.26.0; switching to go1.26.8
go: downloading go1.26.8 (darwin/arm64)
它会在后台下载一整套 Go 1.26 工具链(几百 MB),受限网络下还可能卡住或报 checksum database disabled。本课固定 @v0.55.0 —— 实测能在 Go 1.25 上直接编译的最新版,bcrypt 的 API 与新版完全一致。

⚠️ 必须用 jwt/v5,不要用 dgrijalva/jwt-go 搜「golang jwt」出来的大量中文教程仍在用它。这个库已废弃(作者 2021 年移交给 golang-jwt 组织),且带 CVE-2020-26160 —— aud 是字符串(而非规范允许的数组)时校验被直接跳过,可绕过受众限制。它的最后版本 v3.2.0 永远不会修

v4→v5 也是破坏性升级:StandardClaims 改名 RegisteredClaims,时间字段从 int64 变成 *jwt.NumericDateValid() 被移除。照抄 v4 教程在 v5 上编译不过。 本课所有代码都在 v5.3.1 上实际编译运行过。

本课新建:internal/{model/user,repository/user_repo,service/auth_service,handler/auth_handler,middleware/auth}.go

③ 核心概念

3.1 密码存储

前提:假设数据库最终会泄露。拖库、备份被下载、离职员工带走 dump——这不是小概率事件。密码存储的全部意义是:库泄露后,攻击者仍拿不到密码。

为什么重要?因为用户会复用密码。你的博客被拖库,泄露的不是「某人在你博客的密码」,是「某人的邮箱、支付宝密码」。你保护的是用户在别处的账户。

MD5 / SHA256 为什么不行

「哈希不可逆」在密码场景下是错的,两个原因:

① 彩虹表。 不可逆但可穷举。攻击者不需要反解 e10adc3949ba59abbe56e057f20f883e,只要事先算好 md5("123456") 存表,一查就中。覆盖数十亿常见密码的现成彩虹表可免费下载。

② 快,是致命缺陷。 这是最反直觉的一点:

哈希算法为校验文件完整性而设计,追求。密码哈希需要的恰恰相反——

一块 RTX 4090 每秒能算约 1000 亿次 MD5。8 位小写字母+数字共 36⁸ ≈ 2.8 万亿组合,不到半分钟跑完。SHA256 只慢几倍,同一数量级。

加盐能救吗? 加盐废掉了彩虹表(不能再预计算),但完全没解决「快」——攻击者针对单个用户逐个爆破,速度依旧每秒千亿次。加盐把「一次查表破全站」变成「每人单独爆破」,成本上升了,但对弱密码用户仍是几秒钟的事。结论:加盐的 SHA256 依然不够。

bcrypt

① 自适应 cost。 cost 是整数,迭代次数是 2^cost,每 +1 计算时间翻倍。我在 M 系列 Mac 实测:

cost=10 耗时 62ms
cost=12 耗时 251ms

「自适应」的含义:硬件会变快,但你可以调 cost 跟上。今天 cost=10 是 Go 默认值,五年后改成 12 就抵消了攻击者的算力增长。MD5 没有这个旋钮,只会越来越不安全。

反过来算:60ms 一次意味着攻击者单核每秒只能试约 16 次。就算有 1000 倍并行,也就每秒 1.6 万次——比 MD5 的每秒千亿次慢了 600 多万倍。8 位密码爆破时间从半分钟变成几百年。

cost 怎么选:在生产硬件上让单次 hash 落在 50–250ms。太低不安全;太高会让登录接口变成 DoS 靶子(每个登录占住一个核 250ms,几百并发就打满)。本课用 bcrypt.DefaultCost(=10)。

② 盐自动生成并内置在输出里。 不用自己管盐,也不用额外开一列。

③ 输出固定 60 字节 —— 这就是 schema.sqlCHAR(60) 的由来。

同一个密码 hunter2 哈希两次(实测):

hash1=$2a$10$KknLd/9FS5rJ/7pxhT.tq.819GlE9jgqgeElmFLDQC6a2DT7IU4ci
hash2=$2a$10$374VzKFov96amxLG.yx3NukkD3eNqdLx4n50sMZV0YL.DEWNUEQHe
两者相同 = false     长度 = 60

结果完全不同。 拆解就明白了:

$2a$10$KknLd/9FS5rJ/7pxhT.tq.819GlE9jgqgeElmFLDQC6a2DT7IU4ci
│ │  │  └──────────┬─────────┘└───────────┬──────────┘
│ │  │        盐 (22 字符)          实际哈希 (31 字符)
│ │  └── cost = 10
│ └── 算法版本 2a
└── 分隔符
总长 1+2+1+2+1+22+31 = 60 字节  ← CHAR(60) 正好装下

盐就明明白白写在输出中间。 所以:

  • 每次新随机盐 → 输出必然不同 → 两个用同一密码的用户,库里记录完全不同,攻击者无法通过「哈希相同」看出谁和谁密码一样;
  • 验证时 bcrypt 从你给的 hash 里读出盐和 cost,用同样参数重算再比对 → 不用单独存盐;
  • WHERE password_hash = ? 永远不可能命中。验证必须是「先按用户名查出 hash,再 CompareHashAndPassword」两步。新手常想用一条 SQL 直接查用户名+密码,那是明文时代的写法。

盐公开为什么没关系:盐的作用不是保密,是让预计算失效。攻击者拿到盐后才能开始算,而这时每秒只能算 16 次。盐防「批量」,cost 防「速度」,缺一不可。

Argon2 更好吗? 是,Argon2id 是 2015 年密码哈希竞赛冠军,额外有内存硬度(更抗 GPU/ASIC)。但它三个参数更难调对,调错反而更弱。bcrypt 用了 25 年、被审计无数次、只有一个旋钮,对初学者更稳妥。知道有这回事就行。

3.2 JWT:签名 ≠ 加密

┌─────────────────────────────────────────────────────────────────┐
│ HEADER     base64url({"alg":"HS256","typ":"JWT"})               │
│            ↑ 用什么算法签的(攻击者可改!)                          │
├─────────────────────────────────────────────────────────────────┤
│ PAYLOAD    base64url({"uid":7,"sub":"7","exp":1788719207})      │
│            ↑ claims:你是谁、何时过期                              │
│            ⚠️ 任何人都能解开看,这里【不是加密】                    │
├─────────────────────────────────────────────────────────────────┤
│ SIGNATURE  HMAC-SHA256(b64(header)+"."+b64(payload), secret)    │
│            ↑ 只有握着 secret 的人算得出来                         │
└─────────────────────────────────────────────────────────────────┘
   eyJhbGci...  .  eyJ1aWQiOjcs...  .  spdoHp333yKwbLGsjmW0i...
   └─ header ─┘    └─── payload ──┘    └───── signature ─────┘

base64url 不是加密 —— 最重要的一句话

前两段是 base64url 编码。编码不是加密:没有密钥,可逆,任何人都能解开。 第 ⑤ 节你会亲手验证。

推论:payload 绝对不能放敏感信息。 不能放密码、身份证、完整手机号、银行卡号、任何你不愿让用户自己看到的东西。可以放用户 ID、用户名、角色、过期时间——这些本来就是他自己知道的

攻击场景:你把手机号放进 payload 图方便。用户在公共电脑登录没清浏览器,或前端把 token 存 localStorage 而站点有 XSS——攻击者拿到 token,不需要任何密钥,直接 base64 解码就读到手机号。他甚至不需要 token 还在有效期内,三年前过期的 token 里手机号依然有效。

签名保证的是「没被篡改」

攻击者能看到 {"uid":7},也能改成 {"uid":1} 重新编码。但他算不出新签名——那需要服务端的 secret。服务端重算比对,对不上就拒绝。实测:tampered -> err=token signature is invalid

JWT 是一张「防伪但不遮挡」的身份卡。 上面的字所有人都看得见,但只有发卡方能造出有效的卡。

HS256 还是 RS256

HS256(对称) RS256(非对称)
密钥 一个 secret,签发校验都用它 私钥签发,公钥校验
谁能签发 任何能校验的服务 只有握私钥的那个
适用 单体:签发校验同进程 微服务:认证中心签发,N 个服务各自校验

本课用 HS256(单体应用)。选择标准:只要有第二个服务需要校验,就该上 RS256——因为 HS256 下「能校验」等价于「能伪造」,你把 secret 发给下游的那一刻,它就能签发任意身份了。

标准 claims

claim 含义 本课用法
sub 主体,这个 token 代表谁 用户 ID 的字符串形式
exp 过期时间(Unix 秒) 必填,签发后 2 小时
iat 签发时间 填,便于审计和「改密码后失效」
nbf 在此之前无效 填,一般等于 iat
iss 签发方 "blog",防误收别系统的 token
jti token 唯一 ID 第 9 课做黑名单用

自定义 claims 可随便加,但 payload 越大,每个请求的 header 越大——JWT 跟着每个请求发送,塞太多是实打实的带宽成本。

3.3 JWT 的固有缺陷

必须讲,因为大量教程把 JWT 吹成万能,然后你会在生产踩坑。

核心问题:JWT 无状态,服务端不存任何东西,校验靠签名自证。这是最大优点(无需查库、天然水平扩展),也是无法回避的缺陷:

服务端没有任何办法让一个已签发的 token 提前失效。

场景 你以为 实际
用户点「退出登录」 token 失效了 前端只是删了本地副本。攻击者若已复制走,照常能用到过期
用户改了密码 旧 token 该失效 旧 token 完全有效,直到 exp
管理员封禁用户 立即踢下线 不生效,直到自然过期
发现 token 泄露 吊销它 无法吊销

三种缓解手段:

① 缩短有效期(本课采用)。 15 分钟到 2 小时,危害窗口从几天压到几十分钟。代价是用户频繁重登。

② Refresh token(生产标配)。 短命 access token(15 分钟,无状态校验)+ 长命 refresh token(7 天,存数据库)。因为 refresh 有状态,它可以被吊销——登出/改密码时删库里那条,最多 15 分钟后彻底出不来。

③ 黑名单。 已吊销的 jti 存 Redis(TTL = token 剩余寿命),每次请求查一次。等于放弃无状态的好处,换来实时吊销。第 9 课用 Redis 实现。

还有个不花钱的技巧:把 password_changed_at 存进用户表,校验时 iat < password_changed_at 则拒绝。这样「改密码踢掉所有旧 token」不需要 Redis,代价是每次鉴权多一次用户查询(而我们本来就要查,基本免费)。留作练习 4。

什么时候别用 JWT:单体应用、用户量不大、需要精确会话控制(「同账号只能一处登录」「一键踢人」),传统 session + cookie 更简单也更正确。JWT 的价值在分布式和跨域。不要因为时髦就用。

3.4 认证 vs 授权

认证 Authentication 授权 Authorization
问题 你是谁? 你能干什么?
失败码 401 403
含义 「我不知道你是谁,请登录」 「我知道你是谁,但你不能这么干」
本课位置 RequireAuth 中间件 service 层的所有权检查

HTTP 历史遗留坑:401 的单词是 "Unauthorized",但语义是未认证;403 "Forbidden" 才是未授权。这个命名从 HTTP/1.0 就错了。记语义,别记单词。 判断:没带 token / token 无效 → 401。带了有效 token 但没权限 → 403。

④ 函数逐个精讲

4.0 模型与仓储(骨架)

// internal/model/user.go
type User struct {
    ID       uint64 `json:"id"`
    Username string `json:"username"`
    Email    string `json:"email"`
    // 【绝不能】导出。json:"-" 是双保险:即使有人手滑把整个 User 返回给客户端,
    // 哈希也不会泄露。泄露的后果是攻击者可【离线爆破】—— 不再受你的速率限制,
    // 可以用 GPU 集群慢慢跑。
    PasswordHash string    `json:"-"`
    CreatedAt    time.Time `json:"created_at"`
    UpdatedAt    time.Time `json:"updated_at"`
}

// internal/repository/user_repo.go
// TODO(练习1): 按第 4 课 post_repo.go 的套路补全。
// 注意 FindByEmail/FindByID 在"没找到"时返回 (nil, nil) 而非 error ——
// 对注册和登录来说,"用户不存在"是正常业务分支而不是故障。
type UserRepository interface {
    Create(ctx context.Context, u *model.User) error
    FindByEmail(ctx context.Context, email string) (*model.User, error)
    FindByID(ctx context.Context, id uint64) (*model.User, error)
}

4.1 Register —— 哈希与唯一约束冲突

先把本课新增的领域错误一次性定义好。注意它们是哨兵错误,不是 APIError——service 层不认识 HTTP,它只表达业务语义,翻译成状态码和错误码是第 4/5 课那两个函数的事:

// internal/service/errors.go —— 第 6 课新增
package service

import "errors"

var (
    ErrEmailTaken       = errors.New("该邮箱已被注册")
    ErrUsernameTaken    = errors.New("该用户名已被占用")
    ErrDuplicateAccount = errors.New("该账号信息已存在")
    ErrInvalidCred      = errors.New("用户名或密码错误")
    ErrWeakPassword     = errors.New("密码至少 8 个字符")
    ErrPasswordTooLong  = errors.New("密码不能超过 72 字节")
    ErrTokenExpired     = errors.New("登录已过期,请重新登录")
    ErrInvalidToken     = errors.New("登录凭据无效")
    ErrMissingToken     = errors.New("请先登录")
    ErrNotPostAuthor    = errors.New("只能修改自己的文章")
    ErrPostNotFound     = errors.New("文章不存在")
)

然后给第 4 课的 httpStatusFor 补上新分支——是补,不是重写

// internal/handler/response.go —— 在第 4 课那个 switch 里追加
    case errors.Is(err, service.ErrEmailTaken), errors.Is(err, service.ErrUsernameTaken),
        errors.Is(err, service.ErrDuplicateAccount):
        return http.StatusConflict // 409
    case errors.Is(err, service.ErrInvalidCred), errors.Is(err, service.ErrTokenExpired),
        errors.Is(err, service.ErrInvalidToken), errors.Is(err, service.ErrMissingToken):
        return http.StatusUnauthorized // 401 —— 未认证
    case errors.Is(err, service.ErrNotPostAuthor):
        return http.StatusForbidden // 403 —— 已认证但无权限
    case errors.Is(err, service.ErrWeakPassword), errors.Is(err, service.ErrPasswordTooLong):
        return http.StatusBadRequest // 400

以及第 5 课那张 errCodes 有序表(新增错误码可以,APIError 的三个字段不能动):

    {service.ErrEmailTaken, "EMAIL_TAKEN"},
    {service.ErrUsernameTaken, "USERNAME_TAKEN"},
    {service.ErrInvalidCred, "INVALID_CREDENTIALS"},
    {service.ErrTokenExpired, "TOKEN_EXPIRED"},
    {service.ErrInvalidToken, "INVALID_TOKEN"},
    {service.ErrMissingToken, "MISSING_TOKEN"},
    {service.ErrNotPostAuthor, "NOT_POST_AUTHOR"},
    {service.ErrWeakPassword, "WEAK_PASSWORD"},
    {service.ErrPasswordTooLong, "PASSWORD_TOO_LONG"},
// internal/service/auth_service.go
type AuthService struct {
    users      repository.UserRepository
    jwtSecret  []byte        // HS256 密钥,从环境变量读,绝不硬编码
    tokenTTL   time.Duration
    bcryptCost int           // 可配置,测试时调成 MinCost 加速
}

type RegisterRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Password string `json:"password"`
}

func (s *AuthService) Register(ctx context.Context, req RegisterRequest) (*model.User, error) {
    // 1. 规范化。邮箱转小写:否则 [email protected][email protected] 会被 UNIQUE 当成两个人,
    //    用户自己都不知道注册了哪个。用户名不转,保留他想要的大小写。
    req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    req.Username = strings.TrimSpace(req.Username)
    if req.Username == "" || req.Email == "" {
        return nil, &model.ValidationError{Field: "username", Reason: "用户名和邮箱不能为空"}
    }

    // 2. 密码长度两端都要卡
    if len(req.Password) < 8 {
        return nil, ErrWeakPassword // → 400 WEAK_PASSWORD
    }
    // 上限【必须】卡,且必须按【字节】算 —— 见下方易错点
    if len(req.Password) > 72 {
        return nil, ErrPasswordTooLong // → 400 PASSWORD_TOO_LONG
    }

    // 3. bcrypt。内部自己生成随机盐,不需要我们提供。
    //    这一步花 ~60ms(cost=10),是【故意】的,别想着优化掉。
    hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), s.bcryptCost)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err) // 不能吞,writeErrorFrom 兜成 500
    }

    u := &model.User{Username: req.Username, Email: req.Email, PasswordHash: string(hash)}

    // 4. 落库,把唯一约束冲突翻译成人话
    if err := s.users.Create(ctx, u); err != nil {
        return nil, s.translateDupErr(err)
    }
    return u, nil
}

// translateDupErr 把 MySQL 1062 翻译成用户看得懂的提示。
//
// 为什么不"先 SELECT 查有没有再 INSERT":那是【竞态】。两个请求同时查、
// 都发现没有、都去插入,后一个照样报 1062。数据库的唯一索引是唯一可靠的仲裁者。
// 正确姿势永远是:直接插,让数据库拒绝,再把拒绝翻译成友好提示。
func (s *AuthService) translateDupErr(err error) error {
    var me *mysql.MySQLError
    // repo 层用 %w 包过一层,所以【必须】用 errors.As 而不是类型断言
    if !errors.As(err, &me) || me.Number != 1062 {
        return fmt.Errorf("create user: %w", err) // 其他错误原样上抛 → 500
    }
    // 1062 的 Message 实测长这样:
    //   Duplicate entry '[email protected]' for key 'users.uk_users_email'
    //   Duplicate entry 'alice' for key 'users.uk_users_username'
    // 靠索引名区分是哪个字段 —— 这正是 schema.sql 给唯一键起名的实际价值。
    switch {
    case strings.Contains(me.Message, "uk_users_email"):
        return ErrEmailTaken
    case strings.Contains(me.Message, "uk_users_username"):
        return ErrUsernameTaken
    default:
        return ErrDuplicateAccount
    }
}

实测这段翻译:

[1062 原文] Duplicate entry '[email protected]' for key 'users.uk_users_email'
重复邮箱   -> 邮箱已被注册 | Is(ErrEmailTaken)=true
[1062 原文] Duplicate entry 'verify_alice' for key 'users.uk_users_username'
重复用户名 -> 用户名已被占用 | Is(ErrUsernameTaken)=true

易错点:bcrypt 的 72 字节上限

本课最容易踩的坑。 bcrypt 算法只处理输入的前 72 字节,1999 年的设计,改不了。

历史上 Go 的 bcrypt 静默截断超长输入。后果:

攻击场景:用户密码 80 字节。攻击者只需猜对前 72 字节,后面随便填都能登录。更糟的是,若有人用「长文本 + 用户名」当密码,前 72 字节可能全是相同前缀——多个不同密码 hash 成同一个值

Go 1.23 起(x/crypto v0.24.0+)修正了:超长输入返回 error 而非静默截断。实测:

73 bytes -> err=bcrypt: password length exceeds 72 bytes | Is(ErrPasswordTooLong)=true
72 bytes -> err=<nil>

所以必须处理这个 error。 照抄老教程写成 hash, _ := bcrypt.GenerateFromPassword(...) 吞掉 error 的话,73 字节密码会让 hashnil,你往数据库存一个空字符串。这个用户从此永远无法登录CompareHashAndPassword("", pw) 恒失败),而注册接口返回 200,你完全不知道出了问题。

两个细节

  1. 必须按字节数 len(s) 判断,不是字符数。 中文在 UTF-8 下每字 3 字节,24 个汉字就到 72 字节。按字符数卡 72 的话,中文用户会撞上 bcrypt 的 error。Go 的 len(string) 返回的正是字节数,直接用就对。
  2. 前端也要卡,但后端的校验才有效——攻击者直接打 API 不经过你的前端。

想支持任意长度?标准做法是先 SHA-256 再 base64 得到固定 44 字节再喂 bcrypt。但这引入新坑(password shucking 攻击),初学阶段不要自己发明,卡 72 字节就好。

4.2 Login —— 时序攻击防护

// dummyHash 是一个真实的 bcrypt 哈希(密码随机,没人需要知道)。
// 唯一用途:用户不存在时跑一次假比较,把响应时间抹平。
var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")

// 登录失败【唯一】的对外错误。注意它【不区分】"用户不存在"和"密码错误"。
// ErrInvalidCred 在 errors.go 已声明,这里只是提醒:它是登录失败【唯一】的对外错误,
// 【不区分】"用户不存在"和"密码错误"。

func (s *AuthService) Login(ctx context.Context, email, password string) (string, error) {
    email = strings.ToLower(strings.TrimSpace(email)) // 和注册保持一致,否则大小写不同就登不上

    u, err := s.users.FindByEmail(ctx, email)
    if err != nil {
        // 这是真故障(数据库连不上等),不是"用户不存在"。必须区分开,
        // 否则数据库挂了你会以为是用户密码错。
        return "", fmt.Errorf("find user by email: %w", err)
    }

    if u == nil {
        // ---- 关键:用户不存在时也跑一次 bcrypt,见下方攻击场景 ----
        bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
        return "", ErrInvalidCred // ← 和密码错误返回【完全相同】的错误
    }

    // CompareHashAndPassword 内部:① 从 hash 解析出版本/cost/盐
    // ② 用同样参数重算 ③ 用【常数时间】比较结果(这步本身也防时序攻击)
    if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
        // 不要检查具体是哪种错误,更不要把 err 内容返回给用户。bcrypt 的原文
        // "crypto/bcrypt: hashedPassword is not the hash of the given password"
        // 泄露了你用 bcrypt(帮攻击者定爆破策略),且对用户毫无意义。
        return "", ErrInvalidCred // ← 与上一分支【一字不差】
    }
    return s.issueToken(u)
}

那行假比较的攻击场景:不加它,「用户不存在」分支立即返回,耗时约 1ms(只有一次查询);而「用户存在、密码错」要跑完整 bcrypt,约 60ms。攻击者拿邮箱列表逐个尝试(密码随便填),看响应时间:~1ms → 没注册过;~60ms → 注册过。60 倍差距,即使有网络抖动也能靠多次取样统计出来。

泄露「某邮箱是否注册」的三种危害:① 撞库精准化——先筛出有效账号再集中爆破,效率提升几十倍;② 隐私泄露——「某人在这个心理咨询/相亲站点注册过」本身就敏感;③ 定向钓鱼——确认目标用了你的服务,伪造你的品牌发钓鱼邮件。

实测两条分支耗时(含一次查询):密码错 103ms / 用户不存在 124ms,同量级且差值为负(噪声主导),攻击者提取不到信号。

易错点:错误消息「太贴心」

新手常写这种「体验好」的代码:

if u == nil { return "", errors.New("该邮箱尚未注册") } // ❌
if bcrypt.CompareHashAndPassword(...) != nil {
    return "", errors.New("密码错误")                                              // ❌
}

这比时序攻击还糟——信息直接印在响应体上。 攻击者连计时都不用做,跑一遍邮箱列表就精确枚举出你的全部用户。

「产品经理要求提示更明确」怎么办? 标准解法是把信息移到用户已证明身份的地方:注册时邮箱重复可以明确提示(攻击者本来就能通过注册接口枚举,这个洞只能靠限流和验证码堵);忘记密码流程永远回复「如果该邮箱已注册,我们已发送重置邮件」;登录失败就是统一文案,没得商量。

诚实地说,时序防护是尽力而为的FindByEmail 命中与否本身就有微小差异。但 60ms 的 bcrypt 已把差异从「60 倍」压到噪声级别,投入产出比极高。同时必须配合登录接口限流(练习 5 / 第 9 课)——限流才是防枚举的主力,时序防护是补漏。

4.3 issueToken

// Claims 是自定义声明。【嵌入】jwt.RegisteredClaims 是 v5 的标准姿势
// (第 5 课讲过 struct 嵌入):
//   ① 自动获得 sub/exp/iat/nbf/iss/aud/jti 全部标准字段及 json 标签
//   ② 自动获得 GetExpirationTime()/GetSubject() 等方法,从而满足 jwt.Claims 接口
//      —— 我们【不需要】自己实现任何接口方法,嵌入全包了
//   ③ 库的内置校验(过期、nbf、iss)能直接读到这些字段
type Claims struct {
    UserID   uint64 `json:"uid"`      // 避免每次把 sub 字符串转 uint64
    Username string `json:"username"` // 省掉一次查库就能显示用户名
    jwt.RegisteredClaims               // 嵌入,注意【没有字段名】
}

func (s *AuthService) issueToken(u *model.User) (string, error) {
    now := time.Now()
    claims := &Claims{
        UserID: u.ID, Username: u.Username,
        RegisteredClaims: jwt.RegisteredClaims{
            Subject: fmt.Sprint(u.ID), // sub 按 RFC 是字符串,所以 uint64 要转
            Issuer:  "blog",           // 防止误收其他系统用【相同 secret】签的 token

            // ⚠️ v5 的时间字段是 *jwt.NumericDate,不是 int64(那是 v4)。
            // 必须用 jwt.NewNumericDate(t) 包装,直接塞 time.Time 编译不过,报错形如:
            //   cannot use now (variable of type time.Time) as *jwt.NumericDate value
            IssuedAt:  jwt.NewNumericDate(now),
            NotBefore: jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(s.tokenTTL)),
        },
    }
    // NewWithClaims 只【组装】token 对象,此时还没签名
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    // SignedString 才真正计算签名并输出三段字符串。
    // 参数类型是 any:HS256 要 []byte,RS256 要 *rsa.PrivateKey。传错不会编译报错,
    // 只在【运行时】返回 "key is of invalid type" —— any 参数的代价,写的时候留神。
    return token.SignedString(s.jwtSecret)
}

实测签出的 token 解码后:

header  = {"alg":"HS256","typ":"JWT"}
payload = {"uid":7,"username":"alice","iss":"blog","sub":"7","exp":1788719207,"nbf":1788712007,"iat":1788712007}

header 里的 alg攻击者可以修改的——这引出下一节。

secret 从哪来

secret := os.Getenv("JWT_SECRET")
if len(secret) < 32 {
    // 直接崩掉,不要用默认值兜底。硬编码 fallback secret 是【最常见的生产事故来源】之一:
    // 开发时图方便写 "dev-secret",上线忘配环境变量,于是【全世界都知道你的密钥】,
    // 任何人都能签发 uid=1 的管理员 token。
    log.Fatal("JWT_SECRET 必须设置且至少 32 字节")
}
echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env   # .env 必须已在 .gitignore

不要用 "secret" 这种短字符串——攻击者拿到任意一个你签的 token 就能离线爆破密钥(hashcat -m 16500 专干这个,弱密钥几秒破解)。破解 = 他能签发任意用户的 token。HMAC-SHA256 密钥超过 64 字节会先被压缩,32–64 字节是甜点区

4.4 ParseToken —— 算法校验是重点

func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
    var claims Claims
    _, err := jwt.ParseWithClaims(tokenStr, &claims, // ← 【指针】,传值会 panic 或全零值
        func(t *jwt.Token) (any, error) {
            // ============================================================
            // 【全课最重要的 3 行】校验签名算法
            //
            // t.Header["alg"] 是【攻击者完全可控】的 —— 它就是 token 第一段的内容。
            // keyfunc 在【验签之前】被调用,此时 token 还完全不可信。
            // ============================================================
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
            }
            return s.jwtSecret, nil
        },
        // 下面是【第二道防线】,和 keyfunc 里的检查【同时】用,不是二选一。
        // 纵深防御:任何一道漏了,另一道还在。
        jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
        jwt.WithIssuer("blog"),
        jwt.WithExpirationRequired(),  // exp 【必须存在】—— 见下方易错点
        jwt.WithLeeway(5*time.Second), // 容忍时钟偏差,避免刚签发就被判 nbf 未到
    )
    if err != nil {
        // v5 的错误是包装过的,【必须】用 errors.Is,直接 == 永远不相等
        if errors.Is(err, jwt.ErrTokenExpired) {
            // 过期可以明确告诉客户端 —— 前端据此触发 refresh 或跳登录页。
            // 这不算泄露:token 在他手上,exp 他自己解码就能看到。
            return nil, ErrTokenExpired // → 401 TOKEN_EXPIRED
        }
        // 其他一律笼统处理,免得攻击者靠错误消息调试他的伪造 token
        return nil, ErrInvalidToken // → 401 INVALID_TOKEN
    }
    return &claims, nil
}

实测各路径:

parse    -> err=<nil> valid=true uid=7 user=alice
tampered -> err=token signature is invalid: signature is invalid | Is(ErrTokenSignatureInvalid)=true
expired  -> err=token has invalid claims: token is expired      | Is(ErrTokenExpired)=true

攻击一:alg: none

JWT 规范里 "alg":"none" 意思是「这个 token 没有签名」。攻击者把 header 改成 {"alg":"none"}、payload 改成 {"uid":1}、第三段留空。如果库看到 none 就说「不用验签」,那么任何人能伪造任意身份。这是 JWT 史上最出名的漏洞。

好消息:jwt/v5 默认挡住了。 实测即使 keyfunc 完全不检查算法:

none w/o check -> err: token is unverifiable: 'none' signature type is not allowed

v5 要求显式传入 jwt.UnsafeAllowNoneSignatureType 才肯接受(名字里的 Unsafe 就是在骂你)。所以在 v5 上 alg: none 已不是威胁了。 但这不代表可以不检查算法——因为还有攻击二。

攻击二:算法混淆(真实存在,我复现了)

场景:服务端用 RS256(私钥签、公钥验),而公钥是公开的(本来就该公开)。攻击者:

  1. 拿到你的公钥
  2. 把 header 改成 {"alg":"HS256"}
  3. 把那份公钥当作 HMAC 密钥,给自己伪造的 payload 签名。

服务端 keyfunc 若无脑返回公钥字节,它看到 alg: HS256 就用 HMAC 验签,密钥正是那份公钥——和攻击者用的完全一样,验签通过。我的复现结果:

evil HS256 token signed err: <nil>
alg confusion (no check, []byte key): <nil>   ← 被攻破了
alg confusion (WITH check): token is unverifiable: error while executing keyfunc: unexpected alg: HS256

第二行的 <nil> 意味着伪造的 token 校验通过了,一次完整的身份伪造。

有意思的是,若 keyfunc 返回强类型*rsa.PublicKey 而不是 []byte,v5 的类型检查会顺手救你(key is of invalid type: HMAC verify expects []byte)。但那是运气不是设计——很多真实代码就是从 PEM 读出 []byte 直接返回的。

结论:keyfunc 里的算法检查必须写。 它不是过时的仪式,是防算法混淆的核心防线。jwt.WithValidMethods 做双保险,实测也能拦住 none

易错点:exp 缺失时默认放行

no exp (default)  -> err=<nil>          ← 危险
no exp (required) -> err=token is missing required claim: exp claim is required

若你的签发代码某天漏了 ExpiresAt(比如新增一条签发路径忘了设),你会签出永久有效的 token 且毫无察觉。永远加 jwt.WithExpirationRequired()——它让「忘记设过期」从静默的安全漏洞变成立刻能发现的 401。

4.5 RequireAuth —— 返回中间件的函数

第 5 课的中间件都没有参数。鉴权中间件需要 AuthService——写一个返回 Middleware 的函数,用闭包捕获依赖,和第 5 课的 CORS(origin) 同一个模式。

// internal/middleware/auth.go

// AuthService 是本包对认证服务的【最小依赖接口】。
// 不直接 import service 包的原因:① 避免 middleware→service 的依赖(第 4 课的依赖倒置)
// ② 测试时能塞假实现,不用起真数据库。
// 接口定义在【使用方】这一侧,是 Go 的惯例("接口属于消费者")。
type AuthService interface {
    UserFromToken(ctx context.Context, token string) (*model.User, error)
}

// RequireAuth 返回一个【强制要求登录】的中间件。
// 注意签名 func(AuthService) Middleware —— 它不是中间件,它【返回】中间件,
// 这样才能把 svc 通过闭包带进去。用法: Chain(h, RequireAuth(authSvc))
func RequireAuth(svc AuthService) Middleware {
    return func(next http.Handler) http.Handler { // ← 这一层才是 Middleware
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token, ok := bearerToken(r)
            if !ok {
                writeAuthError(w, r, "MISSING_TOKEN", "请先登录")
                return // ← 【不调用 next】,请求到此为止
            }
            u, err := svc.UserFromToken(r.Context(), token)
            if err != nil {
                writeAuthError(w, r, "INVALID_TOKEN", "登录凭据无效")
                return
            }
            // 复用第 5 课的 context 范式:存值 → 派生新 request → 【重新赋值】
            r = r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u))
            next.ServeHTTP(w, r)
        })
    }
}

// OptionalAuth 尝试识别用户但【不强制】。
// 场景:文章详情匿名可看,但登录用户能额外看到自己的草稿。
// 关键区别:任何失败都【不中断请求】,只是 context 里没有用户。
func OptionalAuth(svc AuthService) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if token, ok := bearerToken(r); ok {
                if u, err := svc.UserFromToken(r.Context(), token); err == nil {
                    r = r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u))
                }
                // err != nil 时【故意什么都不做】:带个过期 token 来看公开文章
                // 不该被拒绝,当匿名处理就好。
            }
            next.ServeHTTP(w, r)
        })
    }
}

func bearerToken(r *http.Request) (string, bool) {
    // strings.CutPrefix 优于 strings.Split(h," ")[1]:
    //   ① 畸形输入下 Split 会索引越界 panic ② 显式返回 ok ③ 一次遍历,无切片分配
    token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
    if !ok || token == "" {
        return "", false
    }
    return token, true
}

// writeAuthError 手写 401 响应。
//
// 为什么不直接调 handler.writeError:会形成【import 循环】——
// 第 5 课的 handler.writeErrorFrom 已经 import 了 middleware(为了 RequestIDFrom),
// 这里再反向 import handler,`go build` 会报
//   import cycle not allowed
// 所以中间件层自己拼这一小段 JSON,字段名与 handler.APIError 保持一致即可。
// (另一种解法是把 APIError 下沉到独立的 apperr 包,两边都 import 它。
//  本课不这么做:第 2 课已经把它定在 handler 包,跨课契约不为局部方便而改。)
func writeAuthError(w http.ResponseWriter, r *http.Request, code, msg string) {
    // WWW-Authenticate 是 401 响应的 HTTP 规范要求头,告诉客户端该用什么方式认证
    w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(http.StatusUnauthorized)
    _ = json.NewEncoder(w).Encode(map[string]string{
        "code": code, "message": msg,
        "request_id": RequestIDFrom(r.Context()), // 复用第 5 课的 request id
    })
}

易错点:Bearer 的大小写和空格

实测各种输入:

CutPrefix("Bearer eyJhbGciOiJI")  -> "eyJhbGciOiJI", true   ✅
CutPrefix("bearer xyz")           -> "bearer xyz",   false  ← 小写 b,不匹配
CutPrefix("Bearer")               -> "Bearer",       false  ← 没有空格
CutPrefix("Bearer  ab")           -> " ab",          true   ← 两个空格,token 带前导空格!
CutPrefix("")                     -> "",             false
  1. RFC 6750 规定 scheme 应当大小写不敏感,但绝大多数实现(含本课)只认 Bearer。要对接奇怪客户端就得用 strings.EqualFold
  2. 两个空格会返回带前导空格的 token,必然校验失败——可加 strings.TrimSpace 兜底。
  3. 不要用 strings.Split(h, " ")[1]h 为空或没有空格时切片长度是 1,[1] 直接 panic ——一个未认证的请求就能打 panic。虽然第 5 课的 Recover 会兜成 500,但正确响应应该是 401,而且这是白送的 DoS 面(攻击者狂发畸形 header 把你的日志刷爆)。
// internal/middleware/context.go(第 5 课的文件,追加)

// CurrentUser 【必须】返回 ok:因为"有没有登录"直接决定调用方的控制流
// (对比 RequestIDFrom 取不到返回空串就行)。这就是第 5 课说的判断标准。
func CurrentUser(ctx context.Context) (*model.User, bool) {
    u, ok := ctx.Value(ctxKeyUser).(*model.User) // 双返回值,单返回值写法在 nil 时 panic
    return u, ok
}

// MustCurrentUser 用于【确定】经过了 RequireAuth 的 handler。取不到就 panic ——
// 这不是防御失败,而是【故意】的:走到这里没有用户说明路由装配漏了 RequireAuth,
// 是【程序 bug】不是运行时异常。panic 会被第 5 课的 Recover 兜成 500 并打堆栈,
// 你能立刻定位到装配错误。这正是第 1 课说的"panic 用于程序进入了不该出现的状态"。
func MustCurrentUser(ctx context.Context) *model.User {
    u, ok := CurrentUser(ctx)
    if !ok {
        panic("middleware: 当前 handler 缺少 RequireAuth,context 中没有用户")
    }
    return u
}

4.6 授权:只有作者能改自己的文章

这条规则放 handler 还是 service?答案:service 层。 三条理由:

  1. service 是业务规则的家。 这和「文章必须有标题」是同一类东西。第 4 课定的规矩就是 handler 只做 HTTP ↔ service 的翻译。
  2. 不能被绕过。 将来加 gRPC 接口、CLI 工具、后台批处理,它们都不经过 HTTP handler。放 service 层所有入口自动受保护;放 handler 层每加一个入口就要重实现一遍,迟早漏一个。
  3. 它需要数据。 判断「是不是作者」必须先查出文章看 author_id,这个查询本来就在 service 层。

但 handler 要做一件事:把当前用户从 context 取出来传给 service。 service 不知道 HTTP、不知道 context 里存了什么 key,它只接受明确的参数。

// internal/service/post_service.go
// Update 修改文章。actorID 是【发起操作的用户】,由 handler 从 context 取出后传入。
//
// 签名里【显式】带 actorID 而不是让 service 自己去 ctx 里掏:这样 service 的
// 依赖是显式的 —— 看签名就知道这个操作需要身份,且单元测试不用构造 context。
func (s *PostService) Update(ctx context.Context, actorID uint64, slug string, req UpdateRequest) (*model.Post, error) {
    p, err := s.repo.GetBySlug(ctx, slug)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrPostNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("get post %q: %w", slug, err)
    }

    // ---- 授权检查 ----
    // author_id 在 schema 里可空(第 6 课之前的文章没作者),所以是 *uint64。
    if p.AuthorID == nil || *p.AuthorID != actorID {
        // 【403 不是 404】:用户已证明身份(认证通过),只是没权限(授权失败)。
        //
        // 但有个权衡:403 等于告诉他"这篇文章存在,只是不是你的"。对【本来就公开】
        // 的博客文章不算泄露 —— 他随便一搜就知道文章存在。若是私密资源(私人文档),
        // 应返回 404 假装不存在,避免攻击者靠 403/404 差异枚举资源。
        // 这叫"资源存在性泄露",练习 3 会再用到。
        return nil, ErrNotPostAuthor // → 403 NOT_POST_AUTHOR
    }
    // TODO(练习3 延伸): 应用 req 字段变更并保存
    return p, nil
}
// internal/handler/post_handler.go
func (h *PostHandler) Update(w http.ResponseWriter, r *http.Request) {
    // handler 的职责:把 HTTP 世界的东西翻译成 service 认识的参数。
    // 这个 handler 挂在 RequireAuth 后面,所以用户一定存在。
    u := middleware.MustCurrentUser(r.Context())

    var req service.UpdateRequest
    // 用第 2 课定稿的 decodeJSON —— 它要 w,因为内部 MaxBytesReader(w, ...) 需要
    if err := decodeJSON(w, r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "BAD_REQUEST", "请求体不是合法 JSON")
        return
    }
    p, err := h.svc.Update(r.Context(), u.ID, r.PathValue("slug"), req)
    if err != nil {
        writeErrorFrom(w, r, err) // 403 由第 5 课的 writeErrorFrom + 第 4 课的 httpStatusFor 映射
        return
    }
    _ = writeJSON(w, http.StatusOK, p) // 第 2 课定稿的 writeJSON 返回 error
}

反面教材:永远不要相信客户端传来的 author_id

var req struct{ AuthorID uint64 `json:"author_id"` }   // ❌ 灾难
h.svc.Update(ctx, req.AuthorID, slug, ...)             // 攻击者随便填别人的 ID
身份只能来自服务端校验过的 token,绝不能来自请求体或查询参数。这类漏洞叫 IDOR(不安全的直接对象引用),是 OWASP 榜上的常客。

4.7 装配

// cmd/server/main.go —— 片段
requireAuth := middleware.RequireAuth(authSvc)

mux.HandleFunc("POST /api/auth/register", authHandler.Register) // 公开
mux.HandleFunc("POST /api/auth/login", authHandler.Login)       // 公开

// 需要登录 —— 【单独】给这几条套 RequireAuth,而不是全局套
mux.Handle("GET /api/auth/me", middleware.Chain(http.HandlerFunc(authHandler.Me), requireAuth))
mux.Handle("POST /api/posts", middleware.Chain(http.HandlerFunc(postHandler.Create), requireAuth))
mux.Handle("PATCH /api/posts/{slug}", middleware.Chain(http.HandlerFunc(postHandler.Update), requireAuth))

// 匿名可看、登录后有增强
mux.Handle("GET /api/posts/{slug}",
    middleware.Chain(http.HandlerFunc(postHandler.GetBySlug), middleware.OptionalAuth(authSvc)))

// 全局链保持第 5 课的顺序不变
root := middleware.Chain(mux, middleware.RequestID, middleware.Logger,
    middleware.Recover, middleware.CORS("http://localhost:3000"), middleware.MaxBody(1<<20))

4.8 一次完整登录 + 鉴权的时序

┌────────┐            ┌───────────┐        ┌─────────────┐      ┌───────┐
│ 客户端 │            │ 中间件链  │        │ AuthService │      │ MySQL │
└───┬────┘            └─────┬─────┘        └──────┬──────┘      └───┬───┘
    │ ① POST /auth/register │                     │                 │
    ├──────────────────────►├────────────────────►│ bcrypt ~60ms ⏳  │
    │◄──────────────────────┤ 201 {id,username}   ├────────────────►│ INSERT
    │                       │                     │                 │
    │ ② POST /auth/login    │                     │                 │
    ├──────────────────────►├────────────────────►├────────────────►│ SELECT
    │                       │                     │◄────────────────┤
    │                       │                     │ Compare ~60ms ⏳ │
    │                       │                     │ (用户不存在也跑, │
    │                       │                     │  抹平时序)       │
    │◄──────────────────────┤ 200 {token:"eyJ…"}  │ issueToken() 签名│
    │  【客户端保存 token】  │                     │                 │
    │                       │                     │                 │
    │ ③ GET /auth/me        │                     │                 │
    │   Authorization: Bearer eyJ…                │                 │
    ├──────────────────────►│                     │                 │
    │              ┌────────┴─────────┐           │                 │
    │              │ RequestID 生成rid│           │                 │
    │              │ Logger    计时   │           │                 │
    │              │ Recover   布防   │           │                 │
    │              │ RequireAuth:     │           │                 │
    │              │ ├CutPrefix("Bearer ")        │                 │
    │              │ ├ParseToken ────────────────►│ ①验算法 ②验签名 │
    │              │ │                       ◄────┤ ③验 exp/iss     │
    │              │ ├FindByID(1) ────────────────┼────────────────►│
    │              │ └r = r.WithContext(user) ⚠️  │◄────────────────┤
    │              └────────┬─────────┘           │                 │
    │◄──────────────────────┤ handler:MustCurrentUser(ctx) → 200    │
    │                       │ 日志: rid=… 200 3ms │                 │

⑤ 跑起来验证

cd ~/go-blog && export JWT_SECRET=$(openssl rand -base64 48)
go build ./... && go vet ./... && go run ./cmd/server

5.1 注册、唯一冲突、超长密码

curl -s -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"alice","email":"[email protected]","password":"correct-horse-battery"}' | jq
# 预期【没有 password_hash 字段】(json:"-" 生效):
# {"id":1,"username":"alice","email":"[email protected]","created_at":"...","updated_at":"..."}

# 邮箱重复 → {"code":"EMAIL_TAKEN","message":"该邮箱已被注册"}
curl -s -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"bob","email":"[email protected]","password":"another-password"}' | jq
# 用户名重复 → {"code":"USERNAME_TAKEN","message":"该用户名已被占用"}
curl -s -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"alice","email":"[email protected]","password":"another-password"}' | jq

# 80 字节 → 预期 400 password_too_long,【绝不能返回 201】
curl -s -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d "{\"username\":\"carol\",\"email\":\"[email protected]\",\"password\":\"$(printf 'a%.0s' {1..80})\"}" | jq
# 25 个汉字 = 75 字节 → 也应 400。若你错误地按【字符数】校验(25<72 放行),
# bcrypt 会返回 error —— 观察你的代码是崩了、是 500、还是正确的 400
curl -s -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"dave","email":"[email protected]","password":"密码密码密码密码密码密码密码密码密码密码密码密码密"}' | jq
mysql -uroot -p123456 -e "SELECT id,username,password_hash,LENGTH(password_hash) len FROM blog_dev.users;"
# | 1 | alice | $2a$10$KknLd/9FS5rJ/7pxhT.tq.819GlE9jgqgeElmFLDQC6a2DT7IU4ci | 60 |

确认:以 $2a$10$ 开头(算法 2a、cost 10)、长度正好 60看不出原密码。另外响应里不能出现 Duplicate entryuk_users_email 这类 SQL 原文——出现了说明 translateDupErr 没生效。

5.2 登录并亲眼看到 payload 是明文

TOKEN=$(curl -s -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct-horse-battery"}' | jq -r .token)
echo "$TOKEN"

这一步请务必亲手做,比读十遍「JWT 不是加密」都有效:

PAYLOAD=$(echo "$TOKEN" | cut -d. -f2)
# base64url 补齐到 4 的倍数并把 -_ 换回 +/。
# 直接 `base64 -d` 在 macOS 上会因缺少 = 填充而【截断最后一个字符】,
# 你会看到少了结尾的 } —— 实测确认过。
while [ $(( ${#PAYLOAD} % 4 )) -ne 0 ]; do PAYLOAD="${PAYLOAD}="; done
echo "$PAYLOAD" | tr '_-' '/+' | base64 -d; echo
{"uid":1,"username":"alice","iss":"blog","sub":"1","exp":1788719207,"nbf":1788712007,"iat":1788712007}

你没有用任何密钥,就读出了 token 的全部内容。 再看 header:

echo "$TOKEN" | cut -d. -f1 | base64 -d; echo
# {"alg":"HS256","typ":"JWT"}   ← alg 是攻击者能随便改的,这就是那 3 行算法校验的理由

5.3 401 / 200 / 篡改 / alg:none

curl -i -s localhost:8080/api/auth/me | head -3
# HTTP/1.1 401 Unauthorized   +   Www-Authenticate: Bearer realm="api"
curl -s localhost:8080/api/auth/me -H "Authorization: Bearer $TOKEN" | jq
# {"id":1,"username":"alice",...}

B=$(echo "$TOKEN" | cut -d. -f1); S=$(echo "$TOKEN" | cut -d. -f3)
enc() { echo -n "$1" | base64 | tr '+/' '-_' | tr -d '='; }

# ① 篡改 payload 成 uid=999,沿用原签名
FAKE="$B.$(enc '{"uid":999,"username":"admin","iss":"blog","sub":"999","exp":9999999999}').$S"
curl -s -o /dev/null -w '篡改:      %{http_code}\n' localhost:8080/api/auth/me -H "Authorization: Bearer $FAKE"
# ② alg:none 攻击
NONE="$(enc '{"alg":"none","typ":"JWT"}').$(enc '{"uid":1,"iss":"blog","sub":"1","exp":9999999999}')."
curl -s -o /dev/null -w 'alg:none:  %{http_code}\n' localhost:8080/api/auth/me -H "Authorization: Bearer $NONE"
# ③ 尾部加料 / 缺 Bearer 前缀
curl -s -o /dev/null -w '尾部加料:  %{http_code}\n' localhost:8080/api/auth/me -H "Authorization: Bearer ${TOKEN}xxx"
curl -s -o /dev/null -w '缺 Bearer: %{http_code}\n' localhost:8080/api/auth/me -H "Authorization: $TOKEN"

全部预期 401

5.4 登录失败两条路径完全一致

for i in 1 2 3; do
  curl -s -o /dev/null -w '存在用户: %{time_total}s\n' -X POST localhost:8080/api/auth/login \
    -H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"x"}'
  curl -s -o /dev/null -w '不存在  : %{time_total}s\n' -X POST localhost:8080/api/auth/login \
    -H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"x"}'
done

两组数字应混在一起分不出来(实测 103ms vs 124ms)。不应出现一个 5ms、一个 100ms——那说明漏了 dummyHash 那次假比较。同时确认两次 JSON 完全相同{"code":"INVALID_CREDENTIALS","message":"用户名或密码错误"}

5.5 授权:改别人的文章 → 403

curl -s -X POST localhost:8080/api/posts -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"title":"Alice 的文章","slug":"alice-post","content":"正文"}' >/dev/null
curl -s -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"bob","email":"[email protected]","password":"bobs-strong-password"}' >/dev/null
BOB=$(curl -s -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"bobs-strong-password"}' | jq -r .token)

curl -s -X PATCH localhost:8080/api/posts/alice-post -H "Authorization: Bearer $BOB" \
  -H 'Content-Type: application/json' -d '{"title":"被 bob 改了"}' | jq
# {"code":"NOT_POST_AUTHOR","message":"只能修改自己的文章"}
#   ← 403 不是 401:bob 【已经认证】了,只是没权限
curl -s -o /dev/null -w 'alice 改自己: %{http_code}\n' -X PATCH localhost:8080/api/posts/alice-post \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"title":"改了"}'   # 200

5.6 过期 token 与日志

临时把 TTL 改成 5*time.Second 重启:

SHORT=$(curl -s -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct-horse-battery"}' | jq -r .token)
curl -s -o /dev/null -w '立即: %{http_code}\n' localhost:8080/api/auth/me -H "Authorization: Bearer $SHORT"  # 200
sleep 7
curl -s localhost:8080/api/auth/me -H "Authorization: Bearer $SHORT" | jq
# {"code":"TOKEN_EXPIRED","message":"登录已过期,请重新登录","request_id":"..."}

token_expiredinvalid_token 是不同 code——前端据此决定「跳登录页」还是「静默 refresh」。验证完改回 2 小时。

服务端日志应该长这样:

rid=a1b2c3d4 POST /api/auth/register 201 142B 68.3ms   ← bcrypt 成本,符合预期
rid=e5f6a7b8 POST /api/auth/login    200 289B 71.2ms
rid=c9d0e1f2 GET  /api/auth/me       401  78B  128µs   ← 没 token,压根没查库
rid=3a4b5c6d GET  /api/auth/me       200 156B  2.1ms

日志里必须没有密码、没有 token、没有 hash。 调试时若加过 log.Printf("password=%s", ...)现在就删掉——日志会被收集、备份、被运维看到,密码进日志等于明文存储。

⑥ TODO 练习

练习 1:补全 UserRepository

验收Createres.LastInsertId() 回填 u.IDFindByEmail/FindByID 没找到时返回 (nil, nil)不是 error;其他错误用 fmt.Errorf("...: %w", err) 包装,保证 errors.As 能挖到 *mysql.MySQLError;5.1、5.2 的 curl 全部通过。

陷阱:若用 errors.New(...) 而不是 %w,错误链就断了,translateDupErrerrors.As 返回 false,用户看到 500 而不是「邮箱已被注册」。

练习 2:UserFromToken/api/auth/me

// TODO(练习2): ParseToken → 拿 claims.UserID → FindByID → 返回用户。
func (s *AuthService) UserFromToken(ctx context.Context, token string) (*model.User, error) { panic("TODO") }

验收:5.5 全部通过;token 有效但用户已被删除时返回 401 而不是 500 或 panic:

mysql -uroot -p123456 -e "DELETE FROM blog_dev.users WHERE username='bob';"
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/auth/me -H "Authorization: Bearer $BOB"  # 401

这个场景很容易漏——FindByID 返回 (nil, nil),不判空就直接 u.ID 是 nil 指针 panic。

思考题(2–3 句):每次请求都查一次库,是不是违背了 JWT「无状态」的初衷?为什么还是要查?(提示:想想「用户被封禁」和「用户改了用户名」。)

练习 3:OptionalAuth 的实际用途

// TODO(练习3): 让 GetBySlug 支持"作者能看自己的草稿"。
func (s *PostService) GetBySlug(ctx context.Context, actorID *uint64, slug string) (*model.Post, error)

规则:model.StatusPublished 任何人可看;model.StatusDraft(草稿)和 model.StatusOffline(已下线)只有作者本人能看,其他人(含匿名)返回 404

注意 model.Post.Status 是 defined typetype Status int8,不是裸 int8)。所以要写 p.Status == model.StatusPublished不能p.Status == 1——后者虽然因为无类型常量可以编译,但 p.Status == someInt8 会直接报 invalid operation: mismatched types model.Status and int8。用具名常量,别用魔数。

验收

匿名:  404      bob: 404      alice: 200

注意这里是 404 不是 403,和 4.6 改文章返回 403 是不同的选择。想清楚为什么:草稿是未公开资源,返回 403 等于告诉 bob「my-draft 这个 slug 存在」,他能靠这个枚举出 alice 所有未发布文章的标题(slug 通常就是标题的转写)。已发布文章返回 403 则无所谓,它本来就公开。能说清这个差别,说明你真的理解了「资源存在性泄露」。

练习 4:改密码后让旧 token 失效

不引入 Redis,用 3.3 提到的技巧。

ALTER TABLE users ADD COLUMN password_changed_at DATETIME NULL AFTER password_hash;
// TODO(练习4):
// ① POST /api/auth/password,要求提供【旧密码】(防 token 被盗后直接改密码锁死账号)
// ② 改密码时把 password_changed_at 设为 NOW()
// ③ UserFromToken 里加检查:claims.IssuedAt 早于 password_changed_at 则 401

验收:改前 200,改后立刻 401。

时间精度陷阱DATETIME 默认精度到,JWT 的 iat 也是秒。若签发和改密码在同一秒内,iat < password_changed_at 为 false,旧 token 侥幸存活。用 DATETIME(3) 或把条件改成 <= 都可缓解——说出你选了哪个及为什么

练习 5:登录失败限流

// TODO(练习5): 同一 IP 15 分钟内失败超过 5 次,后续直接 429,持续 15 分钟。
func LoginRateLimit() Middleware { panic("TODO") }

验收:连打 7 次错密码,前 5 次 401,第 6、7 次 429 且带 Retry-After 头。

必须处理三点

  1. 只对失败计数,登录成功要清零——否则正常用户手滑一次就被锁;
  2. sync.Mutexsync.Map 保护并发。每请求一个 goroutine,裸 map 并发写会 fatal error: concurrent map writes 让进程崩溃,而且这个 fatal error 无法被 recover 捕获——第 5 课的 Recover 救不了你;
  3. 要能清理过期条目,否则 map 无限增长,攻击者伪造大量 IP 就能打爆内存。

思考题:按 IP 限流有什么问题?(提示:整个公司走同一出口 NAT;反过来攻击者有 IPv6 /64 段可随便换 IP。)说明为什么生产环境通常同时按 IP 和按账号限流。

⑦ 自检清单

密码存储

  • 我能说清为什么「加盐的 SHA256」仍不够(快 = 易爆破,加盐只废掉彩虹表),以及 cost 是指数的、「自适应」= 硬件变快就调高 cost
  • 我知道盐内置在 60 字节输出里,能解释为什么每次 hash 不同却仍能验证,也知道 CHAR(60) 的 60 怎么算出来的
  • 我知道 WHERE password_hash = ? 永远查不到东西
  • 我知道上限 72 字节、Go 1.23+ 返回 error 而非静默截断必须处理,且要按 len(s)(字节)而非字符数校验(24 个汉字就到 72)
  • 我的 model.User.PasswordHash 带了 json:"-"

登录安全

  • 我知道时序攻击能泄露「某邮箱是否注册」并能说出三种危害,也在「用户不存在」分支跑了 dummyHash 假比较
  • 我的两条失败分支返回一字不差的错误
  • 我知道时序防护是尽力而为,限流才是防枚举的主力

JWT

  • 我能画出三段结构,亲手 base64 解码过 payload,知道签名保证「未被篡改」而非「不可见」,payload 不能放密码/手机号/身份证
  • 我能说出 HS256 / RS256 的选择标准(有第二个服务需要校验就上 RS256)
  • 我用的是 golang-jwt/jwt/v5不是 dgrijalva/jwt-go(废弃 + CVE-2020-26160);知道 v5 时间字段是 *jwt.NumericDate
  • 我的 keyfunc 里有 t.Method.(*jwt.SigningMethodHMAC) 断言;能说清算法混淆攻击及为什么 keyfunc 返回 []byte 时特别危险
  • 我知道 v5 默认已挡住 alg: none,但算法检查仍然必须写
  • 我加了 jwt.WithExpirationRequired(),知道不加会让「漏设 exp」变成永久 token
  • 我的 secret 来自环境变量,没有硬编码 fallback,长度 ≥32 字节
  • 我知道 JWT 无法主动吊销(登出只是前端删本地副本),能说出三种缓解手段及代价,也知道单体 + 需精确会话控制时 session + cookie 可能更合适

认证与授权

  • 我能区分 401(未认证)和 403(未授权),知道 401 的英文名是历史错误
  • 我把「只有作者能改」放在了 service 层,能说出三条理由
  • 我的 handler 从 context 取用户 ID 传给 service,绝不信任请求体里的 author_id(IDOR)
  • 我知道公开资源用 403、私密资源用 404,以及「资源存在性泄露」是什么
  • 我的 RequireAuth 失败时没有调用 nextOptionalAuth 失败时不中断请求
  • 我的 service 层返回领域哨兵错误,不返回 APIError——它不认识 HTTP
  • 我给第 4 课的 httpStatusFor 追加了 401/403/409 分支,没有重写
  • 我知道 middleware 不能 import handler(会 import 循环),所以 401 在中间件里自己拼
  • 我用了第 2 课的 decodeJSON(w, r, &req)带 w),没有裸用 json.NewDecoder
  • 我知道 model.Post.Status 是 defined type,比较要用 model.StatusPublished 而非 1
  • 我用 strings.CutPrefix 而非 strings.Split(h," ")[1],知道后者会 panic

动手

  • go build ./... && go vet ./... 无输出;库里 password_hash$2a$10$ 开头、长度 60
  • 重复邮箱返回 409 且响应里没有 SQL 原文;80 字节密码返回 400 而不是 201
  • 不带 token → 401,带 token → 200,篡改 → 401,alg:none → 401
  • 登录失败两条路径响应完全相同、耗时同量级
  • bob 改 alice 的文章 → 403,alice 改自己的 → 200
  • 服务端日志里没有密码、token 或 hash

下一课预告

第 7 课把这套标准库实现迁移到 Gin。你会看到 Gin 帮你做了什么:c.ShouldBindJSON 替代手写解码 + 校验(但它的 binding 标签有个静默失效的坑);r.Group("/api/posts", authMw) 就是本课 4.7 那套装配的语法糖;c.Set("user", u) / c.Get("user") 替代 context.WithValue——注意它用的是 string key,正好是第 5 课说「不要这么做」的做法。为什么 Gin 敢这么干,而你不敢?

也会看到代价:Gin 的 c.Next() 和本课的 next.ServeHTTP(w, r) 是同一个洋葱模型,但它用索引 + 切片实现,边界情况下行为不同(比如中间件里 panic 之后 c.Abort() 还有没有用)。

本课写的 AuthService 一行都不用改——它不知道 HTTP 存在,这就是第 4 课分层的回报。要改的只有 middleware/auth.gohandler/