38.2 认证 (Session / JWT / OAuth2 / OIDC)

一句话

认证只回答一件事:“你是谁”的证据要放在哪里、由谁验、能被谁偷走——Session / JWT / OAuth2 / OIDC 的差别不是“新旧”,而是凭证的承载位置与信任模型,选错形态等于把攻击面从一个地方搬到另一个地方。

四个词,四件事

词 回答什么 别和谁混
Session 服务端记住“这个浏览器已登录”,客户端只拿一个不透明 ID 不是令牌,本身不含任何身份信息;账号与主体的关系见 49.1 统一账号体系
JWT 一种自包含的凭证格式(header.payload.signature) 不是协议,也不是“认证方案”;它只是“签过名的信封”
OAuth2 授权委托协议:让第三方在有限范围内代表用户访问资源 不是认证协议(“用 OAuth 登录”严格说是错的)
OIDC 在 OAuth2 上加一层身份:id_token(JWT)+ UserInfo 才是“认证”协议

🔑 记法:OAuth2 发的是“访问权”,OIDC 发的是“身份”。把 access_token 当身份用,是很多越权漏洞的起点——它可能是给某个第三方应用的、带 scope 的、不代表“这个人是谁”。

OIDC 的授权码 + PKCE 六步流程、令牌该放哪三种位置、单点登出怎么做,主场在 49.2 单点登录 (SSO);TTL、刷新轮转、撤销的实现参数在 49.5 登录态与令牌生命周期。本节只讲安全属性与攻击面。

凭证放哪:三种位置,三种暴露面

位置 谁能读到 主要攻击面 适用
HttpOnly + Secure + SameSite Cookie 只有浏览器自动携带,JS 读不到 CSRF(浏览器会自动带) 传统 Web、服务端渲染
Authorization: Bearer 头(存在内存) 页面 JS(如果存在 localStorage) XSS 读到即拿走 SPA 调 API、移动端
localStorage / sessionStorage 任意同源 JS XSS 一击致命,且无法 HttpOnly 不推荐存长期凭证
URL 参数 浏览器历史、Referer、日志、代理 泄露面最大,几乎必然泄露 只允许一次性短凭证
攻击 对 Cookie 方案 对 Bearer 方案
XSS 窃取凭证 读不到(HttpOnly),但可用页面发起请求 直接读走,可离线重放
CSRF 有风险,靠 SameSite + CSRF token 天然免疫(不会自动携带)
重放 有风险,靠短 TTL + 绑定 有风险,靠短 TTL + 绑定
跨域调用 受 CORS 与 SameSite 限制 简单(加头即可),也更容易被滥用

🔑 没有“更安全的位置”,只有“你把风险放在哪”:Cookie 把风险放在 CSRF,Bearer 把风险放在 XSS。选型要看你更怕哪一种——内容型站点(大量用户生成内容)更怕 XSS,后台管理系统更怕 CSRF。

JWT 的四个结构性风险

风险 原因 缓解
不可撤销 自包含、无状态,签发后服务端不查库就认 短 TTL + 刷新令牌;或维护撤销名单(等于又变有状态)
算法可被伪造 实现相信 header 里的 alg(none、HS/RS 混淆) 算法白名单写死在代码里,绝不读 header 决定算法
载荷可读 payload 只是 base64,不是加密 不放敏感信息(PII、权限细节、内部 ID 含义)
体积与放大 权限塞进 token → token 几 KB,每个请求都带 token 只放身份(sub、tenant、过期),权限在服务端查

第一条的细节(四种撤销路径、成本差三个数量级)在 49.5。第二条最容易写出漏洞,下面用代码把它复现出来。

代码:alg: none 伪造——一个能跑通的认证绕过

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"strings"
)

var secret = []byte("server-side-secret")

func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }

func mac(header, payload string) string {
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(header + "." + payload))
	return b64(m.Sum(nil))
}

// issue 正常签发:HS256 + 服务端密钥
func issue(claims map[string]any) string {
	h, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
	p, _ := json.Marshal(claims)
	hh, pp := b64(h), b64(p)
	return hh + "." + pp + "." + mac(hh, pp)
}

