乐于分享
好东西不私藏

【器篇492】软件工厂之【单点登录】1:登录一次,畅行所有系统:单点登录 SSO 的原理、协议、架构与安全设计

【器篇492】软件工厂之【单点登录】1:登录一次,畅行所有系统:单点登录 SSO 的原理、协议、架构与安全设计

登录一次,畅行所有系统:单点登录 SSO 的原理、协议、架构与安全设计

单点登录(Single Sign-On,SSO)不是“把多个系统的登录页面做成一个”,而是建立一个统一身份提供者,让用户在多个相互信任的应用之间复用认证结果。本文从浏览器跳转、Cookie、Token、OAuth 2.0、OIDC、SAML、Session、注销、续期和安全治理等方面,系统拆解 SSO 的工作机制与工程实现。

1. 先说结论

单点登录的核心目标是:

用户只在统一身份中心认证一次              ↓多个业务系统通过信任协议确认用户身份              ↓用户无需重复输入密码即可访问其它系统

SSO 不等于“一套 Cookie 共享所有系统”。现代系统通常使用:

  • OAuth 2.0:授权框架,解决“应用如何获得访问授权”;
  • OpenID Connect:建立在 OAuth 2.0 之上的身份认证协议,解决“用户是谁”;
  • SAML 2.0:企业和传统组织常用的基于 XML 断言的联邦认证协议;
  • CAS:经典 Web 单点登录协议,概念清晰但现代 API 场景相对有限;
  • JWT:一种 Token 格式,不是完整的登录协议。

2. 为什么需要单点登录

假设一个企业有 OA、CRM、财务、代码平台和数据平台。如果每个系统独立登录,会产生:

  • 用户需要记忆多套账号密码;
  • 密码策略、二次认证和离职禁用难以统一;
  • 每个系统都要维护登录、找回密码、风控和审计;
  • 用户在系统之间切换时体验差;
  • 一个账号被禁用后,其他系统可能仍然保留有效会话;
  • 业务系统直接保存密码,攻击面扩大。

SSO 将认证责任集中到 Identity Provider(IdP),业务系统作为 Service Provider(SP)或 Relying Party(RP)消费认证结果。

3. 角色与概念

概念
含义
User
需要访问业务系统的人或服务主体
IdP
Identity Provider,统一身份提供者,负责认证用户
SP
Service Provider,提供业务能力并信任 IdP
RP
Relying Party,在 OIDC 中依赖 IdP 完成身份认证
Client
OAuth/OIDC 中代表业务应用的客户端
Authorization Server
OAuth 授权服务器,签发授权码和 Token
Resource Server
持有受保护资源、校验 Access Token 的服务
Session
IdP 或业务系统维护的登录状态
Cookie
浏览器自动携带的站点状态凭证
Access Token
访问资源的授权凭证
ID Token
OIDC 中描述用户身份的 JWT
Refresh Token
用于换取新 Access Token 的长期凭证
Assertion
SAML 中由 IdP 签名的身份断言

4. 总体架构

4.1 认证中心负责什么

  • 登录页面、密码校验、MFA、验证码和风控;
  • 用户目录、组织、角色和账号生命周期;
  • OAuth/OIDC/SAML/CAS 协议端点;
  • 授权码、Access Token、ID Token、Refresh Token 签发;
  • 公钥发布、密钥轮换和 Token 撤销;
  • 登录审计、设备管理和全局注销。

4.2 业务应用负责什么

  • 发起登录请求;
  • 校验回调中的 state、nonce、code;
  • 建立自己的应用 Session 或维护本地 Token;
  • 根据 claims 映射本地用户、角色和租户;
  • 在访问资源时校验 Access Token 的签名、issuer、audience、scope 和过期时间。

5. 最常见的 OIDC Authorization Code 流程

5.1 正常登录链路

5.2 为什么使用 Authorization Code

浏览器不直接拿到用户密码,也不直接在 URL 中携带 Access Token。授权码短时有效且只能被指定客户端兑换,配合 PKCE 可以降低授权码被拦截后重放的风险。

5.3 核心参数

client_id       客户端标识redirect_uri    认证完成后的回调地址response_type   通常为 codescope           openid profile email 等权限范围state           防 CSRF,必须与浏览器会话绑定nonce           防 ID Token 重放,必须在 ID Token 中校验code_challenge  PKCE 中的挑战值

6. OAuth 2.0 与 OIDC 的区别

