认证协议时序图:JWT / OAuth2 / SAML / OpenID Connect

puml.online

写认证系统最常画的图就是时序图。这篇是四种主流认证协议(JWT、OAuth2 Authorization Code、OAuth2 PKCE、SAML 2.0、OpenID Connect)的 PlantUML 时序图模板,以及 token 生命周期管理的常见 bug。

协议全景

1
2
3
4
5
6
7
8
9
10
11
12
认证
├── 无状态
│ └── JWT (JSON Web Token)
├── 委托授权
│ └── OAuth 2.0
│ ├── Authorization Code
│ ├── PKCE (移动端/SPA)
│ ├── Client Credentials (服务对服务)
│ └── Device Code (IoT/TV)
└── 联合身份
├── SAML 2.0 (企业 SSO 老牌)
└── OpenID Connect (基于 OAuth2,加 ID Token)

协议 1:JWT 自签认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@startuml
title JWT Authentication Flow

actor "User" as User
participant "Web App" as App
participant "Auth Service" as Auth
database "DB" as DB

User -> App : ① 输入用户名密码
App -> Auth : ② POST /login {username, password}
Auth -> DB : ③ SELECT user WHERE username = ?
DB --> Auth : ④ user record (hashed password)

alt 密码正确
Auth -> Auth : ⑤ 验证 bcrypt(password)
Auth -> Auth : ⑥ 签发 JWT {sub: user_id, exp: now+1h, role}
Auth --> App : ⑦ 200 {access_token: <JWT>, refresh_token: <opaque>}
App -> App : ⑧ 存 access_token 到内存,refresh_token 到 HttpOnly cookie
App --> User : ⑨ 重定向到首页

else 密码错
Auth --> App : 401 Unauthorized
App --> User : 显示「用户名或密码错」
end

note over App : 后续请求
App -> App : ① 请求时检查 access_token 是否过期
alt token 未过期
App -> Auth : GET /api/users/me (Authorization: Bearer <JWT>)
Auth -> Auth : ② 验证 JWT 签名
Auth --> App : 200 user info
else token 过期
App -> Auth : POST /refresh (refresh_token)
Auth -> DB : ③ 查 refresh_token 是否被撤销
alt refresh_token 有效
Auth -> Auth : ④ 签发新 access_token
Auth --> App : 200 {access_token: <new JWT>}
else refresh_token 已撤销或过期
Auth --> App : 401 → App 跳登录页
end
end
@enduml

关键决策点:

  • access_token 短(15min - 1h)——泄露后快速过期
  • refresh_token 长(7d - 30d)——但要可撤销(数据库存状态)
  • refresh_token 不能放 localStorage——XSS 偷走就完了。HttpOnly cookie + SameSite=Strict
  • access_token 也不能放 localStorage——放内存(刷新页面就丢,需要重新 silent refresh)

协议 2:OAuth 2.0 Authorization Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@startuml
title OAuth 2.0 Authorization Code Flow

actor "User" as User
participant "Web App\n(Client)" as App
participant "Authorization\nServer" as AuthServer
participant "Resource\nServer" as API

== 步骤 1:重定向到授权页 ==
App -> User : ① 302 https://auth.com/authorize?\nresponse_type=code&\nclient_id=abc&\nredirect_uri=https://app.com/cb&\nscope=read:profile&\nstate=xyz
User -> AuthServer : ② GET /authorize (User-Agent 跟随重定向)
AuthServer -> User : ③ 渲染登录页

== 步骤 2:用户授权 ==
User -> AuthServer : ④ 输入用户名密码 + 点击「同意」
AuthServer -> AuthServer : ⑤ 验证凭证
AuthServer -> User : ⑥ 302 https://app.com/cb?code=AUTH_CODE&state=xyz

== 步骤 3:后端换 token ==
User -> App : ⑦ GET /cb (User-Agent 跟随重定向)
App -> App : ⑧ 验证 state == xyz (防 CSRF)
App -> AuthServer : ⑨ POST /token {grant_type: authorization_code, code, client_id, client_secret, redirect_uri}
AuthServer -> AuthServer : ⑩ 验证 client_secret + code 未被用过
AuthServer -> App : ⑪ 200 {access_token, refresh_token, expires_in, id_token?}

