Skip to content
The Simple
Go back

SSO 一次登录的背后:OAuth2 / OIDC / JWT 是怎么串起来的

Updated:
Edit page

Table of contents

Open Table of contents

1. 先说清一件事:SSO 到底在解决什么

公司里有 5 个系统:邮件、报销、代码仓库、监控、存储管理平台。没有 SSO 的世界是:每个系统一套账号密码,每天登录 5 次,离职时要到 5 个地方删账号。

SSO(Single Sign-On,单点登录)就是:登录一次,所有系统都认识你。

但”登录一次、处处通行”不是魔法,它要解一个具体的难题:每个系统都要确认”你是谁”,可它们都不该各自再问你要一次密码——那样密码就得在每个系统里存一份,任何一处泄露全盘失守。

所以整个机制的设计目标可以浓缩成一句话:密码只在身份提供方那里出现一次,其他系统通过”可信的证件”来认识你。

两个容易混淆的动词

后面所有概念都建立在区分这两个词上:

SSO 的核心是认证。而 OAuth2 其实是个授权协议——它本来的用途是”允许 A 应用代表你访问 B 的资源”(比如允许某 App 读取你的 GitHub 仓库列表),并不回答”你是谁”。OIDC 才是在 OAuth2 之上补出来的认证层。很多人把四个名词混为一谈,根源就在这里。读完全文你会回来复习第 5 节那张对照表。

2. 一局的五个角色

先把参与者认全,后面时序图里每个箭头才知道是谁在跟谁说话。

角色是什么职责
浏览器你手里的客户端跟随重定向、自动携带 Cookie
网关Nginx + Lua(OpenResty)所有请求的入口:查登录状态、代理转发
Redis网关旁边的存储存”已登录会话”的登记表
IdPKeycloak / Okta / Azure AD权威身份源:存用户库、验密码、签发 token
后端服务真正的业务系统干活,但不负责登录

记忆锚点:IdP 是派出所(发证件),网关是小区门卫(查证件),Redis 是门卫手里的登记表,后端是你要进的那栋楼。

注意一个容易误解的点:网关不认识任何用户,也没有用户库。它只信任派出所签发的证件,大部分请求只翻自己的登记表——这正是网关能保持高性能的原因,只有登记表里没有或过期了才真的去问派出所。

那张 Redis 登记表是网关的,不是 IdP 的。 IdP 发完 token 就不跟踪”谁在访问哪个应用”——它可能同时服务几十个应用,如果每个应用的会话都归 IdP 管,IdP 就成了所有请求的必经之路和单点瓶颈。所以每个接入方(网关)各自保管自己的会话。

由此引出整个体系里其实有两层会话,分工完全不同:

IdP 侧会话网关侧 Redis 会话
存哪IdP 自己的存储(Keycloak 用 Infinispan)网关旁边的 Redis
凭证浏览器里 IdP 域下的 SSO Cookie浏览器里网关域下的 session_id Cookie
管什么跨系统:还要不要你重新输密码单系统:这次访问放不放行

你在 A 系统登录过后,IdP 给自己种了 SSO Cookie。之后你去 B 系统,302 跳到 IdP 时它发现 Cookie 还在,就直接发 code 跳回去、不再让你输密码——“单点”体验的来源是 IdP 侧这层会话。而 B 系统网关侧的流程(换 token、写自己那张 Redis)照走不误,和 A 系统互不干扰。

3. 完整流程:一次登录发生了什么

这是全文的核心。先看总图,再逐步拆”为什么”。

sequenceDiagram
    participant B as 浏览器
    participant G as 网关 Nginx+Lua
    participant R as Redis(网关侧)
    participant I as IdP Keycloak
    participant S as 后端服务

    Note over B,I: ① 首次访问:还没登录
    B->>G: GET /app(没带 session_id)
    G->>R: 查登记表 → 查无此人
    G-->>B: 302 重定向到 IdP 登录页(附带 client_id 和回调地址)

    Note over B,I: ② 在 IdP 完成认证(密码只在 IdP 页面上出现)
    B->>I: 输入用户名密码(可加 MFA)
    I-->>B: 302 跳回网关回调地址,URL 带一次性 code
    B->>G: GET /callback?code=xxx

    Note over G,I: ③ code 换 token(服务器对服务器,浏览器看不见)
    G->>I: POST code + client_id + client_secret
    I-->>G: id_token + access_token + refresh_token

    Note over G,R: ④ 网关本地落成会话
    G->>R: 存 session_id → 三个 token 和过期时间
    G-->>B: Set-Cookie: session_id=...(HttpOnly + Secure)

    Note over B,S: ⑤ 之后每次请求:网关本地判断,JWT 注入转发
    B->>G: GET /api/xxx(浏览器自动带上 Cookie)
    G->>R: 查 session_id → 拿到 JWT
    G->>S: 转发请求,Header 注入 JWT(Authorization: Bearer ...)
    S-->>G: 业务响应
    G-->>B: 返回结果

逐步拆解:每一步为什么这样设计

① 为什么是 302 跳去 IdP 登录页,而不是业务系统自己弹个密码框

这是整个体系的第一原则:密码只在 IdP 的页面上出现。如果业务系统自己做登录框,密码就要经它的手——每多一个经手的系统,泄露面就大一圈。302 跳转把”碰密码”这件事收拢到唯一一个地方,这也是浏览器地址栏变成 IdP 域名给你提供的天然提示(地址栏不对就别输密码,防钓鱼)。

