JWT、Cookie 与 Session #
1. 先搞清两个词 #
| 概念 | 英文 | 回答的问题 | 例子 |
|---|---|---|---|
| 认证 | Authentication (AuthN) | 你是谁? | 输账号密码,证明你是张三 |
| 授权 | Authorization (AuthZ) | 你能干什么? | 张三能看本单位案件,不能删用户 |
本文聚焦认证:登录成功后,怎么让后续每个请求都被认出来。
2. 为什么需要鉴权机制:HTTP 是无记忆的 #
HTTP 协议本身无状态——服务端处理完一个请求就「失忆」,下一个请求来时并不知道它和上一个是同一个人发的。
sequenceDiagram
participant B as 浏览器
participant S as 服务端
B->>S: POST /login(账号密码)✅ 登录成功
B->>S: GET /profile ← 服务端:你是谁?🤔
于是需要一张「凭证」:登录时服务端发给你,之后每次请求都带上它,服务端凭它认人。怎么设计这张凭证,就分出了有状态与无状态两条路线。
3. 核心分歧:状态存哪? #
graph LR
subgraph 有状态 Session
A1[凭证只是一个ID] --> A2[(真正的身份数据<br/>存在服务端)]
end
subgraph 无状态 JWT
B1[凭证自带身份数据<br/>+ 签名防伪] --> B2[服务端不存任何东西<br/>验签即可信]
end
| 有状态(Session) | 无状态(JWT) | |
|---|---|---|
| 身份数据存哪 | 服务端(内存/Redis/DB) | 凭证本身(客户端持有) |
| 服务端校验方式 | 拿 ID 去查存储 | 本地验签,无需查存储 |
| 能否主动吊销 | ✅ 容易(删掉记录即可) | ❌ 难(签出去就有效,详见 §9) |
| 水平扩展 | 多实例需共享 Session 存储 | ✅ 任意实例验签即可,天然友好 |
| 每次请求开销 | 一次存储查询(IO) | 一次验签(纯 CPU) |
| 凭证体积 | 小(一个 ID) | 较大(携带全部字段,约 1~2KB) |
一句话:有状态把数据留在服务端、凭证只是钥匙;无状态把数据放进凭证、服务端只管验真伪。
4. 登录的第一关:密码怎么安全存储 #
凭证机制解决「之后怎么认人」,但登录那一刻先要核对密码。密码怎么存,是整个鉴权体系的地基——库一旦泄露,存得好坏天差地别。
4.1 三条铁律 #
- 绝不明文存储。数据库泄露 = 所有账号密码直接外泄,且用户常跨站复用密码。
- 不要用可逆加密存密码。加密能解密,密钥一旦泄露等同明文;密码核对根本不需要「还原」原文,只需「比对」。
- 要用单向哈希(Hash),且必须是「慢哈希」+「加盐」。
加密 vs 哈希:加密可逆(拿密钥能还原明文),用于「需要读回原文」的场景;哈希不可逆(只能算过去、推不回来),用于「只需验证是否一致」的场景。密码存储用哈希,不用加密。
4.2 为什么不能用 MD5 / SHA-256 直接存 #
MD5、SHA-1、SHA-256 是快速哈希,为「校验文件完整性」而生,算得飞快——这恰恰是存密码的灾难:
- GPU 每秒能算几十亿次 SHA-256,弱口令几秒被暴力穷举。
- 无盐时相同密码哈希值相同,攻击者可用彩虹表(预先算好的「明文→哈希」大字典)一键反查。
密码哈希需要的是故意算得慢、且可调难度的算法。
4.3 盐(Salt)是什么 #
盐 = 每个用户一个的随机字符串,拼到密码后再哈希,并和哈希结果一起存库(盐不需要保密)。
无盐: hash("123456") → 两个都用 123456 的用户,哈希完全相同 ❌
加盐: hash("123456" + "x7Fq2a") → 用户A
hash("123456" + "Kp9wLz") → 用户B 两人哈希完全不同 ✅
盐的作用:
- 废掉彩虹表:预算字典针对的是「裸密码」,加了随机盐就对不上了,攻击者只能对每个用户单独爆破。
- 隐藏撞密码:两个人设了同样的密码,加盐后哈希也不同,库里看不出来。
胡椒(Pepper):可选的「全局盐」,存在代码/配置/密钥管理服务里而不入库。库被脱但代码没泄时,多一层保护。盐入库、胡椒不入库,两者互补。
4.4 算法对比 #
| 算法 | 类别 | 内置盐 | 可调难度 | 抗 GPU/ASIC | 用于密码存储 |
|---|---|---|---|---|---|
| MD5 / SHA-1 | 快速哈希 | ❌ | ❌ | ❌ | ❌ 绝不可 |
| SHA-256 | 快速哈希 | ❌ | ❌ | ❌ | ❌ 绝不可 |
| PBKDF2 | 慢哈希(多轮迭代) | ✅ | ✅ 迭代次数 | 一般 | ⚠️ 可用,合规场景常见 |
| bcrypt | 慢哈希 | ✅ | ✅ cost 因子 | 较好 | ✅ 成熟稳妥,广泛使用 |
| scrypt | 慢哈希(耗内存) | ✅ | ✅ CPU+内存 | 好 | ✅ |
| Argon2id | 慢哈希(耗内存) | ✅ | ✅ 时间+内存+并行 | 最好 | ✅ 当前首选(OWASP 推荐) |
选型建议:新项目首选 Argon2id;用 bcrypt 也完全稳妥(成熟、库多、Identity Service 即用 bcrypt)。三者都自带盐、可调成本,绝不要回退到裸 MD5/SHA。
4.5 Go 示例(bcrypt) #
package main
import "golang.org/x/crypto/bcrypt"
// 注册/改密:把明文密码哈希后入库(绝不存明文)
func HashPassword(plain string) (string, error) {
// cost=12:成本因子,越大越慢越安全;bcrypt 会自动生成随机盐并拼进结果
hashed, err := bcrypt.GenerateFromPassword([]byte(plain), 12)
return string(hashed), err
}
// 登录:用库里的哈希校验用户输入,无需、也无法「解密」出原密码
func CheckPassword(hashedFromDB, plainInput string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedFromDB), []byte(plainInput))
return err == nil // nil 表示匹配
}
关键点:bcrypt 自动生成随机盐、把「盐 + cost + 哈希」打包进一个字符串,校验时再从中取出盐——所以你只需存这一个字符串,不必单独管理盐字段。
4.6 bcrypt 哈希串格式详解 #
HashPassword 存进库的就是这样一串(60 字符):
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
└┬┘└┬┘ └───────────────────┬──────────────────────────────┘
│ │ └ 22 字符盐 + 31 字符哈希(base64 编码,紧挨在一起)
│ └ cost 因子 = 12(2^12 轮)
└ 算法版本($2b$ = bcrypt)
正因盐就写在串里,换台机器、隔很久都能正确校验。Argon2 的哈希串同理(形如 $argon2id$v=19$m=65536,t=3,p=4$<盐>$<哈希>,把内存/时间/并行参数也编了进去)。
Identity Service 的
user表用password_hash varchar(256)存的正是这种自带盐的哈希串;密码明文与哈希都绝不写入日志。
5. Session:有状态方案 #
原理 #
- 登录成功,服务端生成一个随机、不可猜的
session_id,把用户身份存进服务端存储:session_id → {user_id, ...}。 - 把
session_id通过 Cookie 发回浏览器。 - 之后每个请求,浏览器自动带上 Cookie;服务端用
session_id查出身份。 - 登出 / 踢人:服务端直接删掉那条记录,凭证立即失效。
sequenceDiagram
participant B as 浏览器
participant S as 服务端
participant R as Session 存储(Redis)
B->>S: POST /login
S->>R: 存 sid_abc → {user_id:10086}
S-->>B: Set-Cookie: sid=sid_abc
Note over B: 之后每个请求自动带 Cookie
B->>S: GET /profile (Cookie: sid=sid_abc)
S->>R: 查 sid_abc
R-->>S: {user_id:10086}
S-->>B: 张三的资料
Go 示例 #
package main
import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
)
// 服务端 Session 存储(示例用内存 map;生产用 Redis)
type Session struct {
UserID int64
ExpiresAt time.Time
}
var (
store = map[string]Session{}
mu sync.RWMutex
)
func newSessionID() string {
b := make([]byte, 32)
rand.Read(b) // 必须用加密安全随机数,绝不能用可预测值
return hex.EncodeToString(b)
}
// 登录:建会话 + 下发 Cookie
func login(w http.ResponseWriter, userID int64) {
sid := newSessionID()
mu.Lock()
store[sid] = Session{UserID: userID, ExpiresAt: time.Now().Add(2 * time.Hour)}
mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: sid,
Path: "/",
HttpOnly: true, // JS 读不到,防 XSS 窃取
Secure: true, // 仅 HTTPS 传输
SameSite: http.SameSiteStrictMode, // 防 CSRF
MaxAge: 7200,
})
}
// 中间件:从 Cookie 取回身份
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("sid")
if err != nil {
http.Error(w, "未登录", http.StatusUnauthorized)
return
}
mu.RLock()
s, ok := store[c.Value]
mu.RUnlock()
if !ok || time.Now().After(s.ExpiresAt) {
http.Error(w, "会话失效", http.StatusUnauthorized)
return
}
// 这里拿到 s.UserID,可放入 context 传给后续 handler
next.ServeHTTP(w, r)
})
}
// 登出:删掉服务端记录,凭证立即作废
func logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("sid"); err == nil {
mu.Lock()
delete(store, c.Value)
mu.Unlock()
}
}
要点:session_id 本身没有任何含义,它只是一把钥匙;删掉服务端记录,钥匙就开不了门——这是有状态方案「能主动吊销」的根本原因。
Session 一致性问题:多实例下怎么保证「换台机器也认得」 #
上面的示例把 Session 存在单机内存里,单实例没问题;可一旦服务多实例 + 负载均衡部署,麻烦就来了:用户在实例 A 登录(session 存在 A 的内存),下一个请求被负载均衡分到了实例 B——B 内存里根本没有这条 session,于是「明明登录了却被当成未登录」。这就是 Session 的一致性问题:同一用户的会话状态,必须让所有实例都看到同一份。
graph TB
U[用户已在 A 登录] --> LB{负载均衡}
LB -->|下次请求轮询到 B| B[实例 B 内存无此 session ❌]
LB -->|轮询到 A| A[实例 A 有 session ✅]
三种解法,逐级更优:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 粘性会话 (Sticky Session) |
负载均衡按来源(IP/Cookie)把同一用户固定打到同一实例 | 改动小、不引依赖 | 实例宕机→该实例 session 全丢;扩缩容/重启后分配失衡;负载不均 |
| Session 复制 (Replication) |
实例之间互相同步各自的 session | 任意实例都能处理 | N 个实例两两复制,网络与内存开销随实例数膨胀,不易扩展 |
| 集中式存储 ✅ (Redis/DB) |
所有实例把 session 读写到同一个外部存储(如 Redis) | 实例彻底无状态、可随意扩缩容、宕机不丢 session | 引入外部依赖与一次网络往返;Redis 自身要做高可用 |
主流选择是集中式存储(Redis):把前面 Go 示例里的内存 map 换成 Redis 读写即可,所有实例共享一份会话,一致性天然成立。采用集中式存储后,仍要留意两点一致性细节:
- 并发写覆盖:同一 session 被多个请求并发修改(如同时刷新过期时间)可能互相覆盖;多数场景 session 只读、影响小,必要时用 Redis 原子操作或加锁。
- 主从复制延迟:Redis 主从架构下,刚写主库的 session 立刻从从库读可能读不到(短暂不一致);对登录态这类关键数据,读写建议走主库或开启读主。
反过来看,无状态 JWT 根本没有这个问题——它不在服务端存任何会话,任意实例靠验签即可独立认人,天生适配多实例水平扩展。代价是「吊销难」(见 §9)。要不要承受 Session 的一致性成本,正是有状态 / 无状态选型的关键权衡之一。
6. Cookie:凭证的「运输工具」 #
Cookie 常和 Session 混为一谈,其实它只是浏览器自动存储与回传一小段数据的机制,是凭证的载体,本身不绑定任何方案——Session ID 能放 Cookie,JWT 也能放 Cookie。
服务端通过响应头 Set-Cookie 下发,浏览器之后对同源请求自动带上 Cookie 请求头,无需前端代码干预。
6.1 格式详解:下发与回传是两个头 #
① 服务端下发——Set-Cookie 响应头(一个 Cookie 一行,名=值 后跟分号分隔的属性):
HTTP/1.1 200 OK
Set-Cookie: sid=abc123xyz; Path=/; Domain=example.com; Max-Age=7200; HttpOnly; Secure; SameSite=Strict
Set-Cookie: theme=dark; Path=/; Max-Age=31536000
逐字段:
Set-Cookie: sid = abc123xyz ; Path=/ ; Domain=example.com ; Max-Age=7200 ; HttpOnly ; Secure ; SameSite=Strict
└┬┘ └───┬───┘ └──┬──┘ └──────┬──────┘ └────┬────┘ └───┬───┘ └──┬─┘ └─────┬──────┘
名 值(凭证) 生效路径 生效域名 存活秒数 禁JS读 仅HTTPS 防跨站携带
② 浏览器回传——Cookie 请求头(只带 名=值,多个用 ; 拼接,不回传任何属性):
GET /profile HTTP/1.1
Host: example.com
Cookie: sid=abc123xyz; theme=dark
注意:HttpOnly、Secure、Path 等属性只是给浏览器看的「投递规则」,回传时一律不带——服务端只收得到 名=值。
6.2 关键安全属性(务必设对) #
| 属性 | 作用 |
|---|---|
HttpOnly |
禁止 JS(document.cookie)读取,防 XSS 窃取凭证 |
Secure |
只在 HTTPS 下发送,防中间人窃听 |
SameSite=Strict/Lax |
限制跨站请求携带 Cookie,防 CSRF |
Max-Age / Expires |
有效期;不设则为关闭浏览器即失效的会话 Cookie |
Path / Domain |
限定 Cookie 生效的路径与域名范围 |
Cookie vs 其它存法:把令牌放 Cookie,胜在自动携带 + 可设
HttpOnly防 XSS,但需防 CSRF;放localStorage+ 手动加Authorization头则相反——不怕 CSRF,但 JS 可读、易受 XSS 影响。
6.3 禁用 Cookie 后,Session 还能用吗? #
能,但要换一种方式携带 session_id。
关键在于:Session 的本质是「服务端存状态 + 客户端每次带回那个 ID」。Cookie 只是携带 ID 最省事的工具,不是唯一工具。禁用 Cookie 只是断了默认的运输通道,只要还能把 session_id 送回服务端,Session 机制照常工作。
替代携带方式:
| 方式 | 做法 | 说明 |
|---|---|---|
| 自定义请求头 | 前端把 sid 放进 Authorization 或自定义头手动发送 |
SPA / App 最常用,干净可控 |
localStorage + 头 |
sid 存 localStorage,每次请求 JS 读出来塞进头 | 同上;注意 XSS 风险 |
| URL 重写 | 把 sid 拼进 URL,如 /profile;jsessionid=abc123 |
传统 Java Web 的退路,但 sid 易泄露在浏览器历史、日志、Referer,不推荐 |
| 隐藏表单字段 | 每个表单塞一个 <input type=hidden> 带 sid |
老式多页应用,仅对表单提交有效 |
graph LR
SID["session_id"] -->|默认| CK[Cookie 自动带]
SID -->|Cookie 被禁| H[自定义 Header 手动带]
SID -->|Cookie 被禁| U[URL 重写, 不推荐]
CK --> SRV[(服务端按 sid 查身份)]
H --> SRV
U --> SRV
结论与边界:
- 浏览器禁用 Cookie + 没做任何替代 → 传统依赖 Cookie 的 Session 直接断(最常见的「禁用 Cookie 就登录不了」就是这种)。
- 改用 Header / localStorage 携带 sid → Session 照常可用,这也是前后端分离项目的普遍做法。
- JWT 同理:JWT 若放在 Cookie 里,禁用 Cookie 一样受影响;但 JWT 通常放
Authorization: Bearer头(不依赖 Cookie),所以天然不受「禁用 Cookie」影响——这也是无状态方案在 SPA / App 场景更受欢迎的原因之一。
7. JWT:无状态方案 #
JWT(JSON Web Token)是一段自带身份信息、并用签名防篡改的字符串。服务端不需要存任何东西,靠验签就能确认它是自己签发且未被改动的。
7.1 结构:三段,用 . 连接
#
eyJhbGciOiJSUzI1NiJ9 . eyJzdWIiOiIxMDA4NiIsImV4cCI6MTcuLi59 . SflKxw...(签名)
└──── Header ────┘ └──────── Payload ────────┘ └── Signature ──┘
算法/类型 身份数据(claims) 防伪签名
- Header:声明签名算法(如
RS256)。 - Payload:身份数据(claims),如
sub(用户ID)、exp(过期时间)、自定义的权限/单位等。⚠️ 仅 Base64 编码、未加密,任何人都能解开看,故别放密码等敏感信息。 - Signature:用密钥对前两段算出的签名。改动任何一个字符,验签都会失败——这是防伪的关键。
7.2 格式详解:拿真实 token 拆开看 #
一个完整 JWT 就是 base64url(Header) + "." + base64url(Payload) + "." + base64url(Signature)。注意是 base64url 编码,不是加密——任何人都能解码出 Header 和 Payload 的明文 JSON(可在 jwt.io 在线解)。
原始 token(为可读已折行,实际是一整行无换行):
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0
.
eyJpc3MiOiJpZGVudGl0eS1zZXJ2aWNlIiwic3ViIjoiMTAwODYiLCJleHAiOjE3MTg5MDAwMDAsImp0aSI6ImE3ZjMiLCJ1c2VybmFtZSI6InpoYW5nX3NhbiJ9
.
NHVQ7r8...(RSA 私钥对前两段算出的签名,约 350 字节)
第一段 Header,base64url 解码后是:
{
"alg": "RS256", // 签名算法
"typ": "JWT", // 类型
"kid": "key-1" // 密钥 ID,用于多公钥时定位该用哪把验签(密钥轮换)
}
第二段 Payload,base64url 解码后是:
{
"iss": "identity-service", // 签发方
"sub": "10086", // 主体:用户 ID
"exp": 1718900000, // 过期时间(Unix 秒)
"jti": "a7f3", // 令牌唯一 ID(吊销/审计用)
"username": "zhang_san",
"permission_codes": ["system:user:list"] // 自定义业务字段
}
第三段 Signature:对 base64url(Header).base64url(Payload) 用密钥按 alg 算出的签名,不可解码成 JSON,只用于验真伪。
再次强调:Payload 是可读的(任何人 base64 解码即见明文)。它防的是「篡改」(改了签名就失效),不是「偷看」。敏感数据别往里放。
7.3 签名算法:HS256 vs RS256 #
| HS256(对称) | RS256(非对称) | |
|---|---|---|
| 密钥 | 签发、验签共用同一把密钥 | 私钥签发 / 公钥验签 |
| 适用 | 单服务自签自验 | 一方签发、多方验签(公钥可公开) |
| 风险 | 验签方都要持有密钥,泄露即灾难 | 公钥泄露无害,运维门槛低 |
本平台用 RS256:Identity Service 用私钥签发,网关等用公钥验签——公钥泄露无影响,且便于密钥轮换(靠 Header 里的 kid 区分新旧公钥)。
7.4 工作流程 #
sequenceDiagram
participant B as 浏览器
participant S as 服务端
B->>S: POST /login
Note over S: 用私钥签发 JWT<br/>(不存任何东西)
S-->>B: { access_token: "eyJ..." }
Note over B: 保存 token
B->>S: GET /profile<br/>Authorization: Bearer eyJ...
Note over S: 本地验签 + 查 exp<br/>无需查数据库 ✅
S-->>B: 张三的资料
7.5 Go 示例(github.com/golang-jwt/jwt/v5)
#
package main
import (
"crypto/rsa"
"time"
"github.com/golang-jwt/jwt/v5"
)
// 自定义 claims:标准字段 + 业务字段
type Claims struct {
Username string `json:"username"`
PermissionCodes []string `json:"permission_codes"`
jwt.RegisteredClaims // 内含 sub / exp / iat / iss / jti 等标准字段
}
// 签发:服务端用私钥签,签完即可丢弃,不留存
func IssueToken(priv *rsa.PrivateKey, userID string, perms []string) (string, error) {
claims := Claims{
Username: "zhang_san",
PermissionCodes: perms,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
Issuer: "identity-service",
ID: newUUID(), // jti:令牌唯一ID,吊销/审计用
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), // 短有效期,缩小被盗窗口
},
}
return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(priv)
}
// 验签:任意服务持公钥即可校验,无需访问数据库
func ParseToken(pub *rsa.PublicKey, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{},
func(t *jwt.Token) (any, error) {
// 强制校验算法,防「alg=none」等降级攻击
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrSignatureInvalid
}
return pub, nil
},
)
if err != nil || !token.Valid { // jwt 库会自动校验 exp 等
return nil, err
}
return token.Claims.(*Claims), nil
}
对比 Session 看本质:验签全程没有任何存储查询——身份就写在 token 里,签名保证它没被篡改。这就是「无状态」省掉一次 IO、且天然支持多实例的来源。
8. 一张图看懂两条路线 #
graph TB
L[用户登录成功] --> Q{凭证里放什么?}
Q -->|放一个随机ID| SES[Session 有状态]
Q -->|放身份数据+签名| JWT[JWT 无状态]
SES --> SES2[服务端存储记录映射<br/>每次请求查存储]
JWT --> JWT2[服务端不存<br/>每次请求验签]
SES2 --> SES3[✅吊销容易<br/>❌多实例要共享存储]
JWT2 --> JWT3[✅扩展友好/无IO<br/>❌吊销难/凭证较大]
9. 无状态的代价:令牌吊销难题 #
Session 删一条记录就能踢人,JWT 没有「记录」可删——只要没过期,签出去的令牌就一直有效。登出、改密、停用、踢下线都会遇到这个问题。常见缓解手段:
- 短有效期(access token):让令牌很快自然过期,缩小被盗/滞后窗口(代价:刷新更频繁)。
- 黑名单(jti blacklist):给每个令牌一个唯一
jti,要吊销时把jti记入黑名单;验签后再查一下黑名单,命中即拒。这相当于给无状态方案打了个「有状态补丁」。 - 双令牌(access + refresh):
graph LR
A["access token<br/>短期(如2h)<br/>带全部身份, 频繁使用"]
R["refresh token<br/>长期(如7天)<br/>只用来换新 access"]
A -.过期.-> R
R -->|/refresh 重查最新身份| A
短期 access 降低泄露风险;长期 refresh 只在续期时用一次,刷新时服务端可重查最新权限重新签发,缓解「权限改了但旧令牌仍是旧数据」的问题。
10. 本平台实践:Identity Service 的选型 #
Identity Service 选了无状态 JWT(RS256)+ jti 黑名单,正是上面各方案的组合落地(完整设计见 services/identity-service/identity-service.md §2):
| 决策 | 选择 | 为什么 |
|---|---|---|
| 路线 | 无状态 JWT | 不依赖 Redis、网关可水平扩展、验签纯本地无 IO |
| 签名 | RS256 非对称 | Identity 私钥签发,网关公钥验签;公钥经 JWKS 端点分发,支持密钥轮换 |
| 令牌 | access(2h) + refresh(7天) | 短 access 缩小吊销延迟;refresh 滚动刷新(换新并拉黑旧的)防滥用 |
| 吊销 | jti 黑名单 | 登出/改密/踢人时把 jti 入黑名单,网关验签后查询(短 TTL 缓存),近线生效 |
| 密码 | bcrypt 哈希 | user.password_hash 存自带盐的 bcrypt 串;明文与哈希均不入日志 |
| 校验位置 | 全在网关 | 网关验签 + 查黑名单后,把身份信息解析成 X-User-Id 等头注入下游,兄弟微服务只读头、不感知 JWT |
sequenceDiagram
participant B as 浏览器
participant G as 网关
participant I as Identity Service
participant Biz as 兄弟微服务
B->>G: 请求 + JWT
Note over G: ①公钥验签(本地)<br/>②查 jti 黑名单(gRPC,带缓存)
G->>I: IsJtiRevoked(jti)?
I-->>G: 未吊销
Note over G: ③解出身份, 注入 X-User-Id 等头
G->>Biz: 转发(已带可信身份头)
Biz-->>B: 业务结果(不感知 JWT)
这套组合的取舍很典型:用无状态换来了扩展性与低运维成本,再用「短有效期 + jti 黑名单 + 滚动刷新」把无状态最大的短板(吊销难)补到业务可接受的程度。
11. 小结 #
- HTTP 无记忆,登录后靠凭证认人;凭证里放什么,决定了有状态还是无状态。
- 密码存储是地基:单向哈希 + 每用户随机盐 + 慢哈希算法(Argon2id / bcrypt),绝不明文、绝不裸 MD5/SHA。
- Session(有状态):凭证是把钥匙,数据在服务端 → 吊销容易,但多实例有一致性问题(需 Redis 集中存储等方式让各实例共享会话)。
- JWT(无状态):凭证自带数据 + 签名 → 扩展友好、无 IO,但吊销难、体积大、Payload 可被解码看(不能放敏感信息)。
- Cookie 只是凭证的运输工具,和方案无关;用它就务必设好
HttpOnly/Secure/SameSite。禁用 Cookie 后,只要改用 Header 等方式携带 ID,Session 仍可用。 - 没有银弹:内网、多实例、低运维优先 → 选 JWT 并用黑名单/短有效期补吊销,正如本平台 Identity Service。