认证协议时序图:JWT / OAuth2 / SAML / OpenID Connect
写认证系统最常画的图就是时序图。这篇是四种主流认证协议(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。