单点登录(Single Sign-On,简称 SSO)这些年几乎成了企业级系统的标配,但真正动手实现过一遍的人都知道,它的坑远比想象中多。我最初接手一个内部多应用平台改造时,光是"为什么登出总是登不干净"这一个问题就被折磨了两个晚上。后来把原理、协议选型和实现细节彻底捋清,才发现绝大多数问题的根因都出在同一个地方:没有把"认证中心"和"业务系统"的边界想清楚。这篇文章想把单点登录的完整脉络讲明白——它不是某个具体的库或产品,而是一套认证架构的抽象。理解了这套抽象,你才能在各种协议、各种中间件之间做选择时不迷路。
无论你是刚接触后端的新人,还是准备给团队搭建统一登录体系的技术负责人,这篇文章都适用。我会从"为什么需要SSO"讲起,逐步深入到票据机制、协议选型,再给一个可运行的最小实现,最后聊一聊我在真实环境中踩过的安全坑。
1. 多系统时代的登录困境:SSO到底在解决什么
1.1 六套账号系统的噩梦
想象一个最普通的中型公司:办公平台、运维后台、财务系统、文档库、客户关系管理系统再加上一个项目管理工具。如果每个系统都是各自独立开发的,大概率每套系统都维护着一张自己的用户表、一套自己的密码策略,甚至还有一种"研发团队自己说了算"的密码规则。用户入职时,管理员在一个又一个系统里创建账号;离职时,又得挨个登录进去禁用账号。漏禁一个,就是安全审计里的一条风险项。
我曾经参与改造的一个项目,就是这种状态。管理员手里攥着一张密码表,上面记着各个系统的管理员密码。由于每个系统的密码过期策略不一致,每隔几周就有人在群里喊"财务系统的密码又忘了"。改一次密码要去六七个后台挨个操作,偶尔还会出现"系统A改了、系统B没改"的错位情况。这种状态持续下去,账号本身就成了最大的安全隐患。
SSO解决的本质问题,就是把这个散落在各业务系统里的"身份认证"搬到一个统一的地方去处理。业务系统不再负责"验证用户名密码对不对",它只负责"确认当前请求的用户是谁"。看起来只是责任转移,但这一个转移能让账号生命周期管理、密码策略、安全审计都收敛到单一位置。
1.2 认证与授权从来不是一回事
很多人在讨论单点登录时,会误以为"上了SSO,权限就能统一管理了"。这是一个高频误区。认证(Authentication)回答的是"你是谁",授权(Authorization)回答的是"你能干什么"。SSO管的是前者,后者依然属于各业务系统自己的职责范围。
比如用户登录了协同办公系统,认证中心确认他是合法员工;但他在这个办公系统里能看哪些项目、能批哪些报销,这是业务系统内部的角色权限模型决定的。SSO统一了"入口",没有统一"内部道路的通行证"。把这两个概念混在一起,会导致设计时纠缠不清:该放在认证中心的放不进去,该留在业务系统的又舍不得剥离。正确姿势是:身份认证集中做,业务授权在各自系统内维护,最多通过统一身份源同步组织架构和角色基准,但不替代业务系统的精细控制。
1.3 会话孤岛带来的连锁成本
各系统独立登录,表面上只是用户麻烦一点,实际上是一连串连锁成本:
- 密码疲劳导致弱密码泛滥:每个系统一套密码,用户记不住,最终会退化成"在记事本里存密码"或者所有系统用同一个密码。
- 账号开通与停用严重滞后:内部应用一多,账号创建、权限变更是高耗时事务,员工离职后账号仍然有效的情况会持续很长时间。
- 审计视角碎片化:登录日志散落在N套系统里,安全团队想追溯"某个账号在某段时间内访问过哪些系统",几乎只能手动翻记录。
- 攻击面被放大:每个系统都有自己的登录入口和密码找回逻辑,任何一个系统防御薄弱,用户身份就面临风险。
会话孤岛的根因是每个系统都自己管理"登录状态"。单点登录把这个状态抽离成"认证中心的全局会话 + 业务系统的本地会话"两层结构。用户只需要在认证中心登录一次,后续访问任意接入系统时,认证中心通过票据机制确认"这个人刚刚已经登录过了",业务系统再据此建立自己的本地会话。
2. 撕开SSO的原理面纱:认证中心、票据与会话
2.1 三个角色,一次受控的"握手"
单点登录的核心模型里有三个角色:用户、认证中心(Identity Provider,简称 IdP)、业务系统(Service Provider,简称 SP)。用一个不太严谨但很好懂的生活类比:认证中心就像体育场馆门口的唯一检票闸机,业务系统是场馆内的各个分馆。观众在闸机处验一次票,领到一个"手环",之后每个分馆的工作人员看到手环就直接放行,不用再重复验票。
一次完整的SSO流程,在逻辑上可以拆成这些步骤:
- 用户访问业务系统A的某个页面,业务系统发现用户没有本地会话。
- 业务系统把用户重定向到认证中心,并在URL里带上自己的回调地址(redirect_uri)和一个随机状态值(state)。
- 认证中心检查用户是否已经建立过全局会话。如果没有,就展示登录页,要求输入账号密码。
- 用户登录成功后,认证中心建立全局会话,同时生成一个一次性票据(Ticket),通过浏览器重定向回业务系统的回调地址。
- 业务系统收到票据后,不能直接信任它,而是拿着票据到认证中心的后端接口去校验。
- 认证中心校验票据有效,返回用户标识等基本信息。
- 业务系统根据返回的用户标识建立自己的本地会话,然后跳回用户最初想访问的页面。
之后用户再访问业务系统B,流程会从第3步直接跳到第4步,因为此时他已经有了全局会话,认证中心无需让他再登录一次。这就是"单点登录"四个字的含义:只在认证中心登录一次,所有接入系统都认可。
2.2 票据的完整生命周期:发放、传递、校验、消亡
票据是整个SSO流程里最容易被轻视、又最容易出错的部分。很多新手会把票据当普通ID随便处理,结果埋下安全漏洞。理解票据生命周期,是理解SSO的关键。
票据生命周期分四个阶段:
- 发放:认证中心确认用户身份后,生成一个随机字符串作为票据。它必须有足够的随机性和长度,不能被猜测。
- 传递:票据通过浏览器重定向传给业务系统,所以它只是"一次性凭证",不是"身份证明本身"。业务系统拿到后,必须马上拿去校验。
- 校验:业务系统在服务端调用认证中心的校验接口。注意,这一步必须是服务端到服务端的调用,绝不能由浏览器直接请求校验接口——否则票据会暴露给前端,任何人都能冒用。
- 消亡:票据校验成功后,认证中心必须立即删除它。同一张票据使用两次,第二次必须被拒绝。
我自己的实现里,Redis里会存两种key:一种是全局会话key,保存用户登录状态,有效期较长;另一种是票据key,有效期极短(通常30秒到5分钟),并且使用过一次就删掉。票据等于"一次性入场券",入场后作废;全局会话等于"已领手环的标记",下次再进门时闸机认得你。
2.3 会话载体之争:服务端Session与JWT谁更适合SSO
关于会话状态,业内绕不开的争论是:服务端Session还是JWT(JSON Web Token)。单点登录场景里,我的结论很明确:认证中心必须用服务端Session(或等价的集中式存储),JWT更适合作为业务系统拿到用户信息后的令牌形式,但不是SSO主流程的首选。
| 维度 | 服务端 Session | JWT Token |
|---|---|---|
| 存储位置 | 服务端内存/Redis,客户端只保留Cookie | 客户端持有令牌,服务端验签 |
| 可撤销性 | 删掉存储即失效,即时生效 | 撤销困难,需要黑名单机制 |
| 跨域体验 | 受Cookie SameSite、Domain限制 | 可放在Authorization头,跨域更灵活 |
| 会话状态 | 有状态,可精确控制过期 | 无状态,但无法主动踢人 |
SSO认证中心承担的是"全局登录状态",它必须支持主动下线、踢人、实时禁用账号等操作。用纯JWT做全局会话,想实现"管理员封禁一个员工,立马让他在所有系统失效"就需要额外的黑名单存储,等于又造回了一个状态层。所以我的默认设计是:认证中心用Redis存Session,单点登录的全局会话放这里;业务系统拿到用户信息后,可以自由选择本地存Session还是发Token,那是业务系统自己的事了。
3. 协议选型决定上限:CAS、OAuth2/OIDC、SAML怎么挑
3.1 CAS:企业内网时代留下的经典方案
CAS(Central Authentication Service)是最老牌的单点登录协议,耶鲁大学时期就已有雏形,后来开源社区广泛使用。它的流程和我上一章描述的基本一致:认证中心签发一次性票据,业务系统服务端校验。优点是模型简单、文档多、老系统集成资料丰富,企业内部纯Web应用时代非常流行。
但CAS也有明显的时代局限。它主要解决"登录认证"这件事,对"授权"和"API访问"的支持非常薄弱。如果业务系统不仅需要知道用户是谁,还需要代表用户去访问其他服务的接口,CAS就力不从心了。移动端和前后端分离的现代架构下,CAS处理得也比较别扭。我的判断是:如果接入方都是企业内部老系统、纯浏览器访问、且以认证为主,CAS仍然是一个稳妥选择;但如果你今天从零设计,我会直接推荐下一节的OIDC。
3.2 OIDC/OAuth2:现代应用的首选组合
OAuth2解决的是"授权"问题:用户允许某个第三方应用访问自己在某服务上的资源。OIDC(OpenID Connect)是在OAuth2之上叠加了一层身份层,额外返回一个ID Token,用来告诉应用"当前登录的用户是谁"。两者配合,正好覆盖了"认证 + 授权"两个需求。
现代Web应用和移动应用几乎可以用OIDC统一解决单点登录。它支持授权码模式(Authorization Code),适合有后端服务的应用;还支持PKCE扩展,让纯前端的SPA应用也能安全使用。ID Token本身是JWT,业务系统验签后就能拿到用户身份,不再需要像CAS那样每次都回调认证中心校验。
我个人的体会是,如果你不需要兼容特别老的协议,OIDC是今天搭建SSO的默认答案。它把校验、发现配置、签名都做了标准化,生态里成熟的客户端库遍地都是,团队学习和排查成本都低很多。
3.3 SAML:协议很稳,但代价不小
SAML(Security Assertion Markup Language)2.0在企业级身份联合里地位很高,很多大型SaaS产品和企业内部系统之间的联邦登录用的就是它。它的特点是基于XML断言,安全模型成熟,适合企业之间做跨组织身份信任。
SAML的问题主要在实现体验上。XML断言解析、数字签名验证、各种断言条件的处理,都比OAuth2系要繁琐得多。新团队第一次摸SAML,光是把证书签名验通就要折腾一阵。我的建议是:只有在你需要对接的第三方系统明确要求SAML时,才优先选它;如果只是内部系统统一登录,完全用不着把SAML的重担压在自己身上。
3.4 九个问题帮你确定选型边界
与其到处问"用哪个协议",不如先回答下面几个问题。答案基本就决定了选型方向。
| 问题 | 倾向CAS | 倾向OIDC | 倾向SAML |
|---|---|---|---|
| 接入方是纯内部系统还是外部SaaS? | 纯内部 | 都可 | 外部企业 |
| 是否同时需要接口授权能力? | 弱 | 强 | 一般 |
| 是否有移动端或SPA前端? | 弱 | 强 | 弱 |
| 团队对XML证书体系的熟悉程度? | 不在意 | 不在意 | 核心考量 |
| 是否需要企业间身份联合? | 不需要 | 可选 | 强烈需要 |
| 未来是否要接社交账号登录? | 不方便 | 方便 | 不方便 |
| 客户端生态成熟度要求? | 中等 | 非常高 | 中等 |
| 改造老系统的成本是否敏感? | 低 | 低 | 高 |
| 日志审计和扩展性要求? | 一般 | 强 | 一般 |
4. 从零搭建一个最小SSO:能跑通才是真理解
讲再多的原理,都不如亲手搭一个最小实现来得直观。下面我给出一个简化版示例:一个认证中心 + 两个业务应用,目的是把"票据怎么流转、校验怎么调用"跑通。完整代码我就不贴全量了,核心逻辑都在下面,你照着能搭出一个能用的Demo。
4.1 先定存储与会话模型:为什么用Redis
我先说存储选型。单机演示当然可以用内存Map,但真实环境下,认证中心往往要部署多个实例,Session必须集中存放。Redis天然支持过期时间,非常适合"票据短时效、会话长时效"这种模型。我选它的核心原因有这三个:
- 票据需要短TTL并支持删除,Redis的
SETEX和DEL操作非常贴合。 - 全局会话需要共享,所有认证中心实例都要能读到同一份会话数据。
- 令牌续期、黑名单之类的扩展操作,Redis的数据结构也能搞定。
本示例里账号数据我直接用Map模拟。真实项目中,这里应该对接你的统一身份源,比如企业目录服务或数据库中的用户体系。
4.2 认证中心三个核心接口的实现
认证中心需要三个核心接口:展示登录页、处理登录提交、校验票据。我用Node.js加Express写一个精简版,存储用Redis。
// sso-server.js 核心逻辑 const express = require('express'); const session = require('express-session'); const crypto = require('crypto'); const redis = require('redis'); const app = express(); const client = redis.createClient(); const ISSUE_TTL = 30; // 票据30秒有效 // 演示账号表:真实环境换成统一身份源查询 const USERS = new Map([ ['user_a', { uid: 'u_10086', name: '内部用户A', password: 'pass_demo' }] ]); app.use(express.urlencoded({ extended: true })); app.use(session({ secret: 'demo-secret-token', resave: false, saveUninitialized: false })); // 生成一次性票据 function createTicket(uid) { const ticket = crypto.randomBytes(24).toString('hex'); client.setEx(`sso:ticket:${ticket}`, ISSUE_TTL, uid); return ticket; } // 返回重定向地址,把票带给业务系统 function buildRedirect(redirectUri, ticket, state) { const sep = redirectUri.includes('?') ? '&' : '?'; return `${redirectUri}${sep}ticket=${ticket}&state=${state}`; } // 1. 登录页:如果已有全局会话,直接签票跳转 app.get('/login', (req, res) => { const { redirect_uri: redirectUri, state } = req.query; if (req.session.user) { const ticket = createTicket(req.session.user.uid); return res.redirect(buildRedirect(redirectUri, ticket, state)); } res.send(` <form method="post" action="/api/login"> <input type="hidden" name="redirect_uri" value="${redirectUri}" /> <input type="hidden" name="state" value="${state}" /> <input name="username" placeholder="用户名" /> <input name="password" type="password" placeholder="密码" /> <button type="submit">登录</button> </form> `); }); // 2. 登录提交:校验账号,建立全局会话,签票跳转 app.post('/api/login', async (req, res) => { const { username, password, redirect_uri: redirectUri, state } = req.body; const user = USERS.get(username); if (!user || user.password !== password) { return res.status(401).send('用户名或密码错误'); } req.session.user = { uid: user.uid, name: user.name }; const ticket = createTicket(user.uid); res.redirect(buildRedirect(redirectUri, ticket, state)); }); // 3. 票据校验:一次性消费,校验通过返回用户信息 app.get('/api/validate', async (req, res) => { const { ticket } = req.query; if (!ticket) return res.status(400).json({ error: 'missing_ticket' }); const uid = await client.getDel(`sso:ticket:${ticket}`); if (!uid) return res.status(401).json({ error: 'ticket_invalid_or_expired' }); const user = USERS.get(uid); res.json({ uid: user.uid, name: user.name }); }); app.listen(8080, () => console.log('SSO server on :8080'));这个版本已经包含了我反复强调的几个点:票据用getDel一次性消费、绝对不在校验接口接受用户从浏览器直接传过来的身份信息、全局会话与票据分别用不同TTL控制生命周期。密码校验用了明文对比,只是演示,真实项目必须用加盐哈希或统一认证服务。
4.3 应用端接入:跳转、回调、校验三件套
每个业务系统接入时,只需要做三件事:写一个"未登录就跳转认证中心"的中间件,写一个"接收票据并去校验"的回调接口,然后建立本地会话。用Express写一个最小示例:
// app-a.js 业务系统 const express = require('express'); const session = require('express-session'); const app = express(); const SSO_BASE = 'http://localhost:8080'; const CALLBACK = 'http://localhost:3000/auth/callback'; const STATE_SECRET = 'random-per-request'; app.use(session({ secret: 'app-a-secret', resave: false, saveUninitialized: false })); // 未登录跳SSO function requireLogin(req, res, next) { if (req.session.user) return next(); // state在真实实现里要先存起来,校验回调时比对 res.redirect(`${SSO_BASE}/login?redirect_uri=${encodeURIComponent(CALLBACK)}&state=${STATE_SECRET}`); } app.get('/auth/callback', async (req, res) => { const { ticket, state } = req.query; // state必须和发起跳转时保存的一致,这里省略比对环节 if (!ticket) return res.status(400).send('no ticket'); const r = await fetch(`${SSO_BASE}/api/validate?ticket=${ticket}`); const data = await r.json(); if (!data.uid) return res.status(401).send('ticket invalid'); req.session.user = data.uid; res.redirect('/'); }); app.get('/', requireLogin, (req, res) => res.send(`当前用户:${req.session.user}`)); app.listen(3000, () => console.log('App A on :3000'));第二个应用照葫芦画瓢,换一个端口即可。跑通后你会发现一个关键体验:在应用A登过一次,再访问应用B时,不会出现登录页,而是被认证中心直接签一张新票据送到回调接口。因为全局会话还在,认证中心认得你。
说一句我的实践经验:状态成熟之后,这段逻辑会封装成一个SDK或者中间件,各业务系统只配置域名和回调地址,不该让每个接入方重复实现一遍跳转与校验。否则每接入一个系统,都得重新踩一遍安全坑。
4.4 单点登出:最容易做成半拉子工程的功能
SSO里"登录"大家都做得很认真,"登出"却经常草草收场。最典型的问题是:用户点了登出,认证中心的全局会话被清了,但业务系统A、B、C上的本地会话还活着。用户再访问A,业务系统发现本地会话还在,照样放你进去;直到本地会话过期才需要重新认证。这等于"登出"变成了"假登出"。
完整的单点登出(SLO)有两种常见思路:
- 认证中心在登出后,调每个业务系统的登出回调,通知它们清理本地会话。
- 业务系统定期到认证中心检查全局会话是否仍然有效,发现失效就清理本地会话。
方案一实时性好,但要求业务系统提供一个登出回调接口。方案二实现简单但存在延迟窗口。我的建议是:先明确"登出的安全基线"。如果业务系统只是内部工具,可以接受短暂延迟,方案二够用;如果有严格合规要求,必须做方案一的回调通知,并且回调通知要带鉴权,防止恶意请求伪造登出。
5. 安全与踩坑:真实环境中最容易失手的薄弱环节
5.1 回调地址白名单:一次开放重定向的教训
我在前面提过,业务系统会给认证中心提供一个回调地址。这个地址如果不加限制,会酿成典型的开放重定向漏洞。攻击者构造一个URL,redirect_uri指向他自己的钓鱼站,然后诱导用户点击。用户看到的是自家认证中心的登录页,放心输入了账号密码,登录成功后却被重定向到了钓鱼站。
别以为这个漏洞很老就没人踩。我经手过的项目里,就出现过因为图省事,对回调地址只做前缀匹配,结果攻击者注册了相似域名绕过校验的案例。正确做法是白名单精确匹配:
const ALLOWED_REDIRECTS = new Set([ 'https://app-a.internal.example.com/auth/callback', 'https://app-b.internal.example.com/auth/callback' ]); function checkRedirect(uri) { return ALLOWED_REDIRECTS.has(uri); }回调地址还有两个细节:必须只允许HTTPS,不允许可猜测的通配符;校验时用字符串精确相等,不要用includes这类包含匹配。曾经出现过用https://app-a.internal.example.com.evil.com来绕过含前缀匹配的案例,这些都是真实教训。
5.2 state参数:防的不只是CSRF
state参数在很多初版实现里被写死成一个固定字符串,这等于没做防护。state的核心作用至少有三个:防止登录CSRF、防止会话固定攻击、把"发起登录的请求"和"回调回来的请求"绑定起来。
攻击场景是这么发生的:攻击者自己登录认证中心拿到一个有效凭证,然后构造一个带着这个凭证的URL发给受害者。受害者点击后,在业务系统里被绑定成攻击者的身份,后续操作都被算到攻击者头上。如果应用系统有写入操作,后果直接是数据污染。解决办法是每次发起跳转时生成不可预测的随机state,存到临时存储里;回调时比对,不一致就拒绝。很多成熟框架已经把这一步内置了,但如果手写,千万别省。
5.3 票据防重放与时效设计
票据是认证链路里最核心的凭证,它有两个要素必须同时守好:时效性和一次性。时效性通过TTL控制,我一般设置在30秒到5分钟之间。太短会导致用户体验问题(网络慢时回调还没到就过期),太长又会扩大重放攻击窗口。
一次性则要求认证中心在返回校验结果的同时删除票据。前面代码里用getDel原子地取出并删除,就是为了避免"先查再删"之间的并发空隙,防止同一张票据被并发请求拿到两次。另外,票据校验接口必须做频率限制和日志记录,异常的大量校验请求本身就是被盗用的预警信号。
还有一个常见失误:在业务系统端,用票据本身去识别用户,而不是用校验结果里返回的用户标识。票据是一个随机字符串,跟用户ID没有稳定的对应关系,拿它当会话身份是设计混乱的根源。
5.4 线上踩坑清单:七条浓缩经验
最后把这些年见过的、踩过的问题汇总一下,每条都对应过一次真实的线上故障:
- 把SSO校验接口暴露给浏览器端直接调用,导致票据可以被任意人获取使用。
- Cookie的Domain和SameSite设置不当,第三方Cookie被浏览器拦截后,SSO在Safari和Chrome里表现不一致。
- 登出只清了认证中心全局会话,忘了业务系统本地会话,造成"登出无效"的投诉。
- 回调地址白名单用了通配符或包含匹配,被相似域名钓鱼利用。
- 票据有效期设太长,又没做一次性校验,重放攻击可以在数小时内生效。
- 未校验state参数,业务系统被登录CSRF攻击,用户会话被固定到攻击者账号。
- 认证中心单点故障时,没有降级方案,全部业务系统跟着一起无法登录,事故等级直接拉满。
如果你在实现或运维SSO时遇到"莫名其妙登不上""登出登不干净""偶尔出现别人的登录态",先按上面七条排查,大概率能定位到根因。
我个人做SSO项目有一个习惯:无论协议选型怎么变,动手前先把票据怎么流转、每个角色怎么参与画清楚,确认过期时间、校验时机、登出通知这三件事都有明确设计,然后再写代码。这一套组合拳打下来,绝大多数安全和管理问题都能在设计阶段被拦掉。希望这篇东西能帮你少走几段弯路。