② IdP 为什么不直接发 token,而是先给一个一次性的 code

因为这个 code 会经过浏览器地址栏——它可能留在浏览历史、代理日志、Referer 头里,是个”半公开”的东西。设计者的应对是:让 code 单独没用。

③ code 换 token:整个流程里最关键的一步

换 token 的请求是网关服务器直接发给 IdP 的(不经过浏览器),并且必须同时出示 client_id + client_secret。client_secret 只存在于网关服务器上,浏览器永远看不到。

所以即使 code 被截获,攻击者没有 client_secret 也换不出任何东西——code 只是一张”取件凭条”,token 才是”证件”,取件必须凭条和密钥一起出示。同时 code 通常几分钟内过期、只能用一次,把泄露后的可用窗口压到最小。

这三样东西各是谁的?(高频误解点)

东西属于谁类比
code这次登录的一次性凭条(绑定了该用户 + 该应用),本身不含用户信息快递取件码
client_id网关在 IdP 注册时拿到的公开身份(就在 URL 里,人人可见)公司名
client_secret网关的密钥,只存在网关服务器上,IdP 留有登记公章

注意区分:code 里并没有用户信息——用户信息在换回来的 id_token 里(比如 sub、name 这些 claim)。client_id/secret 也不是 IdP 的,而是应用自己的身份:网关拿三件套来换 token 时,IdP 核对”这个 client_id 注册过吗?secret 对得上吗?code 是我刚发出去的那张吗?“,三个都对才发 token。

追问:那攻击者拿到 code 去找网关,网关带上 client_secret 去找 IdP,不就换出 token 了吗?

这个攻击思路是对的,而且真实存在(叫 code 截获 / Login CSRF):攻击者把偷来的 code 发给网关回调地址,网关确实会去 IdP 换 token——成功后 session_id 会发给攻击者的浏览器,攻击者就以受害者身份登录了。所以规范里配了两道专门的锁:

④ 网关为什么不把 JWT 直接给浏览器,而是换成 session_id

三个 token 到网关手里后,网关做了一次”落地”:把真 token 存进 Redis,浏览器只收到一个无信息量的随机串 session_id(放在 HttpOnly Cookie 里,JS 读不到)。这样做的收益:

⑤ 后端怎么知道你是谁:JWT 在这里才真正出场

网关从 Redis 取出 JWT,放进 Authorization: Bearer <jwt> 请求头转发给后端。后端不查库、不问 IdP,用 IdP 的公钥(从 IdP 公开的 jwks_uri 下载)在本地验证签名,然后从 payload 里读 sub(用户 ID)和角色。这就是为什么 JWT 要自带签名——验证成本被摊到每个后端本地完成,IdP 不会成为每次请求的瓶颈。

JWT 的 payload 里一般装什么:

4. 过期了怎么办:refresh token 的静默续期

设计上,access token 短命(常见 5 分钟~1 小时),refresh token 长命(常见几天~30 天)。网关拿到 JWT 后发现快过期时,用 refresh_token 找 IdP 静默换一组新 token、更新 Redis 记录,用户完全无感。

为什么这样分:

真正的主动下线则更简单:删 Redis 记录,下次请求直接被网关打回登录页。

5. 四个名词回到一起(现在这是复习,不是门槛)

概念是什么在上面流程里的位置
SSO目标/体验登录一次,处处通行
OIDC认证协议第 ②③ 步(登录 + code 换 token,多返回一个 id_token)
OAuth2授权框架OIDC 的地基:code 换 token 这套机制就是它的授权码模式
JWTtoken 的编码格式id_token / access_token 的载体:header.payload.signature,自带签名

一句话串起来:SSO 是想要的体验 → 用 OIDC 做认证来实现 → OIDC 复用 OAuth2 的授权码机制做底座 → 三个 token 用 JWT 格式承载。

6. Token vs Session:不是二选一,而是各管一段

先把两个词的定义钉死:

注意别混淆:JWT 本身无状态,但系统可以混着用——本文架构就是浏览器↔网关走有状态 session,网关↔后端走无状态 JWT。

JWT(自包含)Session(引用式)
谁产生IdP 签发网关自己生成
内容自带用户信息 + 签名空壳随机串,信息在 Redis
验起来本地验签即可,零查询必须查一次 Redis
吊销做不到(签名不过期就有效)删记录立即生效
适合网关 → 后端的内部传递浏览器 → 网关的对外暴露面

回看流程会发现:对外(浏览器)用 session,对内(后端)用 JWT,各取所长。session_id 本身不是 JWT——它是个不透明随机串,必须配合 Redis 才有意义,这是它和自包含 JWT 最本质的区别。

7. 为什么公司 SSO 选 Nginx + Lua

8. 附录 A:Keycloak 界面速查

(按需阅读——理解流程不需要它,配环境时才用得上)

配置顺序:Keycloak 建 Client 拿 client_id/secret → 网关 lua-resty-openidc 填 discovery URL + client_id/secret → 用户走 Keycloak/LDAP 登录 → 登录成功后按 redirect_uri 把 code 送回网关 → 网关完成第 ③④ 步。

9. 附录 B:mTLS 什么时候需要


Edit page
Share this post:

Previous Post
在博客里画流程图(Mermaid)
Next Post
Go 并发的标准写法,以及高并发时业界怎么做