ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSO单点登录实战:协议选型、认证中心搭建与避坑指南

SSO单点登录实战:协议选型、认证中心搭建与避坑指南

简介:这是一份面向Java Web开发者与系统架构学习者的SSO单点登录实战项目源码,围绕Servlet环境下多应用统一认证场景,帮助读者理解一次登录、多系统互通的实现思路。压缩包共80个文件,约103KB,以xml配置、java源码、jsp页面、properties配置及class编译文件为主,另含lst、prefs、classpath、project等工程与IDE配置文件,完整呈现sso-server、sso-oa、sso-pro三个模块的目录结构。资源涵盖认证中心、自定义过滤器、Session共享、Token生成与校验、注销通知等关键环节,并预留了集成CAS、OAuth2、OpenID Connect等开源框架的扩展方向。已有146人学习关注,适合希望从零搭建SSO原型、对照源码梳理认证流程、排查过滤器与票据验证问题的开发者参考,也可作为课程设计或小型项目的起步骨架。

1. 从一次登录失效说起:sso单点登录到底在解决什么

早上九点,某公司内部系统群里炸了锅。客服同事反馈,客户在门户改完密码后,工单系统、报表系统、审批系统全部掉线,需要挨个重新登录。运维排查发现,三个系统各自维护了一套用户表,改密只同步了门户,另外两个系统的会话压根不知道这回事。这不是个例,而是所有多系统并存的团队迟早会撞上的墙。

sso单点登录(Single Sign-On)要解决的就是这件事:用户在一处完成身份认证,访问其他受信系统时不再重复输入凭据。它不是一个具体框架,而是一套协议加落地架构。适合谁?手里有两套以上自研系统、用户体系开始打架的后端和运维;也适合正在做中台改造、想把登录收口到统一入口的团队。读完这篇,你能判断自己该选哪种协议、怎么搭最小可跑通的认证中心、以及最容易翻车的几个参数。

2. 协议选型:CAS、OAuth2、OIDC 到底该用哪个

2.1 三种主流协议的本质差异

很多人把 OAuth2 直接当成单点登录方案,这是个高频误解。OAuth2 是授权协议,解决的是“第三方应用能不能代表用户访问资源”,它本身不规定用户身份如何传递。OIDC 是在 OAuth2 之上补了身份层,用 ID Token(一个 JWT)承载用户信息,才真正适合做登录。CAS 则是更早的、专门为单点登录设计的协议,流程简单,票据机制清晰。

选型时我一般看三个维度:客户端类型、是否需要第三方接入、团队对 JWT 的熟悉程度。纯内部 Web 系统,CAS 够用且心智负担低;要开放给外部合作方或移动端,OIDC 更合适;如果已经有 OAuth2 授权服务器,直接升级到 OIDC 成本最低。

协议核心凭证典型场景令牌格式
CASTGT / ST 票据内部多 Web 系统XML 或自定义
OAuth2Access Token授权第三方访问资源不限定
OIDCID Token + Access Token登录 + 授权,含移动端JWT

2.2 认证中心的最小架构

不管选哪个协议,认证中心(Identity Provider,简称 IdP)的骨架是固定的:一个统一的登录页、一个会话存储、一组受信客户端注册信息、一个票据或令牌签发模块。我习惯把会话存 Redis,因为多实例部署时本地 Session 必然不一致,这是血泪经验。

下面是一个基于 OIDC 的最小认证中心核心配置,用 Python 的 Authlib 库演示,重点看客户端注册和令牌签发部分:

# auth_server.py from authlib.integrations.flask_oauth2 import AuthorizationServer from authlib.oauth2.rfc6749 import grants from flask import Flask, request, jsonify import jwt import time app = Flask(__name__) app.secret_key = "replace-with-random-secret" # 生产环境务必用环境变量注入 # 客户端注册信息,实际项目应存数据库 CLIENTS = { "report-system": { "client_secret": "report-secret-001", "redirect_uris": ["https://report.internal/callback"], "allowed_scopes": ["openid", "profile"], }, "ticket-system": { "client_secret": "ticket-secret-002", "redirect_uris": ["https://ticket.internal/sso/callback"], "allowed_scopes": ["openid", "email"], }, } # 签发 ID Token,有效期设短,配合 Refresh Token 使用 def issue_id_token(user_id, client_id, scopes): now = int(time.time()) payload = { "iss": "https://sso.internal", # 签发者,客户端必须校验 "sub": user_id, # 用户唯一标识 "aud": client_id, # 受众,防止令牌被其他客户端复用 "exp": now + 300, # 5 分钟过期,降低泄露风险 "iat": now, "scope": " ".join(scopes), } return jwt.encode(payload, app.secret_key, algorithm="HS256") @app.route("/authorize") def authorize(): client_id = request.args.get("client_id") redirect_uri = request.args.get("redirect_uri") if client_id not in CLIENTS: return jsonify({"error": "invalid_client"}), 400 if redirect_uri not in CLIENTS[client_id]["redirect_uris"]: return jsonify({"error": "invalid_redirect_uri"}), 400 # 此处应跳转登录页,登录成功后生成 code 并重定向 return jsonify({"status": "redirect_to_login", "client": client_id})