func decode(seg string) map[string]any {
	raw, _ := base64.RawURLEncoding.DecodeString(seg)
	var m map[string]any
	json.Unmarshal(raw, &m)
	return m
}

// verifyWeak:相信 header 里的 alg(真实世界的经典漏洞)
func verifyWeak(tok string) (map[string]any, error) {
	parts := strings.Split(tok, ".")
	if len(parts) != 3 {
		return nil, fmt.Errorf("格式错误")
	}
	hdr := decode(parts[0])
	if hdr["alg"] == "none" {
		return decode(parts[1]), nil // 没有签名就不校验
	}
	if mac(parts[0], parts[1]) != parts[2] {
		return nil, fmt.Errorf("签名不匹配")
	}
	return decode(parts[1]), nil
}

// verifySafe:算法白名单 + 常量时间比较
func verifySafe(tok string) (map[string]any, error) {
	parts := strings.Split(tok, ".")
	if len(parts) != 3 {
		return nil, fmt.Errorf("格式错误")
	}
	hdr := decode(parts[0])
	if hdr["alg"] != "HS256" {
		return nil, fmt.Errorf("拒绝:算法 %q 不在白名单", hdr["alg"])
	}
	if !hmac.Equal([]byte(mac(parts[0], parts[1])), []byte(parts[2])) {
		return nil, fmt.Errorf("签名不匹配")
	}
	return decode(parts[1]), nil
}

func main() {
	good := issue(map[string]any{"sub": "u-1", "role": "user"})
	fmt.Printf("合法令牌: %s...\n", good[:36])

	// 攻击者构造:把 alg 改成 none,签名段随便填
	forged := b64([]byte(`{"alg":"none","typ":"JWT"}`)) + "." +
		b64([]byte(`{"sub":"attacker","role":"admin"}`)) + ".x"

	fmt.Println("\n=== 同一枚伪造令牌,两个实现 ===")
	if c, err := verifyWeak(forged); err == nil {
		fmt.Printf("弱校验: 接受!sub=%v role=%v\n", c["sub"], c["role"])
	} else {
		fmt.Printf("弱校验: 拒绝(%v)\n", err)
	}
	if _, err := verifySafe(forged); err != nil {
		fmt.Printf("强校验: 拒绝(%v)\n", err)
	}

	// 另一种:篡改 payload,不改签名
	parts := strings.Split(good, ".")
	tampered := parts[0] + "." + b64([]byte(`{"sub":"u-1","role":"admin"}`)) + "." + parts[2]
	fmt.Println("\n=== 篡改载荷(签名不变)===")
	if _, err := verifySafe(tampered); err != nil {
		fmt.Printf("强校验: 拒绝(%v)\n", err)
	}
	fmt.Printf("(两个令牌长度只差 %d 字节:%d vs %d——不能靠长度发现篡改)\n",
		len(tampered)-len(good), len(good), len(tampered))
}

本机实测(macOS,Apple M1,Go 1.27):

合法令牌: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

=== 同一枚伪造令牌,两个实现 ===
弱校验: 接受!sub=attacker role=admin
强校验: 拒绝(拒绝:算法 "none" 不在白名单)

=== 篡改载荷(签名不变)===
强校验: 拒绝(签名不匹配)
(两个令牌长度只差 2 字节:117 vs 119——不能靠长度发现篡改)

读数:

现象 说明
弱校验接受了 alg:none 的伪造令牌 漏洞不在 JWT 格式,而在“由 token 自己决定怎么验自己”
强校验拒绝,报“算法不在白名单” 修法只有一条:算法与密钥由服务端代码固定,header 里的 alg 只用于拒绝
篡改 payload 后签名不匹配 签名保护的是“完整三段”,改一个字节都过不了
两个令牌长度只差 2 字节(117 vs 119) 不能靠长度或格式判断合法性,必须验签

⚠️ 同一类问题的另一种形态是 RS256 → HS256 混淆:服务端用公钥验 RS256,攻击者把 alg 改成 HS256,再用那把公钥当 HMAC 密钥签名。防御同样是“算法白名单 + 每种算法用各自的密钥类型”。

选型:四种场景,四种答案