== 步骤 4:API 调用 ==
App -> API : GET /api/data (Authorization: Bearer access_token)
API -> API : 验证 token
API --> App : 200 data

@enduml

注意:

  • 步骤 ⑨ 必须后端做——client_secret 不能泄露给前端
  • 步骤 ⑧ state 必须验证——不验证就有 CSRF
  • code 只能用一次——用过的 code 立即失效
  • redirect_uri 严格匹配——防止 authorization code 注入攻击

协议 3:OAuth 2.0 PKCE(移动端/SPA)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@startuml
title OAuth 2.0 with PKCE (Mobile/SPA)

actor "User" as User
participant "Mobile App\n(Public Client)" as App
participant "Auth Server" as AuthServer

== 步骤 1:生成 PKCE 参数 ==
App -> App : ① 生成 code_verifier (随机 43-128 字符)
App -> App : ② code_challenge = SHA256(code_verifier) base64url
App -> App : ③ code_challenge_method = S256

== 步骤 2:重定向 ==
App -> User : ④ 打开浏览器 https://auth.com/authorize?\nresponse_type=code&\nclient_id=mobile_app&\ncode_challenge=...&\ncode_challenge_method=S256
User -> AuthServer : ⑤ 用户登录 + 授权
AuthServer -> AuthServer : ⑥ 存 code_challenge
AuthServer -> User : ⑦ redirect_uri?code=AUTH_CODE

== 步骤 3:换 token(用 verifier 证明身份) ==
User -> App : ⑧ 收到 redirect + code
App -> AuthServer : ⑨ POST /token {grant_type: authorization_code, code, client_id, code_verifier}
AuthServer -> AuthServer : ⑩ 验证 SHA256(code_verifier) == code_challenge
AuthServer --> App : ⑪ 200 {access_token, refresh_token}

note over App
PKCE 替代 client_secret——
移动端没有后端存 secret,
PKCE 用 code_verifier 证明是同一个 app
end note
@enduml

关键:PKCE 解决「公共客户端(没有 secret)」的安全问题。SPA、移动 App、桌面应用 必须用 PKCE。

协议 4:SAML 2.0 (企业 SSO)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@startuml
title SAML 2.0 SSO (SP-Initiated)

actor "User" as User
participant "Service\nProvider (SP)" as App
participant "Identity\nProvider (IdP)" as IdP

== 步骤 1:用户访问 SP ==
User -> App : ① GET /dashboard
App -> App : ② 用户未登录
App -> User : ③ 302 https://idp.com/sso?SAMLRequest=<base64 XML>

== 步骤 2:IdP 认证 ==
User -> IdP : ④ GET /sso (User-Agent 跟随)
IdP -> User : ⑤ 渲染登录页
User -> IdP : ⑥ 输入企业凭证 + MFA

== 步骤 3:IdP 返回 SAML Response ==
IdP -> IdP : ⑦ 生成 SAML Response (XML,签名)\n包含 Assertion {Subject, Attribute}
IdP -> User : ⑧ 302 https://app.com/sso/acs?\nSAMLResponse=<base64 XML>
User -> App : ⑨ POST /sso/acs (form auto-submit)

== 步骤 4:SP 验证 ==
App -> App : ⑩ 解码 + 验证 SAML Response 签名
App -> App : ⑪ 验证 Audience == 自己
App -> App : ⑫ 验证 NotOnOrAfter > now
App -> App : ⑬ 创建 session cookie
App -> User : ⑭ 200 /dashboard
@enduml

SAML vs OAuth/OIDC:

  • SAML:XML 格式、企业 IT 部门熟悉、老牌(2005)
  • OAuth/OIDC:JSON 格式、开发者友好、现代(2010+)
  • 新项目用 OIDC,老企业 IT 集成用 SAML

协议 5:OpenID Connect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@startuml
title OpenID Connect (OIDC) Login

actor "User" as User
participant "Web App\n(Relying Party)" as App
participant "OpenID\nProvider" as OP

