Go 博客教程 · 第 6 课 / 10
第 6 课:用户注册登录与 JWT 认证
到现在为止,你的博客系统里任何人都能删任何文章。posts.author_id 从第 3 课起就在 schema 里躺着,一直是 NULL。这一课把它填上。
这也是整套教程里唯一一课,写错了会造成真实损失。密码存储和 token 校验属于「跑起来了不代表是对的」的领域——一个能正常登录的系统,可能同时把全站密码送给了拖库的人。所以每一处安全设计我都会先说攻击场景,再说怎么防。
① 本课目标
写出注册(bcrypt 哈希 + 唯一约束冲突友好化)、登录(时序攻击防护 + 签发 JWT)、受保护接口 /api/auth/me、RequireAuth 与 OptionalAuth 两个鉴权中间件,以及「只有作者能改自己的文章」这条授权规则。
并能回答:为什么 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.NumericDate,Valid() 被移除。照抄 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.sql 里 CHAR(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 字节密码会让 hash 是 nil,你往数据库存一个空字符串。这个用户从此永远无法登录(CompareHashAndPassword("", pw) 恒失败),而注册接口返回 200,你完全不知道出了问题。
两个细节:
- 必须按字节数
len(s)判断,不是字符数。 中文在 UTF-8 下每字 3 字节,24 个汉字就到 72 字节。按字符数卡 72 的话,中文用户会撞上 bcrypt 的 error。Go 的len(string)返回的正是字节数,直接用就对。 - 前端也要卡,但后端的校验才有效——攻击者直接打 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(私钥签、公钥验),而公钥是公开的(本来就该公开)。攻击者:
- 拿到你的公钥;
- 把 header 改成
{"alg":"HS256"}; - 把那份公钥当作 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
- RFC 6750 规定 scheme 应当大小写不敏感,但绝大多数实现(含本课)只认
Bearer。要对接奇怪客户端就得用strings.EqualFold。 - 两个空格会返回带前导空格的 token,必然校验失败——可加
strings.TrimSpace兜底。 - 不要用
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 层。 三条理由:
- service 是业务规则的家。 这和「文章必须有标题」是同一类东西。第 4 课定的规矩就是 handler 只做 HTTP ↔ service 的翻译。
- 不能被绕过。 将来加 gRPC 接口、CLI 工具、后台批处理,它们都不经过 HTTP handler。放 service 层所有入口自动受保护;放 handler 层每加一个入口就要重实现一遍,迟早漏一个。
- 它需要数据。 判断「是不是作者」必须先查出文章看
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 entry 或 uk_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_expired 与 invalid_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
验收:Create 用 res.LastInsertId() 回填 u.ID;FindByEmail/FindByID 没找到时返回 (nil, nil) 而不是 error;其他错误用 fmt.Errorf("...: %w", err) 包装,保证 errors.As 能挖到 *mysql.MySQLError;5.1、5.2 的 curl 全部通过。
陷阱:若用 errors.New(...) 而不是 %w,错误链就断了,translateDupErr 的 errors.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 type(type 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 头。
必须处理三点:
- 只对失败计数,登录成功要清零——否则正常用户手滑一次就被锁;
- 用
sync.Mutex或sync.Map保护并发。每请求一个 goroutine,裸 map 并发写会fatal error: concurrent map writes让进程崩溃,而且这个 fatal error 无法被recover捕获——第 5 课的Recover救不了你; - 要能清理过期条目,否则 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失败时没有调用next;OptionalAuth失败时不中断请求 - 我的 service 层返回领域哨兵错误,不返回
APIError——它不认识 HTTP - 我给第 4 课的
httpStatusFor追加了 401/403/409 分支,没有重写 - 我知道
middleware不能 importhandler(会 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.go 和 handler/。