场景 选什么 为什么
传统 Web(服务端渲染、同域) Session + HttpOnly Cookie 撤销即时(删服务端会话)、JS 拿不到,CSRF 用 SameSite + token 防
SPA + 独立 API(同公司) OIDC 授权码 + PKCE,access token 放内存,refresh 放 HttpOnly Cookie 避免 localStorage;刷新令牌受 Cookie 保护
第三方接入(开放平台) OAuth2 授权码 + PKCE + 短期 access token + scope 委托访问,最小权限,令牌可撤销
服务间(机器身份) mTLS 或 client credentials(+ 短 TTL) 没有“用户”,只有“服务身份”;密钥要能轮换(信封加密见 38.4 加密 (对称 / 非对称 / TLS / 密钥管理),运维见 13.4 密钥与凭证管理)

反模式

反模式 后果
用 access_token 当“用户身份”传给下游 下游拿到的是别人的授权凭证,scope 与身份混为一谈
JWT 里塞角色与权限列表 token 几 KB、权限变更要等过期、撤销不可能;角色该建多少见 38.3 授权 (RBAC / ABAC / 权限模型)
把 JWT 存 localStorage 且长期有效 一次 XSS = 长期账号接管(攻击面见 38.6 常见攻击 (注入 / XSS / CSRF / SSRF / 越权 / 重放))
自己实现 JWT 解析(拆分字符串、不验签) alg:none、typ 混淆、超长 payload 全部中招
Session 用可预测 ID / 不轮换 会话固定攻击;登录后 ID 不变
OAuth2 用隐式流(implicit) token 出现在 URL 与历史里,已被 OAuth 2.1 移除
只在前端校验“是否登录” 后端接口裸奔,改个 localStorage 就进后台

自检

Q1 为什么说“用 OAuth2 登录”这句话严格来说是错的?OIDC 补了什么?

▸ 答案:OAuth2 解决的是授权委托——第三方应用拿到 access_token,代表用户在某个 scope 内访问资源;它不保证“这个 token 背后是一个已认证的人”,也不告诉你“这个人是谁”。OIDC 在 OAuth2 之上增加了 id_token(一个 JWT,含 sub、iss、aud、exp 等身份声明)与 UserInfo 端点,才使“认证”成立。把 access_token 当身份用会出两类问题:① 它可能是 client credentials 换来的(没有用户);② 它的 scope 是给某个应用的,不能代表用户全部身份。

Q2 本节代码里的漏洞,为什么“给 token 加个黑名单”不是正确修法?

▸ 答案:黑名单解决的是撤销问题,而这里的问题是验签逻辑被 token 控制——攻击者根本不需要合法令牌,他自己造一个 alg:none 的就通过了。黑名单只对“曾经签发过的令牌”有效,伪造令牌不在名单里,照样通过。正确修法是“算法白名单 + 密钥由服务端固定 + 用 hmac.Equal 做常量时间比较”,与黑名单是两个正交的问题。

Q3 Cookie 方案怕 CSRF,Bearer 方案怕 XSS。一个内容社区(大量用户生成内容)该选哪个?

▸ 答案:优先 Cookie(HttpOnly + SameSite=Lax/Strict + CSRF token)。内容社区最大的风险是 XSS(用户提交的富文本、Markdown、外链),而 XSS 一旦能读 localStorage 就等于长期接管账号;Cookie 的 HttpOnly 让 XSS 读不到凭证,虽然仍可用页面发起请求,但 CSRF 有成熟的防御(SameSite + token)。反过来说,纯后台管理系统(无用户内容、有强 CSRF 风险)可以选 Bearer 放内存。

Q4 一个团队把用户角色放进 JWT,运营改角色后要求“立刻生效”。他们会遇到什么,怎么解?

▸ 答案:改角色不会影响已签发的 JWT,旧 token 里的 role 仍然是旧值,直到过期——所以“立刻生效”做不到。三条路:① 把 TTL 缩到分钟级(体验与刷新压力上升);② 引入令牌版本号(用户表里存 token_version,改角色时 +1,验签后比对,等于每次请求查一次库/缓存,回到有状态);③ token 只放身份,权限在服务端查(推荐,49.5 的结论)。第三条把“权限变更即时生效”与“无状态”解耦:token 只证明“你是谁”,权限每次按 sub 查缓存。

关联