2026-04-13 · architecture
【系统架构设计百科】无状态设计:扩展的第一步也是最难的一步
有状态服务是水平扩展的最大障碍。本文从 Session、文件上传、WebSocket 三个典型场景出发,拆解状态外置模式、JWT 与服务端 Session 的架构级差异,以及 Sticky Session 的真实代价,给出从有状态到无状态的完整迁移路径。






















一个电商平台有 12 个微服务:用户服务、商品服务、订单服务、支付服务、库存服务、物流服务、搜索服务、推荐服务、通知服务、风控服务、营销服务、网关服务。每个服务都需要知道”当前请求是谁发的”。如果沿用单体时代的 Session 方案,用户登录后,Session 存在用户服务的内存里——其他 11 个服务怎么拿到这个 Session?
最直接的做法是把 Session 搬到 Redis。所有服务连同一个 Redis 集群,每次请求都拿 Session ID 去 Redis 查。这能跑,但问题也很明显:Redis 成了全局单点,每秒几万次的 Session 查询压在上面,一旦 Redis 挂了,所有服务同时失去认证能力。更麻烦的是,跨机房部署时 Redis 的同步延迟会导致用户在 A 机房登录后、B 机房的请求偶尔认证失败。
JWT(JSON Web Token)给出了另一个思路:把用户身份信息直接编码到令牌里,用数字签名保证不可篡改,每个服务自己验签就行,不需要查任何中心存储。听起来完美——但 JWT 签发之后就”活”在客户端了,服务端想让它失效怎么办?用户改了密码、管理员封禁了账号、令牌泄露需要紧急吊销——这些场景下,JWT 的”无状态”反而成了负担。
再往上走一层:公司有 5 条产品线,每条产品线有自己的用户体系。现在要求用户在一个产品登录后,访问其他产品不用再登录一次。这就是单点登录(SSO,Single Sign-On)的需求,OpenID Connect(OIDC)是当前的主流解决方案。
这篇文章围绕一个核心问题展开:JWT 到底比 Session 好在哪里?又差在哪里?SSO 的架构选型怎么做?
Session 认证(Session-based
Authentication)的核心思想很简单:服务端维护一张”会话表”,用户登录成功后,服务端生成一个随机的
Session ID,把用户信息存在服务端内存(或外部存储)中,然后把
Session ID 通过 Set-Cookie
头返回给浏览器。此后每次请求,浏览器自动带上这个
Cookie,服务端拿 Session ID
去会话表里查到对应的用户信息。
登录流程:
1. 客户端 POST /login {username, password}
2. 服务端验证凭据
3. 服务端生成 Session ID = "abc123",存储 sessions["abc123"] = {user_id: 42, role: "admin"}
4. 响应 Set-Cookie: SESSION_ID=abc123; HttpOnly; Secure; SameSite=Strict
5. 后续请求自动带 Cookie: SESSION_ID=abc123
6. 服务端查 sessions["abc123"] 得到用户信息
Session 数据必须存在某个地方,常见方案有三种:
进程内存储:Session 存在应用进程的内存里,最快、最简单。问题是进程重启后 Session 全部丢失,多实例部署时需要粘性会话(Sticky Session),负载均衡器必须把同一用户的请求路由到同一台机器。
集中式存储(Redis / Memcached):所有实例连同一个外部存储。解决了多实例问题,但引入了网络延迟和单点故障。Redis 的 Session 查询延迟通常在 0.5-2ms,对于高并发场景需要考虑 Redis 集群的容量和可用性。
数据库存储:Session 存在关系型数据库中。持久化能力最强,但查询性能最差。适合登录频率低、Session 生命周期长的后台管理系统。
Session 认证在单体架构下工作得很好,但在以下场景中遇到结构性困难:
跨域问题:Cookie 受同源策略(Same-Origin
Policy)约束。前端部署在 app.example.com,API
在 api.example.com,Cookie
默认不能跨子域传递。虽然可以设置
Domain=.example.com,但如果两个服务在完全不同的域名下(比如收购了另一家公司的产品),Cookie
就无能为力了。
移动端适配:原生移动应用没有浏览器的 Cookie 自动管理机制。虽然可以手动在 HTTP 头中携带 Session ID,但这本质上是在模拟 Cookie 行为,不如直接用 Token。
水平扩展成本:即使用了 Redis,Session 查询也是每请求一次的 IO 操作。在每秒 10 万请求的系统中,这意味着 Redis 每秒要处理 10 万次 GET 操作,加上 Session 续期的 EXPIRE 操作,总 QPS 可以达到 20 万以上。
微服务间调用:服务 A 调用服务 B 时,怎么传递用户身份?把 Cookie 透传过去?服务 B 也要连 Redis 查 Session?如果有 5 层服务调用链,每一层都查一次 Redis,延迟和 Redis 压力都会线性增长。
// Session 中间件示例 —— 每个请求都需要查 Redis
func SessionMiddleware(store *redis.Client) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("SESSION_ID")
if err != nil {
http.Error(w, "未认证", http.StatusUnauthorized)
return
}
// 每次请求都要查 Redis
data, err := store.Get(r.Context(), "session:"+cookie.Value).Result()
if err == redis.Nil {
http.Error(w, "Session 已过期", http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, "内部错误", http.StatusInternalServerError)
return
}
var session UserSession
if err := json.Unmarshal([]byte(data), &session); err != nil {
http.Error(w, "Session 数据损坏", http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), "user", &session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}JWT(JSON Web Token)是 RFC 7519
定义的一种紧凑的、自包含的令牌格式。它由三部分组成,用点号分隔:Header.Payload.Signature。
Header 声明令牌类型和签名算法:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024-01"
}Payload 携带声明(Claims):
{
"iss": "https://auth.example.com",
"sub": "user-42",
"aud": "order-service",
"exp": 1713052800,
"iat": 1713049200,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"roles": ["admin", "order_manager"],
"tenant_id": "acme-corp"
}Signature 是对前两部分的数字签名:
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
签名算法的选择直接影响架构:
HMAC(HS256):使用对称密钥,签发方和验证方共享同一个密钥。适合单服务场景,但在微服务架构中,每个服务都持有签名密钥意味着任何一个服务被攻破,攻击者就能伪造任意令牌。
RSA / ECDSA(RS256 / ES256):使用非对称密钥对。认证服务用私钥签发令牌,其他服务用公钥验证。即使某个业务服务被攻破,攻击者也无法伪造令牌,因为它没有私钥。公钥可以通过 JWKS(JSON Web Key Set)端点公开发布。
// JWT 签发 —— 使用 RSA 私钥
package auth
import (
"crypto/rsa"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
jwt.RegisteredClaims
Roles []string `json:"roles"`
TenantID string `json:"tenant_id"`
}
func IssueToken(privateKey *rsa.PrivateKey, userID string, roles []string, tenantID string) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://auth.example.com",
Subject: userID,
Audience: jwt.ClaimStrings{"order-service", "product-service"},
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
ID: generateJTI(),
},
Roles: roles,
TenantID: tenantID,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "key-2024-01"
return token.SignedString(privateKey)
}// JWT 验证中间件 —— 使用 RSA 公钥,无需查 Redis
package middleware
import (
"crypto/rsa"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
func JWTMiddleware(publicKey *rsa.PublicKey) func(http.Handler) http.Handler {
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithIssuer("https://auth.example.com"),
jwt.WithExpirationRequired(),
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "缺少令牌", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
token, err := parser.ParseWithClaims(tokenStr, &Claims{},
func(t *jwt.Token) (interface{}, error) {
return publicKey, nil
},
)
if err != nil {
http.Error(w, "令牌无效", http.StatusUnauthorized)
return
}
claims := token.Claims.(*Claims)
ctx := context.WithValue(r.Context(), "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}与 Session 相比,JWT 有几个架构层面的优势:
无状态验证:服务端只需要公钥就能验证令牌,不需要查任何外部存储。验证操作是纯 CPU 计算(RSA 验签大约 0.1-0.5ms),没有网络 IO。
天然适合微服务:网关验签后,可以把 JWT 原样透传给下游服务。每个下游服务都能独立验证令牌并提取用户信息,不需要回调认证服务。
跨域友好:JWT 通过
Authorization 头传递,不受 Cookie
的同源策略限制。前端、移动端、第三方集成都用同一套机制。
自包含用户上下文:Payload 中可以携带角色、租户 ID 等业务信息,下游服务不需要再查用户服务。
JWT 最大的问题恰恰来自它最大的优势——无状态。令牌一旦签发,在过期之前始终有效。以下场景都会暴露这个问题:
常见的缓解方案及其代价:
短过期时间 + Refresh Token:Access Token 的有效期设为 5-15 分钟,配合长期有效的 Refresh Token 进行续签。这不能解决即时吊销,但把”窗口期”缩短到了分钟级。
黑名单(Blocklist):在 Redis 中维护一个已吊销的 JWT ID(jti)列表。每次验证令牌时,除了验签还要查黑名单。这本质上是在 JWT 之上叠加了一层有状态检查,部分抵消了无状态的优势,但黑名单的数据量远小于完整的 Session 存储(只存被吊销的令牌,不存所有活跃会话)。
版本号机制:在用户表中维护一个
token_version 字段,JWT 的 Payload
中也携带一个版本号。验证令牌时,检查 Payload
中的版本号是否与数据库中的一致。用户改密码或被封禁时,递增数据库中的版本号。这个方案需要每次请求查一次用户表,但可以通过缓存缓解。
OAuth 2.0 本质上不是认证(Authentication)协议,而是授权(Authorization)协议。它解决的问题是:“用户如何授权第三方应用访问自己在某个平台上的资源,而不需要把密码告诉第三方。”
但在实践中,很多应用把 OAuth 2.0 当认证协议用——拿到 Access Token 后调用用户信息接口来确认用户身份。这种做法有安全隐患,因为 Access Token 的设计目标是授权而不是身份证明。
OpenID Connect(OIDC)在 OAuth 2.0 之上增加了一个身份层(Identity Layer),核心变化是引入了 ID Token——一个包含用户身份信息的 JWT。
OIDC 定义了三个关键概念:
sub(用户唯一标识)、iss(签发方)、aud(受众)、nonce(防重放)等标准声明。授权码流程(Authorization Code Flow)是安全性最高的 OAuth 2.0 流程。PKCE(Proof Key for Code Exchange,RFC 7636)是对授权码流程的安全增强,最初为移动端和 SPA 设计,现在已成为所有公开客户端的推荐实践。
sequenceDiagram
participant User as 用户浏览器
participant App as 客户端应用(RP)
participant AuthZ as 授权服务器(OP)
participant API as 资源服务器
Note over App: 生成 code_verifier(随机字符串,43-128字符)
Note over App: 计算 code_challenge = BASE64URL(SHA256(code_verifier))
User->>App: 点击"登录"
App->>User: 302 重定向到授权端点
Note right of App: GET /authorize?<br/>response_type=code<br/>&client_id=app-123<br/>&redirect_uri=https://app.example.com/callback<br/>&scope=openid profile email<br/>&state=xyz789<br/>&nonce=abc456<br/>&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8...<br/>&code_challenge_method=S256
User->>AuthZ: 重定向到授权服务器
AuthZ->>User: 展示登录页面
User->>AuthZ: 输入用户名和密码
AuthZ->>AuthZ: 验证凭据
AuthZ->>User: 302 重定向回应用
Note left of AuthZ: Location: https://app.example.com/callback?<br/>code=SplxlOBeZQQYbYS6WxSbIA<br/>&state=xyz789
User->>App: 携带授权码的回调请求
App->>App: 验证 state 参数防 CSRF
App->>AuthZ: POST /token
Note right of App: grant_type=authorization_code<br/>&code=SplxlOBeZQQYbYS6WxSbIA<br/>&redirect_uri=https://app.example.com/callback<br/>&client_id=app-123<br/>&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
AuthZ->>AuthZ: 验证授权码 + code_verifier
AuthZ-->>App: 返回 Access Token + ID Token + Refresh Token
App->>App: 验证 ID Token 签名和 nonce
App->>API: 携带 Access Token 请求资源
API->>API: 验证 Access Token
API-->>App: 返回受保护资源
| 流程 | 适用客户端 | 安全性 | 是否推荐 |
|---|---|---|---|
| 授权码 + PKCE | Web 应用、SPA、移动端 | 高 | 强烈推荐 |
| 授权码(无 PKCE) | 有后端的 Web 应用 | 中高 | 仅限机密客户端 |
| 隐式流程(Implicit) | SPA(已废弃) | 低 | 不推荐,已被 OAuth 2.1 移除 |
| 客户端凭据(Client Credentials) | 服务间调用(无用户上下文) | 中 | 适用于 M2M 场景 |
| 设备码(Device Code) | 智能电视、CLI 工具等无浏览器设备 | 中 | 特定场景适用 |
成熟的认证系统通常采用 Access Token + Refresh Token 的双令牌模型:
时间线:
t=0 用户登录,获得 AT(15min有效)+ RT(30天有效)
t=14min 客户端检测到 AT 即将过期
t=14min 客户端用 RT 请求新 AT
t=14min 服务端签发新 AT + 新 RT(Rotation),旧 RT 标记为已使用
t=28min 重复上述续签过程
...
t=30d RT 过期,用户需要重新登录
Refresh Token 轮换(Rotation)是一项关键安全机制:每次使用 Refresh Token 获取新的 Access Token 时,同时签发一个新的 Refresh Token,旧的立即作废。
这样做的好处是:如果 Refresh Token 被窃取,攻击者和合法用户都会尝试使用它。谁先用,另一方就会失败。当授权服务器检测到一个已使用过的 Refresh Token 被再次提交时,可以判定发生了令牌泄露,立即吊销该令牌家族(Token Family)下的所有令牌。
// Refresh Token 轮换实现
type TokenFamily struct {
FamilyID string
CurrentTokenID string
UserID string
UsedTokenIDs map[string]bool // 已使用的 Refresh Token
RevokedAt *time.Time
}
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
// 解析 Refresh Token
claims, err := s.parseRefreshToken(refreshToken)
if err != nil {
return nil, ErrInvalidToken
}
family, err := s.store.GetTokenFamily(ctx, claims.FamilyID)
if err != nil {
return nil, ErrInvalidToken
}
// 检测令牌重用:如果这个 RT 已经被用过,说明发生了泄露
if family.UsedTokenIDs[claims.ID] {
// 吊销整个令牌家族
s.store.RevokeTokenFamily(ctx, family.FamilyID)
s.alertSecurityTeam(ctx, family.UserID, "refresh_token_reuse_detected")
return nil, ErrTokenReuse
}
// 检查是否是当前有效的 RT
if family.CurrentTokenID != claims.ID {
return nil, ErrInvalidToken
}
// 标记当前 RT 为已使用
family.UsedTokenIDs[claims.ID] = true
// 签发新的令牌对
newAccessToken, err := s.issueAccessToken(family.UserID)
if err != nil {
return nil, err
}
newRefreshToken, newRefreshClaims, err := s.issueRefreshToken(family.UserID, family.FamilyID)
if err != nil {
return nil, err
}
// 更新令牌家族的当前 RT
family.CurrentTokenID = newRefreshClaims.ID
s.store.UpdateTokenFamily(ctx, family)
return &TokenPair{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
}, nil
}| 策略 | 即时性 | 性能开销 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 等待过期(不吊销) | 无(等 5-15 分钟) | 零 | 最低 | 低安全要求的内部系统 |
| JTI 黑名单(Redis) | 即时 | 每请求一次 Redis GET | 中 | 需要即时吊销的大多数系统 |
| 版本号校验 | 即时 | 每请求一次缓存/DB 查询 | 中 | 按用户粒度吊销 |
| 短期 AT + RT 轮换 | 分钟级 | 仅续签时查 DB | 低 | 对即时性要求不高的系统 |
| 事件驱动推送黑名单 | 秒级 | 本地内存查询 | 高 | 大规模微服务集群 |
最后一种方案值得展开:认证服务在吊销令牌时,通过消息队列(Kafka / NATS)广播吊销事件,各业务服务维护一个本地的黑名单缓存。验证令牌时先查本地缓存,命中则拒绝。这样既保持了验证的高性能(本地内存查询),又实现了秒级吊销。代价是引入了消息队列依赖和最终一致性。
graph LR
A[认证服务] -->|吊销事件| B[消息队列<br/>Kafka / NATS]
B --> C[订单服务<br/>本地黑名单]
B --> D[商品服务<br/>本地黑名单]
B --> E[支付服务<br/>本地黑名单]
B --> F[其他服务<br/>本地黑名单]
G[客户端请求] --> C
C -->|查本地黑名单| H{JTI 在黑名单中?}
H -->|是| I[拒绝请求]
H -->|否| J[验签通过,处理请求]
Refresh Token 的存储位置决定了整个认证系统的安全上限。选错了,其他所有安全措施都白费。
localStorage:
sessionStorage:
HttpOnly Cookie:
Secure 标记仅在 HTTPS 下传输SameSite=Strict 或
SameSite=Lax 防 CSRF(跨站请求伪造,Cross-Site
Request Forgery)Path 设为 Refresh Token 端点(如
/auth/refresh),避免在普通 API 请求中发送Set-Cookie: refresh_token=eyJhbGci...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh;
Max-Age=2592000
即使用了 HttpOnly Cookie,浏览器端存储令牌仍然不是最安全的方案。BFF(Backend for Frontend)模式把令牌管理完全移到服务端:
sequenceDiagram
participant Browser as 浏览器
participant BFF as BFF 服务
participant Auth as 认证服务
participant API as 业务 API
Browser->>BFF: POST /bff/login {username, password}
BFF->>Auth: POST /oauth/token(授权码交换)
Auth-->>BFF: Access Token + Refresh Token
Note over BFF: 令牌存储在 BFF 的服务端 Session 中<br/>浏览器只拿到一个 Session Cookie
BFF-->>Browser: Set-Cookie: BFF_SESSION=xxx; HttpOnly; Secure
Browser->>BFF: GET /bff/api/orders(带 Session Cookie)
BFF->>BFF: 从 Session 中取出 Access Token
BFF->>API: GET /api/orders(带 Bearer Token)
API-->>BFF: 订单数据
BFF-->>Browser: 订单数据
Note over BFF: AT 过期时,BFF 自动用 RT 续签<br/>浏览器完全不感知令牌的存在
BFF 模式的优势:
BFF 模式的代价:
iOS:使用 Keychain
Services,数据加密存储在系统级安全区域。设置
kSecAttrAccessible 为
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,确保令牌仅在设备解锁时可访问,且不会通过
iCloud 同步到其他设备。
Android:使用 EncryptedSharedPreferences(Jetpack Security 库),底层依赖 Android Keystore 系统进行密钥管理。API 23 及以上版本支持硬件级密钥保护。
// Android 安全存储 Refresh Token
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
public class SecureTokenStore {
private static final String PREF_NAME = "secure_auth_prefs";
private static final String KEY_REFRESH_TOKEN = "refresh_token";
private final SharedPreferences encryptedPrefs;
public SecureTokenStore(Context context) throws Exception {
MasterKey masterKey = new MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
encryptedPrefs = EncryptedSharedPreferences.create(
context,
PREF_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public void saveRefreshToken(String token) {
encryptedPrefs.edit()
.putString(KEY_REFRESH_TOKEN, token)
.apply();
}
public String getRefreshToken() {
return encryptedPrefs.getString(KEY_REFRESH_TOKEN, null);
}
public void clearRefreshToken() {
encryptedPrefs.edit()
.remove(KEY_REFRESH_TOKEN)
.apply();
}
}| 存储位置 | XSS 防护 | CSRF 防护 | 令牌暴露面 | 适用场景 |
|---|---|---|---|---|
| localStorage | 无 | 天然免疫 | 最大 | 不推荐 |
| sessionStorage | 无 | 天然免疫 | 大 | 不推荐 |
| HttpOnly Cookie | 有 | 需配合 SameSite | 中 | Web 应用(无 BFF) |
| BFF Session | 有 | 需配合 CSRF Token | 最小 | 高安全要求的 Web 应用 |
| iOS Keychain | 系统级隔离 | 不适用 | 小 | iOS 原生应用 |
| Android Keystore | 系统级隔离 | 不适用 | 小 | Android 原生应用 |
OAuth 2.0 授权码流程虽然是安全性最高的标准流程,但其复杂的重定向机制引入了多个攻击面。
攻击原理:攻击者构造一个指向授权服务器的链接,其中
redirect_uri
指向受害者的应用,但授权码绑定的是攻击者的账号。如果受害者点击了这个链接并完成了授权,攻击者的账号就会被绑定到受害者的应用账户上。
防御:state
参数。应用在发起授权请求前生成一个随机的 state
值,存储在用户的 Session 中。回调时验证返回的
state 与存储的一致。攻击者无法预测
state 值,因此无法构造有效的伪造请求。
// state 参数生成与验证
func generateState() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(b)
}
func (h *AuthHandler) StartLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
// 存储 state 到 Session(或加密 Cookie)
session := getSession(r)
session.Values["oauth_state"] = state
session.Save(r, w)
authURL := fmt.Sprintf(
"%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&scope=%s",
h.authServerURL, h.clientID, url.QueryEscape(h.redirectURI),
state, "openid profile email",
)
http.Redirect(w, r, authURL, http.StatusFound)
}
func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
session := getSession(r)
expectedState, ok := session.Values["oauth_state"].(string)
if !ok || r.URL.Query().Get("state") != expectedState {
http.Error(w, "state 参数不匹配,疑似 CSRF 攻击", http.StatusForbidden)
return
}
delete(session.Values, "oauth_state")
session.Save(r, w)
// 继续处理授权码交换...
}攻击原理:攻击者获取了一个合法的授权码(例如通过中间人攻击或 Referer 头泄露),然后把这个授权码注入到受害者的回调请求中。如果授权服务器不验证授权码与客户端的绑定关系,攻击者就能以受害者的身份获取令牌。
防御:PKCE。客户端在发起授权请求前生成一个
code_verifier(随机字符串),计算其 SHA256
哈希作为 code_challenge
发送给授权服务器。换取令牌时,客户端提交原始的
code_verifier,授权服务器验证其哈希值是否与之前收到的
code_challenge
匹配。攻击者即使拦截了授权码,也无法提供正确的
code_verifier。
// PKCE 实现
import (
"crypto/sha256"
"encoding/base64"
"crypto/rand"
)
func generateCodeVerifier() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func computeCodeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// 在发起授权请求时
func (h *AuthHandler) StartLoginWithPKCE(w http.ResponseWriter, r *http.Request) {
state := generateState()
codeVerifier := generateCodeVerifier()
codeChallenge := computeCodeChallenge(codeVerifier)
session := getSession(r)
session.Values["oauth_state"] = state
session.Values["code_verifier"] = codeVerifier
session.Save(r, w)
authURL := fmt.Sprintf(
"%s/authorize?response_type=code&client_id=%s&redirect_uri=%s"+
"&state=%s&scope=%s&code_challenge=%s&code_challenge_method=S256",
h.authServerURL, h.clientID, url.QueryEscape(h.redirectURI),
state, "openid+profile+email", codeChallenge,
)
http.Redirect(w, r, authURL, http.StatusFound)
}
// 在回调中交换令牌时
func (h *AuthHandler) ExchangeCode(code string, session *Session) (*TokenResponse, error) {
codeVerifier, ok := session.Values["code_verifier"].(string)
if !ok {
return nil, errors.New("缺少 code_verifier")
}
resp, err := http.PostForm(h.authServerURL+"/token", url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {h.redirectURI},
"client_id": {h.clientID},
"code_verifier": {codeVerifier},
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
var tokenResp TokenResponse
json.NewDecoder(resp.Body).Decode(&tokenResp)
return &tokenResp, nil
}攻击原理:攻击者修改授权请求中的
redirect_uri,使其指向攻击者控制的服务器。如果授权服务器对
redirect_uri
的校验不严格(例如只检查域名前缀),授权码就会被发送到攻击者的服务器。
防御措施:
redirect_uri
列表redirect_uri 中包含开放重定向(Open
Redirect)的路径# Keycloak 客户端配置示例
clients:
- clientId: "my-web-app"
redirectUris:
- "https://app.example.com/callback" # 精确匹配
# 以下配置有安全风险,不推荐:
# - "https://app.example.com/*" # 通配符过于宽泛
# - "https://*.example.com/callback" # 子域名通配符
webOrigins:
- "https://app.example.com"即使流程实现正确,令牌仍然可能通过以下渠道泄露:
Referer
头:用户从应用页面点击外部链接时,浏览器可能在
Referer 头中携带当前页面的 URL。如果 URL
中包含令牌(如隐式流程中的
Fragment),令牌就会泄露给外部站点。防御:使用
Referrer-Policy: no-referrer 响应头。
浏览器历史记录:URL 中的令牌会被记录在浏览器历史中。防御:避免在 URL 中传递敏感令牌,授权码在使用后应立即销毁。
日志系统:很多 Web
服务器和反向代理默认记录完整的请求 URL 和 Header。如果
Access Token 在 URL
参数中传递,它就会出现在日志里。防御:令牌只通过
Authorization 头或 POST Body 传递。
中间人攻击:HTTP 明文传输时,令牌可被网络设备截获。防御:强制使用 HTTPS,配置 HSTS(HTTP Strict Transport Security)。
| 攻击类型 | 攻击目标 | 防御机制 | OAuth 2.1 状态 |
|---|---|---|---|
| CSRF | 授权请求 | state 参数 | 必须 |
| 授权码注入 | 授权码交换 | PKCE | 必须(所有客户端) |
| Redirect URI 操纵 | 授权码 / 令牌 | 精确匹配 | 必须 |
| 令牌泄露(Referer) | Access Token | Referrer-Policy | 推荐 |
| 令牌泄露(日志) | Access Token | 仅用 Header 传递 | 推荐 |
| 令牌重放 | Access Token | 短过期 + Audience 校验 | 推荐 |
| Refresh Token 泄露 | Refresh Token | 轮换 + 绑定 | 推荐 |
| 混合攻击(Mix-Up) | 多 IdP 场景 | iss 参数 + AS Metadata | OAuth 2.1 新增 |
单点登录(Single Sign-On)要解决的问题很直接:用户登录一次,就能访问多个互相独立的应用系统。
SSO 不只是”少输几次密码”的便利性问题。在企业场景中,它还涉及:
基于 OIDC 的 SSO 是当前的主流方案。核心流程:
graph TD
subgraph OP ["身份提供方(OP)"]
LOGIN[登录页面]
SESSION[OP Session]
ISSUE[令牌签发]
end
subgraph Apps [应用集群]
APP_A[应用 A<br/>app-a.example.com]
APP_B[应用 B<br/>app-b.example.com]
APP_C[应用 C<br/>app-c.example.com]
end
USER[用户] -->|1. 首次访问| APP_A
APP_A -->|2. 未登录,重定向| LOGIN
LOGIN -->|3. 输入密码| SESSION
SESSION -->|4. 签发令牌| ISSUE
ISSUE -->|5. 重定向回| APP_A
USER -->|6. 访问| APP_B
APP_B -->|7. 未登录,重定向| SESSION
SESSION -->|8. 已有会话,直接签发| ISSUE
ISSUE -->|9. 重定向回| APP_B
OIDC SSO 的关键配置项:
# 应用端 OIDC 配置
oidc:
issuer: "https://sso.example.com/realms/company"
client_id: "app-a"
client_secret: "${APP_A_CLIENT_SECRET}"
redirect_uri: "https://app-a.example.com/auth/callback"
scopes: ["openid", "profile", "email"]
# ID Token 验证
id_token_signing_alg: "RS256"
jwks_uri: "https://sso.example.com/realms/company/protocol/openid-connect/certs"
# Session 管理
session_max_age: 3600
# 前端登出联动
post_logout_redirect_uri: "https://app-a.example.com"
# 后端登出联动(Back-Channel Logout)
backchannel_logout_uri: "https://app-a.example.com/auth/backchannel-logout"SAML(Security Assertion Markup Language)2.0 发布于 2005 年,至今仍然是很多企业 SSO 系统的基础。SAML 与 OIDC 的主要区别:
| 维度 | SAML 2.0 | OIDC |
|---|---|---|
| 数据格式 | XML(SAML Assertion) | JSON(JWT) |
| 传输方式 | HTTP POST Binding / Redirect Binding | HTTP 重定向 + 后端令牌交换 |
| 令牌大小 | 大(XML 签名 + 加密后通常 5-20KB) | 小(JWT 通常 1-3KB) |
| 移动端支持 | 差(XML 解析重,重定向流程对 WebView 不友好) | 好(JSON 轻量,原生 SDK 丰富) |
| 生态系统 | 企业 IT(Active Directory、Workday、Salesforce) | 互联网应用(Google、Auth0、Okta) |
| 实现复杂度 | 高(XML 签名、XML 加密、元数据管理) | 中(JWT 库成熟,Discovery 机制标准化) |
| 新项目推荐 | 仅在对接遗留系统时使用 | 新系统首选 |
是否需要对接企业级遗留系统(AD FS、Shibboleth)?
├── 是 → 必须支持 SAML,考虑同时支持 OIDC
│ 使用 Keycloak 或 Okta 作为身份代理(Identity Broker),
│ 对内提供 OIDC,对外桥接 SAML
└── 否 → 使用 OIDC
├── 团队规模小、预算有限 → Keycloak(开源自托管)
├── 不想自运维 → Auth0 / Okta / AWS Cognito(SaaS)
└── 超大规模(千万用户级) → 自研 OP + 开源库
SSO 的登录很优雅,登出却很头痛。用户在应用 A 点击”登出”,期望所有应用都同时登出。实现方式有两种:
Front-Channel Logout:OP 返回一个 HTML
页面,其中包含多个隐藏的 <iframe>,每个
iframe 指向一个 RP 的登出端点。浏览器并行加载这些 iframe
触发登出。问题:受浏览器第三方 Cookie
策略影响(Safari、Chrome 都在限制第三方
Cookie),可靠性越来越差。
Back-Channel Logout:OP 通过后端 HTTP 请求直接调用各 RP 的登出端点,发送一个 Logout Token(也是 JWT)。不依赖浏览器,可靠性高。问题:需要 RP 暴露一个可被 OP 访问的端点,对网络拓扑有要求。
// Back-Channel Logout 端点实现
func (h *AuthHandler) BackChannelLogout(w http.ResponseWriter, r *http.Request) {
logoutToken := r.FormValue("logout_token")
if logoutToken == "" {
http.Error(w, "缺少 logout_token", http.StatusBadRequest)
return
}
// 验证 Logout Token
token, err := h.verifyLogoutToken(logoutToken)
if err != nil {
http.Error(w, "无效的 logout_token", http.StatusBadRequest)
return
}
claims := token.Claims
// Logout Token 必须包含 sub 或 sid
if claims.Subject == "" && claims.SessionID == "" {
http.Error(w, "logout_token 缺少 sub 或 sid", http.StatusBadRequest)
return
}
// 清除本应用中对应用户的会话
if claims.Subject != "" {
h.sessionStore.RevokeByUserID(r.Context(), claims.Subject)
}
if claims.SessionID != "" {
h.sessionStore.RevokeBySessionID(r.Context(), claims.SessionID)
}
w.WriteHeader(http.StatusOK)
}某金融科技公司(以下称 FinCorp)有一个运行了 5 年的单体电商系统,用户量约 200 万。技术栈是 Java Spring Boot + MySQL + Redis。认证方案是经典的 Spring Session + Redis:用户登录后,Session 存在 Redis 中,所有请求通过 Cookie 携带 Session ID。
2023 年,公司决定将单体拆分为微服务,同时收购了一家做 B2B 供应链的公司,需要实现两套产品的 SSO。
b2b.supplycorp.com,主站在
www.fincorp.com,Cookie 无法跨域。团队评估了三个方案:
方案一:Redis Cluster 扩容 + 跨域 Cookie 代理
方案二:全面转 JWT,自建认证服务
方案三:部署 Keycloak 作为 OIDC Provider,各服务接入 OIDC
graph TB
subgraph 迁移前
MONO[单体应用] --> REDIS_OLD[Redis<br/>Session 存储]
CLIENT_OLD[浏览器] -->|Cookie: SESSION_ID| MONO
end
subgraph 迁移后
KC[Keycloak<br/>OIDC Provider]
GW[API 网关<br/>Kong / Envoy]
SVC_A[用户服务]
SVC_B[订单服务]
SVC_C[支付服务]
B2B[B2B 产品]
MOBILE[移动 App]
WEB[Web 前端]
WEB -->|OIDC 登录| KC
MOBILE -->|OIDC + PKCE| KC
B2B -->|OIDC 登录| KC
KC -->|ID Token + AT| WEB
KC -->|ID Token + AT| MOBILE
KC -->|ID Token + AT| B2B
WEB -->|Bearer Token| GW
MOBILE -->|Bearer Token| GW
B2B -->|Bearer Token| GW
GW -->|验签 + 转发| SVC_A
GW -->|验签 + 转发| SVC_B
GW -->|验签 + 转发| SVC_C
end
第一阶段(2 周):Keycloak 部署与用户数据迁移
第二阶段(3 周):网关层 JWT 验签
X-User-ID、X-User-Roles
等头传递给下游服务第三阶段(2 周):双跑验证
第四阶段(1 周):下线旧系统
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 认证延迟(P99) | 15ms(Redis 查询) | 0.3ms(JWT 本地验签) | -98% |
| Redis Session QPS | 50 万 | 0(已下线) | -100% |
| 认证系统可用性 | 99.9%(Redis 故障影响全局) | 99.99%(无中心依赖) | +0.09% |
| 跨产品 SSO | 不支持 | 支持 | 新能力 |
| MFA 支持 | 需自研 | Keycloak 内置 | 节省 3 人月 |
| 移动端接入耗时 | 需自行实现 Cookie 管理 | 使用 AppAuth SDK,1 周完成 | 大幅缩短 |
JWT 体积膨胀:初始设计在 JWT
中塞了太多业务信息(角色、权限、部门层级),导致令牌大小达到
4KB。每个 HTTP 请求的 Authorization 头都带 4KB
数据,在高并发场景下增加了带宽消耗。最终精简
Payload,只保留用户 ID、角色和租户
ID,细粒度权限由各服务自行查询。
时钟偏移:微服务部署在不同的物理机上,机器时钟存在几秒钟的偏差。JWT
的 exp 字段是精确到秒的 Unix
时间戳,时钟快的机器会提前拒绝令牌。解决方案:验证时允许 30
秒的时钟容差(Clock Skew),同时部署 NTP 同步。
Keycloak Session 爆炸:Keycloak 自身也维护用户的 SSO Session。默认配置下 Session 保留 30 天,200 万用户的 Session 数据占了 PostgreSQL 20GB 空间。调整为 Session 过期时间 8 小时 + 空闲超时 30 分钟后,数据量降到 500MB 以内。
| 维度 | Session + Redis | JWT(自管理) | OIDC(Keycloak / Auth0) |
|---|---|---|---|
| 状态管理 | 有状态(服务端存储) | 无状态(令牌自包含) | 混合(OP 有状态,RP 无状态) |
| 扩展性 | 受限于 Redis 容量和连接数 | 线性扩展,无中心瓶颈 | OP 需要集群化,RP 无限扩展 |
| 令牌吊销 | 即时(删除 Session) | 困难(需额外机制) | 较好(OP 管理 Session + RT 吊销) |
| 跨域支持 | 差(Cookie 同源限制) | 好(Header 传递) | 好(标准化重定向流程) |
| 移动端支持 | 差 | 好 | 好(AppAuth SDK) |
| SSO | 需自研 | 需自研 | 内置 |
| MFA | 需自研 | 需自研 | 内置 |
| 安全性 | 中(Session 固定、CSRF 风险) | 中高(签名保护,但吊销弱) | 高(标准化流程,防御机制完善) |
| 实现复杂度 | 低 | 中 | 中高(需理解 OAuth 2.0 / OIDC 协议) |
| 运维成本 | 低(Redis 运维) | 低(无基础设施依赖) | 中(Keycloak 集群运维) |
| 第三方集成 | 需自研适配 | 需自研适配 | 标准化,直接对接 |
| 审计日志 | 需自建 | 需自建 | 内置 |
| 适用阶段 | 单体应用、早期项目 | 微服务内部通信 | 企业级多产品体系 |
选 Session:项目是单体架构,用户量在 10 万以内,没有跨域需求,没有移动端,团队对 OAuth / OIDC 不熟悉。Session 是最简单、最成熟的方案,没有必要为了”先进”而引入复杂性。
选 JWT:微服务架构,服务间调用频繁,不需要 SSO。JWT 作为服务间传递用户上下文的载体非常合适。但要注意:JWT 适合做”内部通信令牌”,不适合做”面向终端用户的认证令牌”——后者建议搭配 BFF 模式或使用 OIDC。
选 OIDC:多产品线需要 SSO;需要对接第三方身份源(Google、企业 AD);有 MFA、审计等企业级需求;团队有能力运维 Keycloak 或预算购买 Auth0 / Okta。
认证架构的演进不是线性替代关系。Session 没有被 JWT “淘汰”,JWT 也没有被 OIDC “替代”。它们解决不同层次的问题,在实际系统中经常共存:
选型的关键不是”哪个更先进”,而是”当前系统的核心矛盾是什么”:
没有银弹。理解每种方案的结构性优势和结构性限制,才能做出合理的架构决策。
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-13 · architecture
有状态服务是水平扩展的最大障碍。本文从 Session、文件上传、WebSocket 三个典型场景出发,拆解状态外置模式、JWT 与服务端 Session 的架构级差异,以及 Sticky Session 的真实代价,给出从有状态到无状态的完整迁移路径。
2026-04-03 · authentication
OAuth2 授权协议详解:单点登录、第三方授权的实现原理与实战案例
2026-04-13 · architecture
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
2026-04-13 · architecture
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。