这段代码里三个参数最要命:aud必须严格等于请求的 client_id,否则 A 系统的令牌能拿去访问 B 系统;exp不要超过 5 到 10 分钟,长有效期令牌一旦泄露就是灾难;redirect_uris必须白名单精确匹配,用前缀匹配等于给开放重定向漏洞开门。

2.3 客户端接入的四个步骤

客户端侧要做的事比想象中少,但每一步都不能省。第一步,在认证中心注册,拿到 client_id 和 client_secret,登记回调地址。第二步,用户访问受保护页面时,检查本地会话,没有就重定向到认证中心的/authorize端点。第三步,用户登录后带 code 回调,客户端用 code 换 ID Token。第四步,校验 ID Token 的签名、iss、aud、exp,通过后建立本地会话。

# client_callback.py import jwt import requests from flask import Flask, request, session app = Flask(__name__) app.secret_key = "client-local-secret" SSO_ISSUER = "https://sso.internal" CLIENT_ID = "report-system" CLIENT_SECRET = "report-secret-001" @app.route("/callback") def callback(): code = request.args.get("code") # 用 code 换令牌,必须走服务端到服务端,不能在前端做 resp = requests.post( f"{SSO_ISSUER}/token", data={ "grant_type": "authorization_code", "code": code, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "redirect_uri": "https://report.internal/callback", }, timeout=5, ) token_data = resp.json() id_token = token_data.get("id_token") # 校验令牌,任何一项不过都拒绝建立会话 try: claims = jwt.decode( id_token, options={"verify_signature": True}, algorithms=["HS256"], audience=CLIENT_ID, issuer=SSO_ISSUER, ) except jwt.InvalidTokenError as e: return f"token invalid: {e}", 401 session["user_id"] = claims["sub"] session["login_at"] = claims["iat"] return "login success"

注意jwt.decode里的audience和issuer参数,很多人只验签名就放行,结果令牌被跨系统重放。timeout=5也是必须的,认证中心抖动时不能让客户端线程池被拖死。

3. 会话与令牌:把登录态管住的三个关键设计

3.1 全局会话与局部会话的分离

单点登录的核心模型是两层会话:认证中心维护全局会话(用户是否已登录),各客户端维护局部会话(本系统是否放行)。用户注销时,理想情况是全局会话失效,所有局部会话跟着失效。但现实是,客户端本地 Session 不会自动感知,需要额外机制。

常见做法有两种:一是认证中心提供注销通知接口,注销时遍历已注册客户端逐个回调;二是客户端在每次请求时校验令牌有效性,令牌过期即跳转重新认证。前者实时性好但实现复杂,后者简单但有延迟。我一般混合用:关键系统走通知,边缘系统走令牌过期。

3.2 令牌存储位置与刷新策略

Access Token 和 Refresh Token 的存放位置直接决定安全性。Web 场景下,Access Token 放内存或 HttpOnly Cookie,Refresh Token 必须放 HttpOnly + Secure + SameSite 的 Cookie,绝不能塞 localStorage。移动端则放系统安全存储区。

刷新策略上,我习惯让 Access Token 有效期 15 分钟,Refresh Token 7 天且一次性使用。每次刷新都签发新的 Refresh Token 并作废旧的那个,这样即使旧令牌泄露,攻击窗口也有限。下面是一个刷新端点的核心逻辑:

# refresh.py import redis import secrets r = redis.Redis(host="localhost", port=6379, db=0) def rotate_refresh_token(old_token, user_id): # 检查旧令牌是否存在且未被使用 stored_user = r.get(f"refresh:{old_token}") if stored_user is None or stored_user.decode() != user_id: # 令牌不存在或用户不匹配,可能是重放攻击,直接清空该用户所有会话 r.delete(*r.keys(f"refresh:*:{user_id}")) raise ValueError("refresh token reuse detected") # 作废旧令牌 r.delete(f"refresh:{old_token}") # 签发新令牌,绑定用户 new_token = secrets.token_urlsafe(32) r.setex(f"refresh:{new_token}", 7 * 24 * 3600, user_id) return new_token

secrets.token_urlsafe(32)生成 256 位随机令牌,比 uuid 更难预测。检测到重放时清空该用户所有刷新令牌,这是防令牌盗用的后悔药,代价是用户需要重新登录,但比账号被持续控制强。

3.3 跨域与 Cookie 的边界

