一个电商平台有 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 认证模型
1.1 基本原理
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"] 得到用户信息
1.2 服务端存储方案
Session 数据必须存在某个地方,常见方案有三种:
进程内存储:Session 存在应用进程的内存里,最快、最简单。问题是进程重启后 Session 全部丢失,多实例部署时需要粘性会话(Sticky Session),负载均衡器必须把同一用户的请求路由到同一台机器。
集中式存储(Redis / Memcached):所有实例连同一个外部存储。解决了多实例问题,但引入了网络延迟和单点故障。Redis 的 Session 查询延迟通常在 0.5-2ms,对于高并发场景需要考虑 Redis 集群的容量和可用性。
数据库存储:Session 存在关系型数据库中。持久化能力最强,但查询性能最差。适合登录频率低、Session 生命周期长的后台管理系统。
1.3 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 认证模型
2.1 结构解析
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
)
2.2 对称签名与非对称签名
签名算法的选择直接影响架构:
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))
})
}
}2.3 JWT 的结构性优势
与 Session 相比,JWT 有几个架构层面的优势:
无状态验证:服务端只需要公钥就能验证令牌,不需要查任何外部存储。验证操作是纯 CPU 计算(RSA 验签大约 0.1-0.5ms),没有网络 IO。
天然适合微服务:网关验签后,可以把 JWT 原样透传给下游服务。每个下游服务都能独立验证令牌并提取用户信息,不需要回调认证服务。
跨域友好:JWT 通过
Authorization 头传递,不受 Cookie
的同源策略限制。前端、移动端、第三方集成都用同一套机制。
自包含用户上下文:Payload 中可以携带角色、租户 ID 等业务信息,下游服务不需要再查用户服务。
2.4 JWT 的核心困境:吊销
JWT 最大的问题恰恰来自它最大的优势——无状态。令牌一旦签发,在过期之前始终有效。以下场景都会暴露这个问题:
- 用户修改密码后,旧令牌应该立即失效
- 管理员封禁某个账号
- 令牌泄露,需要紧急吊销
- 用户主动登出
常见的缓解方案及其代价:
短过期时间 + Refresh Token:Access Token 的有效期设为 5-15 分钟,配合长期有效的 Refresh Token 进行续签。这不能解决即时吊销,但把”窗口期”缩短到了分钟级。
黑名单(Blocklist):在 Redis 中维护一个已吊销的 JWT ID(jti)列表。每次验证令牌时,除了验签还要查黑名单。这本质上是在 JWT 之上叠加了一层有状态检查,部分抵消了无状态的优势,但黑名单的数据量远小于完整的 Session 存储(只存被吊销的令牌,不存所有活跃会话)。
版本号机制:在用户表中维护一个
token_version 字段,JWT 的 Payload
中也携带一个版本号。验证令牌时,检查 Payload
中的版本号是否与数据库中的一致。用户改密码或被封禁时,递增数据库中的版本号。这个方案需要每次请求查一次用户表,但可以通过缓存缓解。
三、OAuth 2.0 与 OIDC
3.1 OAuth 2.0 解决什么问题
OAuth 2.0 本质上不是认证(Authentication)协议,而是授权(Authorization)协议。它解决的问题是:“用户如何授权第三方应用访问自己在某个平台上的资源,而不需要把密码告诉第三方。”
但在实践中,很多应用把 OAuth 2.0 当认证协议用——拿到 Access Token 后调用用户信息接口来确认用户身份。这种做法有安全隐患,因为 Access Token 的设计目标是授权而不是身份证明。
3.2 OIDC:OAuth 2.0 + 身份层
OpenID Connect(OIDC)在 OAuth 2.0 之上增加了一个身份层(Identity Layer),核心变化是引入了 ID Token——一个包含用户身份信息的 JWT。
OIDC 定义了三个关键概念:
- OP(OpenID Provider):身份提供方,负责认证用户并签发 ID Token。例如 Google、Okta、Keycloak。
- RP(Relying Party):依赖方,即你的应用。
- ID Token:一个 JWT,包含
sub(用户唯一标识)、iss(签发方)、aud(受众)、nonce(防重放)等标准声明。
3.3 授权码流程 + PKCE
授权码流程(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: 返回受保护资源
3.4 各 OAuth 2.0 流程适用场景
| 流程 | 适用客户端 | 安全性 | 是否推荐 |
|---|---|---|---|
| 授权码 + PKCE | Web 应用、SPA、移动端 | 高 | 强烈推荐 |
| 授权码(无 PKCE) | 有后端的 Web 应用 | 中高 | 仅限机密客户端 |
| 隐式流程(Implicit) | SPA(已废弃) | 低 | 不推荐,已被 OAuth 2.1 移除 |
| 客户端凭据(Client Credentials) | 服务间调用(无用户上下文) | 中 | 适用于 M2M 场景 |
| 设备码(Device Code) | 智能电视、CLI 工具等无浏览器设备 | 中 | 特定场景适用 |
四、Token 生命周期管理
4.1 双令牌模型
成熟的认证系统通常采用 Access Token + Refresh Token 的双令牌模型:
- Access Token:短期有效(5-15 分钟),用于访问资源。轻量、高频使用,泄露的影响窗口小。
- Refresh Token:长期有效(7-90 天),仅用于获取新的 Access 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 过期,用户需要重新登录
4.2 Refresh Token 轮换(Rotation)
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
}4.3 Access Token 吊销策略对比
| 策略 | 即时性 | 性能开销 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 等待过期(不吊销) | 无(等 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 安全存储
Refresh Token 的存储位置决定了整个认证系统的安全上限。选错了,其他所有安全措施都白费。
5.1 浏览器端存储方案
localStorage:
- 容量大(5-10MB),操作简单
- 致命缺陷:任何在页面上下文中执行的 JavaScript 都能读取,包括 XSS(跨站脚本攻击,Cross-Site Scripting)注入的恶意脚本
- 结论:绝对不要把 Refresh Token 存在 localStorage 中
sessionStorage:
- 与 localStorage 类似,但关闭标签页后自动清除
- 同样存在 XSS 风险
- 结论:同样不推荐存储 Refresh Token
HttpOnly Cookie:
- 浏览器自动管理,JavaScript 无法读取(防 XSS)
- 配合
Secure标记仅在 HTTPS 下传输 - 配合
SameSite=Strict或SameSite=Lax防 CSRF(跨站请求伪造,Cross-Site Request Forgery) - Cookie 的
Path设为 Refresh Token 端点(如/auth/refresh),避免在普通 API 请求中发送 - 结论:浏览器端存储 Refresh Token 的最佳选择
Set-Cookie: refresh_token=eyJhbGci...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh;
Max-Age=2592000
5.2 BFF 模式(Backend for Frontend)
即使用了 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 模式的优势:
- 令牌完全不暴露给浏览器,XSS 攻击无法窃取令牌
- BFF 是机密客户端(Confidential Client),可以使用 client_secret,比公开客户端更安全
- 令牌刷新逻辑在服务端完成,减少前端复杂性
BFF 模式的代价:
- 多一层服务,增加部署和运维成本
- BFF 服务成为性能瓶颈(所有前端请求都经过它)
- 需要管理 BFF 自身的 Session 状态
5.3 移动端安全存储
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();
}
}5.4 存储方案决策矩阵
| 存储位置 | XSS 防护 | CSRF 防护 | 令牌暴露面 | 适用场景 |
|---|---|---|---|---|
| localStorage | 无 | 天然免疫 | 最大 | 不推荐 |
| sessionStorage | 无 | 天然免疫 | 大 | 不推荐 |
| HttpOnly Cookie | 有 | 需配合 SameSite | 中 | Web 应用(无 BFF) |
| BFF Session | 有 | 需配合 CSRF Token | 最小 | 高安全要求的 Web 应用 |
| iOS Keychain | 系统级隔离 | 不适用 | 小 | iOS 原生应用 |
| Android Keystore | 系统级隔离 | 不适用 | 小 | Android 原生应用 |
六、OAuth 2.0 攻击面分析
OAuth 2.0 授权码流程虽然是安全性最高的标准流程,但其复杂的重定向机制引入了多个攻击面。
6.1 CSRF 攻击:伪造授权请求
攻击原理:攻击者构造一个指向授权服务器的链接,其中
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)
// 继续处理授权码交换...
}6.2 授权码注入(Authorization Code Injection)
攻击原理:攻击者获取了一个合法的授权码(例如通过中间人攻击或 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
}6.3 Redirect URI 操纵
攻击原理:攻击者修改授权请求中的
redirect_uri,使其指向攻击者控制的服务器。如果授权服务器对
redirect_uri
的校验不严格(例如只检查域名前缀),授权码就会被发送到攻击者的服务器。
防御措施:
- 授权服务器必须做精确匹配(Exact Match),不允许通配符或前缀匹配
- 客户端注册时预先配置允许的
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"6.4 令牌泄露渠道
即使流程实现正确,令牌仍然可能通过以下渠道泄露:
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)。
6.5 攻击面总览
| 攻击类型 | 攻击目标 | 防御机制 | 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 新增 |
七、SSO 架构选型
7.1 SSO 的核心需求
单点登录(Single Sign-On)要解决的问题很直接:用户登录一次,就能访问多个互相独立的应用系统。
SSO 不只是”少输几次密码”的便利性问题。在企业场景中,它还涉及:
- 统一身份管理:一个员工的入职、离职、角色变更只需要在一个地方操作
- 统一安全策略:密码强度、MFA(多因素认证,Multi-Factor Authentication)、登录审计集中管控
- 合规审计:谁在什么时间访问了什么系统,需要集中的审计日志
7.2 OIDC-based SSO
基于 OIDC 的 SSO 是当前的主流方案。核心流程:
- 用户访问应用 A,应用 A 发现用户未登录,重定向到 OP(身份提供方)
- 用户在 OP 完成登录(输入密码、MFA 验证等)
- OP 签发 ID Token 和 Access Token,重定向回应用 A
- 用户访问应用 B,应用 B 同样重定向到 OP
- OP 检测到用户已在 OP 上有活跃 Session,直接签发令牌,无需再次输入密码
- 用户无感知地完成了应用 B 的登录
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"7.3 SAML:仍然活着的遗产
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 机制标准化) |
| 新项目推荐 | 仅在对接遗留系统时使用 | 新系统首选 |
7.4 SSO 选型决策树
是否需要对接企业级遗留系统(AD FS、Shibboleth)?
├── 是 → 必须支持 SAML,考虑同时支持 OIDC
│ 使用 Keycloak 或 Okta 作为身份代理(Identity Broker),
│ 对内提供 OIDC,对外桥接 SAML
└── 否 → 使用 OIDC
├── 团队规模小、预算有限 → Keycloak(开源自托管)
├── 不想自运维 → Auth0 / Okta / AWS Cognito(SaaS)
└── 超大规模(千万用户级) → 自研 OP + 开源库
7.5 单点登出(Single Logout)
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)
}八、工程案例:从 Session 到 OIDC 的迁移
8.1 背景
某金融科技公司(以下称 FinCorp)有一个运行了 5 年的单体电商系统,用户量约 200 万。技术栈是 Java Spring Boot + MySQL + Redis。认证方案是经典的 Spring Session + Redis:用户登录后,Session 存在 Redis 中,所有请求通过 Cookie 携带 Session ID。
2023 年,公司决定将单体拆分为微服务,同时收购了一家做 B2B 供应链的公司,需要实现两套产品的 SSO。
8.2 面临的问题
- Redis Session 瓶颈:拆分为 15 个微服务后,每个服务每次请求都要查 Redis。高峰期 Redis 的 QPS 达到 50 万,P99 延迟从 1ms 飙到 15ms。
- 跨域 Cookie 失效:收购的 B2B 产品部署在
b2b.supplycorp.com,主站在www.fincorp.com,Cookie 无法跨域。 - 移动端改造:新上线的移动 App 需要接入认证,但原生 App 处理 Cookie 很别扭。
- 合规要求:金融监管要求支持 MFA,且登录审计日志必须集中管理。
8.3 方案选型
团队评估了三个方案:
方案一:Redis Cluster 扩容 + 跨域 Cookie 代理
- 优点:改动最小
- 缺点:不解决根本问题,跨域代理方案脆弱,移动端仍然别扭
- 结论:否决
方案二:全面转 JWT,自建认证服务
- 优点:无状态验证,性能好
- 缺点:需要自己处理令牌吊销、密钥轮换、MFA 集成,工程量大
- 结论:短期成本高,长期维护风险大
方案三:部署 Keycloak 作为 OIDC Provider,各服务接入 OIDC
- 优点:成熟开源方案,内置 MFA、SSO、用户管理、审计日志;支持 SAML 桥接(为未来对接银行系统预留)
- 缺点:引入新组件,团队需要学习 Keycloak 运维
- 结论:采纳
8.4 迁移架构
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
8.5 迁移步骤
第一阶段(2 周):Keycloak 部署与用户数据迁移
- 部署 Keycloak 高可用集群(2 节点 + PostgreSQL + 主从复制)
- 编写用户数据迁移脚本,从 MySQL 导入 200 万用户到 Keycloak
- 密码哈希迁移:Keycloak 支持自定义密码哈希验证器(SPI),可以识别原系统的 BCrypt 哈希,用户无需重置密码
第二阶段(3 周):网关层 JWT 验签
- 在 API 网关(Kong)上配置 OIDC 插件,所有请求必须携带有效的 JWT
- 网关验签通过后,将用户信息通过
X-User-ID、X-User-Roles等头传递给下游服务 - 下游服务只读取请求头,不再查 Redis
第三阶段(2 周):双跑验证
- 新旧认证系统并行运行 2 周
- 用特性开关(Feature Flag)控制流量切换比例:10% → 50% → 100%
- 监控指标:登录成功率、令牌验证延迟、Redis QPS、错误率
第四阶段(1 周):下线旧系统
- 确认所有流量已切换到 OIDC
- 下线 Redis Session 存储
- 回收旧认证代码
8.6 迁移结果
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 认证延迟(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 周完成 | 大幅缩短 |
8.7 踩过的坑
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 以内。
九、选型对比
9.1 全维度对比表
| 维度 | 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 集群运维) |
| 第三方集成 | 需自研适配 | 需自研适配 | 标准化,直接对接 |
| 审计日志 | 需自建 | 需自建 | 内置 |
| 适用阶段 | 单体应用、早期项目 | 微服务内部通信 | 企业级多产品体系 |
9.2 决策建议
选 Session:项目是单体架构,用户量在 10 万以内,没有跨域需求,没有移动端,团队对 OAuth / OIDC 不熟悉。Session 是最简单、最成熟的方案,没有必要为了”先进”而引入复杂性。
选 JWT:微服务架构,服务间调用频繁,不需要 SSO。JWT 作为服务间传递用户上下文的载体非常合适。但要注意:JWT 适合做”内部通信令牌”,不适合做”面向终端用户的认证令牌”——后者建议搭配 BFF 模式或使用 OIDC。
选 OIDC:多产品线需要 SSO;需要对接第三方身份源(Google、企业 AD);有 MFA、审计等企业级需求;团队有能力运维 Keycloak 或预算购买 Auth0 / Okta。
十、总结
认证架构的演进不是线性替代关系。Session 没有被 JWT “淘汰”,JWT 也没有被 OIDC “替代”。它们解决不同层次的问题,在实际系统中经常共存:
- OIDC Provider 内部用 Session 管理用户的登录状态
- OIDC 签发的 Access Token 是 JWT 格式
- BFF 层拿到 JWT 后,可能用 Session 方式管理与浏览器的会话
选型的关键不是”哪个更先进”,而是”当前系统的核心矛盾是什么”:
- 如果核心矛盾是微服务间的认证传递效率——用 JWT
- 如果核心矛盾是多产品的统一登录——用 OIDC
- 如果核心矛盾是即时吊销能力——Session 或 JWT + 黑名单
- 如果核心矛盾是开发速度——先用 Session,后续再迁移
没有银弹。理解每种方案的结构性优势和结构性限制,才能做出合理的架构决策。
参考资料
- Jones, M., Bradley, J., Sakimura, N.(2015). RFC 7519: JSON Web Token (JWT). IETF.
- Hardt, D.(2012). RFC 6749: The OAuth 2.0 Authorization Framework. IETF.
- Sakimura, N., Bradley, J., Agarwal, N.(2015). RFC 7636: Proof Key for Code Exchange by OAuth Public Clients. IETF.
- Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., Mortimore, C.(2014). OpenID Connect Core 1.0. OpenID Foundation.
- Lodderstedt, T., Bradley, J., Labunets, A., Fett, D.(2020). RFC 6819: OAuth 2.0 Security Best Current Practice. IETF.
- Fett, D., Kuesters, R., Schmitz, G.(2016). A Comprehensive Formal Security Analysis of OAuth 2.0. ACM CCS.
- Parecki, A.(2020). OAuth 2.0 Simplified. oauth.com.
- Keycloak Documentation. https://www.keycloak.org/documentation
- Lodderstedt, T., Fett, D.(2022). OAuth 2.1 Authorization Framework (Draft). IETF.
- OWASP.(2023). OAuth 2.0 Cheat Sheet. OWASP Foundation.
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】无状态设计:扩展的第一步也是最难的一步
有状态服务是水平扩展的最大障碍。本文从 Session、文件上传、WebSocket 三个典型场景出发,拆解状态外置模式、JWT 与服务端 Session 的架构级差异,以及 Sticky Session 的真实代价,给出从有状态到无状态的完整迁移路径。
以实例说明 OAuth2
OAuth2 授权协议详解:单点登录、第三方授权的实现原理与实战案例
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。