惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

大猫的无限游戏
大猫的无限游戏
博客园 - 【当耐特】
Cloudbric
Cloudbric
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Attack and Defense Labs
Attack and Defense Labs
爱范儿
爱范儿
The Cloudflare Blog
腾讯CDC
Security Archives - TechRepublic
Security Archives - TechRepublic
TaoSecurity Blog
TaoSecurity Blog
云风的 BLOG
云风的 BLOG
Recent Announcements
Recent Announcements
C
Check Point Blog
Schneier on Security
Schneier on Security
S
Schneier on Security
J
Java Code Geeks
B
Blog RSS Feed
Cisco Talos Blog
Cisco Talos Blog
Vercel News
Vercel News
Stack Overflow Blog
Stack Overflow Blog
博客园_首页
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
A
About on SuperTechFans
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Google DeepMind News
Google DeepMind News
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
A
Arctic Wolf
S
Secure Thoughts
P
Palo Alto Networks Blog
The Last Watchdog
The Last Watchdog
SecWiki News
SecWiki News
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
博客园 - 三生石上(FineUI控件)
D
Darknet – Hacking Tools, Hacker News & Cyber Security
量子位
U
Unit 42
I
InfoQ
D
DataBreaches.Net
P
Privacy International News Feed
T
Troy Hunt's Blog
博客园 - 叶小钗
T
Threatpost
博客园 - Franky
K
Kaspersky official blog
Hugging Face - Blog
Hugging Face - Blog
IT之家
IT之家
www.infosecurity-magazine.com
www.infosecurity-magazine.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
C
Cisco Blogs

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证 排序基准测试:用数据说话
【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC
2026-04-13 · via 土法炼钢兴趣小组的算法知识备份

上一篇:DDD 与微服务 | 下一篇:授权架构


一个电商平台有 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 的架构选型怎么做?


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=StrictSameSite=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,数据加密存储在系统级安全区域。设置 kSecAttrAccessiblekSecAttrAccessibleWhenUnlockedThisDeviceOnly,确保令牌仅在设备解锁时可访问,且不会通过 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 是当前的主流方案。核心流程:

  1. 用户访问应用 A,应用 A 发现用户未登录,重定向到 OP(身份提供方)
  2. 用户在 OP 完成登录(输入密码、MFA 验证等)
  3. OP 签发 ID Token 和 Access Token,重定向回应用 A
  4. 用户访问应用 B,应用 B 同样重定向到 OP
  5. OP 检测到用户已在 OP 上有活跃 Session,直接签发令牌,无需再次输入密码
  6. 用户无感知地完成了应用 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 面临的问题

  1. Redis Session 瓶颈:拆分为 15 个微服务后,每个服务每次请求都要查 Redis。高峰期 Redis 的 QPS 达到 50 万,P99 延迟从 1ms 飙到 15ms。
  2. 跨域 Cookie 失效:收购的 B2B 产品部署在 b2b.supplycorp.com,主站在 www.fincorp.com,Cookie 无法跨域。
  3. 移动端改造:新上线的移动 App 需要接入认证,但原生 App 处理 Cookie 很别扭。
  4. 合规要求:金融监管要求支持 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-IDX-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,后续再迁移

没有银弹。理解每种方案的结构性优势和结构性限制,才能做出合理的架构决策。


参考资料

  1. Jones, M., Bradley, J., Sakimura, N.(2015). RFC 7519: JSON Web Token (JWT). IETF.
  2. Hardt, D.(2012). RFC 6749: The OAuth 2.0 Authorization Framework. IETF.
  3. Sakimura, N., Bradley, J., Agarwal, N.(2015). RFC 7636: Proof Key for Code Exchange by OAuth Public Clients. IETF.
  4. Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., Mortimore, C.(2014). OpenID Connect Core 1.0. OpenID Foundation.
  5. Lodderstedt, T., Bradley, J., Labunets, A., Fett, D.(2020). RFC 6819: OAuth 2.0 Security Best Current Practice. IETF.
  6. Fett, D., Kuesters, R., Schmitz, G.(2016). A Comprehensive Formal Security Analysis of OAuth 2.0. ACM CCS.
  7. Parecki, A.(2020). OAuth 2.0 Simplified. oauth.com.
  8. Keycloak Documentation. https://www.keycloak.org/documentation
  9. Lodderstedt, T., Fett, D.(2022). OAuth 2.1 Authorization Framework (Draft). IETF.
  10. OWASP.(2023). OAuth 2.0 Cheat Sheet. OWASP Foundation.

上一篇:DDD 与微服务 | 下一篇:授权架构

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-03 · authentication

以实例说明 OAuth2

OAuth2 授权协议详解:单点登录、第三方授权的实现原理与实战案例

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。