当认证中心和客户端不同域时,Cookie 默认不发送,这是很多人调试半天找不到原因的坑。解决方案是认证中心设置 Cookie 时指定SameSite=None; Secure,客户端跳转时带上前端无法读取的会话 Cookie。但SameSite=None要求必须 HTTPS,本地开发环境需要额外处理。

另一个边界是子域共享。如果认证中心在sso.internal,客户端在report.internal,可以把 Cookie 的 Domain 设为.internal,实现子域共享。但跨顶级域(如a.com和b.com)无法共享 Cookie,只能靠令牌传递。

4. 避坑排查:单点登录落地时最容易翻车的五件事

4.1 回调地址配了前缀匹配,上线就被开放重定向

现象:安全扫描报出开放重定向漏洞,攻击者构造redirect_uri=https://evil.com能拿到授权码。原因:客户端注册时用了startswith或通配符匹配回调地址。解决:改为精确字符串匹配,且只允许 HTTPS,本地开发单独维护白名单。

4.2 时钟偏移导致令牌校验随机失败

现象:令牌刚签发就报exp过期,或者偶尔校验通过偶尔失败。原因:认证中心和客户端服务器时间不同步,偏差超过令牌有效期容忍度。解决:所有节点接入同一 NTP 源,JWT 校验时留 30 到 60 秒的leeway参数。

jwt.decode(id_token, algorithms=["HS256"], audience=CLIENT_ID, issuer=SSO_ISSUER, leeway=60) # 容忍 60 秒时钟偏移

4.3 注销只清了本地,全局会话还在

现象:用户点注销后,回到认证中心发现仍是登录态,直接又跳回系统。原因:客户端只清了本地 Session,没通知认证中心。解决:注销时先重定向到认证中心的/logout端点,由认证中心清全局会话后再回调各客户端清理接口。

4.4 令牌放 localStorage 被 XSS 一锅端

现象:安全审计发现任意 XSS 都能窃取令牌,攻击者拿到后可在异地登录。原因:前端把 Access Token 存了 localStorage。解决:改用 HttpOnly Cookie 传输,前端通过后端代理调用接口,令牌对 JavaScript 不可见。

4.5 多实例部署会话不一致,用户反复跳登录

现象:负载均衡后,用户第一次请求在实例 A 登录成功,第二次请求打到实例 B 又要求登录。原因:会话存在本地内存,多实例不共享。解决:会话统一存 Redis,所有实例读写同一份数据,并设置合理的过期时间。

5. 进阶技巧:用 PKCE 和令牌内省把安全性再抬一档

5.1 授权码模式加 PKCE,防拦截

传统授权码模式在移动端或单页应用里,client_secret 无法安全保存,授权码一旦被拦截就能换令牌。PKCE(Proof Key for Code Exchange)的思路是:客户端生成一个随机code_verifier,哈希后作为code_challenge随授权请求发送,换令牌时再带上原始code_verifier,认证中心比对哈希值。这样即使授权码被截获,没有code_verifier也换不到令牌。

# pkce_client.py import hashlib import base64 import secrets # 生成 code_verifier,43 到 128 位随机字符串 code_verifier = secrets.token_urlsafe(64) # 计算 code_challenge,S256 方法 digest = hashlib.sha256(code_verifier.encode()).digest() code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() # 授权请求带上 code_challenge 和 method auth_url = ( f"https://sso.internal/authorize?" f"response_type=code&client_id=mobile-app&" f"code_challenge={code_challenge}&code_challenge_method=S256" ) # 换令牌时带上原始 code_verifier token_payload = { "grant_type": "authorization_code", "code": received_code, "code_verifier": code_verifier, # 认证中心会重新哈希比对 "client_id": "mobile-app", }

code_verifier必须每次授权都重新生成,不能复用。code_challenge_method用 S256,不要用 plain,plain 等于没加。

5.2 令牌内省:让资源服务器不再盲信

JWT 是自包含的,资源服务器本地校验签名即可,但这也意味着令牌一旦签发,在过期前无法主动撤销。对安全要求高的接口,可以改用令牌内省(Token Introspection):资源服务器把令牌发给认证中心,由认证中心返回是否有效及用户信息。代价是每次请求多一次网络调用,适合低频但敏感的操作用。

校验方式延迟可撤销适用场景
本地 JWT 校验低否高频普通接口
令牌内省高是支付、改密等敏感操作
短期 JWT + 黑名单中部分折中方案

我一般对普通查询用本地校验,对资金和权限变更类接口走内省,兼顾性能和可控性。

5.3 一个我常做的验证习惯

每次改完认证流程,我会用三个账号做交叉验证:账号 A 登录系统 1,确认系统 2 免登录;账号 A 注销,确认系统 2 下次访问跳登录;账号 B 登录系统 2,确认拿到的令牌sub是 B 而不是 A。这三步能挡住绝大多数会话串号和令牌复用问题。单点登录的坑大多不在协议本身,而在会话边界和令牌生命周期的细节里,多验一步,少熬一夜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进