6.1 OAuth 2.0 解决什么

OAuth 2.0 解决的是授权委托:

用户允许应用访问某个资源

它并不标准化“用户是谁”。Access Token 主要面向 Resource Server,不应被业务应用直接当作身份信息来源。

6.2 OIDC 增加了什么

OIDC 在 OAuth 2.0 上增加:

  • openid
     scope;
  • ID Token;
  • UserInfo Endpoint;
  • 标准 claims,如 sub、iss、aud、nonce、auth_time
  • Discovery 和 JWKS。

因此,登录认证优先使用 OIDC,而不是仅使用 OAuth 2.0 自定义“获取用户信息”接口。

7. Token 设计

7.1 Access Token

Access Token 表示访问资源的授权,常见字段包括:

{  "iss": "https://id.example.com",  "sub": "user-123",  "aud": "order-api",  "scope": "orders:read orders:write",  "exp": 1780000000,  "iat": 1779996400,  "jti": "token-id"}

资源服务必须校验:签名、issuer、audience、有效期、scope、必要的租户和权限 claims。

7.2 ID Token

ID Token 是给客户端的身份结果,主要回答“认证了哪个用户、认证如何发生”。它不是通用 API 访问令牌,不应该拿着 ID Token 调任意资源服务。

7.3 Refresh Token

Refresh Token 寿命较长,风险更高。推荐:

  • 只存储在安全的后端或受保护的客户端存储中;
  • 使用 Refresh Token Rotation;
  • 绑定客户端、设备或会话;
  • 检测重用并撤销令牌族;
  • 支持主动撤销和全局注销。

7.4 JWT 不是万能方案

JWT 优点是无状态校验、跨服务传递方便;缺点是签发后默认不容易立即撤销,Payload 可被读取,过大时会增加网络成本。JWT 应该只放必要 claims,不要放密码、敏感隐私或不断变化的权限。

8. Cookie Session 与 Token 的选择

方案
优点
缺点
适合
服务端 Session
易撤销、敏感数据不出服务端
需要存储和共享 Session
Web 后台、传统业务
JWT Access Token
跨服务方便、无需中心查询
撤销难、泄漏影响大
API、微服务
BFF + HttpOnly Cookie
浏览器不接触 Token,安全性好
增加 BFF 层
Web SPA
浏览器直接持有 Token
架构简单
XSS/存储风险高
受控场景,需谨慎

推荐 Web 应用使用 BFF:浏览器只持有 HttpOnly、Secure、SameSite Cookie,BFF 在后端保存或转发 Token。

9. SAML、CAS、OIDC 对比

维度
OIDC
SAML 2.0
CAS
数据格式
JSON/JWT
XML Assertion
Ticket
主要场景
Web、移动端、API、现代应用
企业 SaaS、传统组织、AD
经典 Web 单点登录
开发体验
相对简单
XML、签名和配置复杂
概念简单
API 友好性
较弱
较弱
身份与授权
身份 + OAuth 授权
身份断言 + 属性
登录票据
常见问题
Token 泄漏、配置错误
XML 签名/解析错误
跨域和现代客户端适配

如果新系统由自己设计,通常优先 OIDC;需要接入传统企业 IdP 时,SAML 仍然非常常见。

10. 单点登录与单点退出

10.1 登录态的两个层次

IdP Session:用户在身份中心已经登录App Session:用户在某个业务系统已经登录

用户访问第二个应用时,IdP 发现已有 IdP Session,可以直接签发新的授权码,用户无需再次输入密码。但这不代表两个应用共享同一个业务 Session。

10.2 注销难点

注销有三层:

  1. 本地注销:删除当前应用 Session;
  2. IdP 注销:删除统一身份中心 Session;
  3. 全局注销:通知其他应用结束会话并撤销 Token。

全局注销可以通过前端通道、后端通道、事件总线或短 Access Token + Refresh Token 撤销实现。工程上必须接受分布式系统的短暂不一致,并记录注销状态。

11. 安全设计