User -> App : ① 登录
App -> OP : ② 302 /authorize?\nresponse_type=code&\nscope=openid+profile+email
User -> OP : ③ 登录 + 授权
OP -> User : ④ redirect_uri?code=...&state=...
User -> App : ⑤ GET /cb
App -> OP : ⑥ POST /token (code + client_secret)
OP -> App : ⑦ {access_token, id_token, refresh_token}

App -> App : ⑧ 解析 id_token (JWT)\n验证 iss/aud/exp/nonce
App -> App : ⑨ 提取用户信息 {sub, email, name, picture}
App -> User : ⑩ 登录成功

note over App
id_token = 身份信息 (JWT)
access_token = API 访问凭证
refresh_token = 刷新
end note
@enduml

OIDC vs OAuth2:

  • OAuth2 只关心授权(authorization)
  • OIDC 加 ID Token,关心认证(authentication)
  • 任何说「用 OAuth2 登录」的其实都在用 OIDC

Token 生命周期管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@startuml
title Token Refresh Race Condition

participant "Client" as C
participant "API" as API
database "Token Store" as Store

== 场景:两个客户端同时刷新 ==
C -> API : ① refresh_token = abc
C -> API : ② refresh_token = abc (并发请求)
API -> Store : ③ 查 abc
API -> Store : ④ UPDATE abc SET used=true
API --> C : ⑤ new token + new refresh_token

API -> Store : ⑥ 查 abc (第二次请求)
Store --> API : ⑦ used=true → 拒绝
API --> C : ⑧ 401 → 触发全设备登出
@enduml

修法——refresh token rotation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@startuml
title Refresh Token Rotation (RFC 6819 §5.2.2.3)

participant "Client" as C
participant "Auth Server" as Auth
database "Token Store" as Store

C -> Auth : ① refresh_token = abc
Auth -> Store : ② 查 abc

alt abc 有效且未被使用过
Auth -> Store : ③ 标记 abc 已使用
Auth -> Store : ④ 存 abc 的家族 ID
Auth --> C : ⑤ new access_token + new refresh_token = def\n同 family_id
C -> Auth : ⑥ 用 def 刷新
Auth -> Store : ⑦ 查 def (family_id=family1)
Auth --> C : ⑧ new token + new refresh = ghi (family1)

else abc 已被使用(被偷)
Auth -> Store : ⑨ 检测到复用 → 撤销 family1 全部 token
Auth --> C : ⑩ 401 → 强制重新登录
end
@enduml

复用检测触发整个家族撤销——攻击者拿到偷的 refresh_token 用过一次后,真用户的 token 也失效,会收到「异地登录」通知

实战踩坑

  • access_token 放 localStorage——XSS 一键偷走。改用内存 + silent refresh
  • refresh_token 没设 HttpOnly——同样被偷。必须 HttpOnly + Secure + SameSite=Strict
  • state 不验证——CSRF 攻击可以预先生成 code 然后冒充用户。state 必须 crypto random + 验证回传值
  • PKCE verifier 不足 43 字符——RFC 7636 要求 43-128 字符。短了直接 400
  • SAML Response 不验证签名——攻击者伪造 SAML Response 直接登入。必须验签
  • JWT 存在 cookie 里——CSRF 漏洞可以拿 cookie 直接用。Authorization: Bearer 头或双重提交 cookie

决策树

1
2
3
4
5
6
7
要做什么?
├─ 自己用户系统 → JWT + refresh token
├─ 第三方登录(微信/Google/企业微信) → OAuth2 Authorization Code + PKCE
├─ 企业 SSO(老牌大企业) → SAML 2.0
├─ 现代 SaaS / 新企业 → OpenID Connect
├─ 服务对服务(无用户) → OAuth2 Client Credentials
└─ IoT / TV / CLI → OAuth2 Device Code

最小实现——JWT 自签 + refresh token rotation。用户量上去后——加 OAuth2 server,让别人接入。企业客户要求 SAML——加 SAML SP。永远不要明文存密码——只用 bcrypt/argon2 hash。

  • 标题: 认证协议时序图:JWT / OAuth2 / SAML / OpenID Connect
  • 作者: puml.online
  • 创建于 : 2026-07-30 17:30:00
  • 更新于 : 2026-08-14 21:34:29
  • 链接: https://puml.online/blog/plantuml-jwt-oauth-sequence/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。