11.1 必须防御的攻击

  • CSRF:使用不可预测且与会话绑定的 state;
  • 授权码注入:校验 state、PKCE、client 和 redirect_uri;
  • Open Redirect:redirect_uri 必须精确白名单,不允许任意 URL;
  • Token 泄漏:避免 URL 暴露、日志打印和前端长期存储;
  • XSS:HttpOnly Cookie、CSP、输出编码和依赖治理;
  • 重放攻击:短过期时间、nonce、jti、设备绑定和 Rotation;
  • 权限提升:严格校验 audience、scope、tenant 和角色来源;
  • IdP 仿冒:TLS、issuer 校验、公钥来源固定和域名保护;
  • 登录 CSRF:不能只校验“用户已经登录”,还要校验请求上下文;
  • Session Fixation:登录成功后重新生成 Session ID。

11.2 Cookie 安全属性

Set-Cookie: sid=...; HttpOnlySecureSameSite=LaxPath=/; Max-Age=1800
  • HttpOnly
    :禁止 JavaScript 读取;
  • Secure
    :只通过 HTTPS 发送;
  • SameSite
    :降低跨站请求携带风险;
  • Domain
    :尽量不要设置过宽;
  • Path
    :限制发送范围;
  • Max-Age/Expires
    :明确生命周期。

12. 统一身份中心的数据模型

User ├── Credential ├── Device ├── Organization ├── Role/Permission └── ConsentOAuthClient ├── redirect_uris ├── allowed_scopes ├── client_auth_method └── secret/public_keyAuthSession ├── user_id ├── device_id ├── authentication_time ├── amr/acr └── statusTokenFamily ├── access_tokens ├── refresh_tokens ├── rotation_counter └── revoked_at

权限、身份和会话要分开建模。用户的角色变化不一定立即改变已签发 JWT,因此关键操作必须回源检查或使用短期 Token。

13. 源码设计

sso/  identity/ user.py organization.py credential.py directory.py  protocol/ oauth.py oidc.py saml.py cas.py discovery.py jwks.py  auth/ login.py password.py mfa.py risk.py consent.py  session/ session.py cookie.py device.py logout.py  token/ access.py id_token.py refresh.py revoke.py rotate.py  client/ registry.py redirect.py policy.py  security/ csrf.py nonce.py pkce.py signatures.py key_rotation.py  audit/ events.py login_log.py risk_log.py  api/ authorize.py token.py userinfo.py introspect.py

13.1 Authorization Endpoint

def authorize(request):    client = clients.get(request.client_id)    validate_redirect_uri(client, request.redirect_uri)    validate_response_type(request.response_type)    validate_scope(client, request.scope)    if not session.is_authenticated(request.browser):        return redirect_to_login(request)    auth_code = code_store.create(        client_id=client.id,        user_id=session.user_id,        redirect_uri=request.redirect_uri,        code_challenge=request.code_challenge,        nonce=request.nonce,        expires_in=60,        one_time=True,    )    return redirect(request.redirect_uri, code=auth_code, state=request.state)

13.2 Token Endpoint

def token(request):    code = code_store.consume_once(request.code)    validate_client(code.client_id, request.client_auth)    validate_redirect_uri(code.redirect_uri, request.redirect_uri)    validate_pkce(code.code_challenge, request.code_verifier)    id_token = signer.sign_id_token(code.user_id, nonce=code.nonce)    access_token = signer.sign_access_token(code.user_id, code.client_id)    refresh_token = refresh_store.issue_rotating_family(code.user_id)    return Tokens(id_token, access_token, refresh_token)

13.3 资源服务校验

def authenticate_bearer(token, required_scope):    claims = jwt.verify(token, jwks, issuer=IDP, audience=RESOURCE_ID)    require(claims.exp > now())    require(required_scope in claims.scope)    return Principal(sub=claims.sub, tenant=claims.tenant)

14. 登录失败、续期和异常场景

14.1 登录失败

  • 密码错误:限速、风险评分、必要时验证码;
  • MFA 失败:记录设备和 IP,避免无限尝试;
  • 账号禁用:不透露过多账号存在性信息;
  • 回调失败:校验 state 和 code,不重复兑换;
  • IdP 不可用:已登录的短期本地 Session 可按策略继续,不能伪造新身份。

14.2 Access Token 过期

客户端使用 Refresh Token 获取新 Access Token;如果 Refresh Token 也过期或被撤销,重新走授权流程。并发刷新要避免多个请求同时旋转导致 Token 家族误判重用。

14.3 用户权限变化

短期 Token 不能保证权限立即收敛。高风险权限变更后应:

  1. 撤销该用户 Token Family;
  2. 删除或缩短业务 Session;
  3. 发布权限变更事件;
  4. 资源服务在关键操作时回源或检查版本号。

15. 前后端分离与移动端

15.1 SPA

推荐 Authorization Code + PKCE,避免 Implicit Flow。更安全的企业方案是 BFF,浏览器只管理 Cookie,BFF 负责 Token 交换和 API 访问。

15.2 移动端

使用系统浏览器或安全认证代理完成授权,不要在 App 内嵌 WebView 中收集密码。使用 PKCE,配合 Universal Links/App Links 防止回调被恶意应用抢占。

15.3 服务间认证

服务间通常不需要用户交互登录,可使用 Client Credentials、mTLS、私钥 JWT 或工作负载身份。不要把用户登录流程硬套到后台服务。

16. 可观测性和审计

每次认证应关联:

request_iduser_id / subjectclient_idsession_iddevice_idip / user-agentamr / acrscopeissuer / audienceresult / failure_reasontimestamp

日志中禁止记录密码、完整 Token、client secret 和 Refresh Token。需要支持:登录成功率、MFA 挑战率、授权码失败率、Token 刷新失败率、重放检测、异常设备和全局注销状态。

17. 一次完整 SSO 请求

用户访问“财务系统”:

  1. 浏览器访问财务系统,发现没有本地 Session;
  2. 财务系统生成 state、nonce 和 PKCE verifier;
  3. 浏览器跳转到 IdP;
  4. IdP 检查自己的 SSO Cookie;
  5. 如果没有,展示登录和 MFA;
  6. 认证成功后创建 IdP Session;
  7. IdP 生成一次性 authorization code;
  8. 浏览器带 code 和 state 回调财务系统;
  9. 财务后端校验 state、code、redirect_uri 和 PKCE;
  10. 财务后端调用 Token Endpoint;
  11. IdP 签发 ID Token、Access Token 和可选 Refresh Token;
  12. 财务系统校验 ID Token 的签名、issuer、audience、nonce 和过期时间;
  13. 财务系统映射本地用户和权限,创建本地 Session;
  14. 后续请求携带本地 HttpOnly Cookie;
  15. 用户访问 CRM 时,CRM 再次跳转 IdP;
  16. IdP 已有 Session,直接为 CRM 签发新的 code;
  17. 用户无需再次输入密码。

18. 选型建议

场景
推荐
新建 Web/API 平台
OIDC Authorization Code + PKCE
企业接入传统 IdP
SAML 2.0 或由网关转换为 OIDC
内部经典 Web 系统
CAS 或 OIDC
SPA
OIDC + PKCE,优先 BFF
移动端
系统浏览器 + PKCE
服务间
Client Credentials/mTLS/工作负载身份
关键权限操作
短 Token + 回源校验 + MFA

19. 最容易踩的坑

  1. 把 JWT 当成登录协议;
  2. 不校验 state
  3. 允许任意 redirect_uri
  4. 把 Access Token 放在 URL;
  5. 把 ID Token 当 API Token;
  6. 把 Refresh Token 存在 localStorage;
  7. 不校验 issaudnonce
  8. 只依赖 JWT 过期,不设计撤销;
  9. 用户离职后只禁用账号,不撤销已有会话;
  10. 多系统共享过宽的 Domain Cookie;
  11. 日志打印完整 Token;
  12. 误以为“IdP 登录了”就代表所有业务权限都合法;
  13. 不区分认证(Authentication)和授权(Authorization);
  14. 不考虑时钟偏差、密钥轮换和公钥缓存;
  15. 前端跳转成功就认为后端认证成功。

20. 总结

SSO 的本质不是共享登录页面,而是建立跨应用的信任关系:

统一身份认证      + 标准协议      + 短期凭证      + 严格回调校验      + 权限与会话治理      + 可审计与可撤销      = 可生产化的单点登录系统

对新系统而言,OIDC Authorization Code + PKCE 是常见起点;对传统企业系统,SAML 仍有价值;对浏览器安全,BFF + HttpOnly Cookie 通常比把 Token 暴露给前端更稳妥。无论使用何种协议,都必须将 state、nonce、PKCE、redirect_uri、issuer、audience、scope、过期、撤销和审计 当作完整系统的一部分,而不是可选配置。

参考资料

  • OAuth 2.0 RFC 6749
  • OAuth 2.0 Security Best Current Practice
  • OpenID Connect Core 1.0
  • SAML 2.0 Technical Overview
  • OWASP OAuth 2.0 Cheat Sheet