手里有 OA、工单、报表、知识库四五个系统,每个都要单独登录一次,用户记不住密码,运维改一次密码要跑四个后台。这就是单点登录(Single Sign On,SSO)要解决的问题:认证只做一次,授权在多个系统间传递。
一、SSO 到底解决了什么
很多人把 SSO 和”统一认证”混为一谈,其实两者的关注点不同:
- 统一认证解决”你是谁”,多个系统共用一套账号体系
- 单点登录解决”登录一次,多处通行”,重点在会话(Session)的跨域共享
- 单点登出是最容易被漏掉的一半,退出时必须通知所有已登录的子系统销毁会话
换句话说,统一认证是前提,SSO 是在这个前提上做的会话接力。
二、核心角色与票据流转
不管用哪种方案,SSO 都逃不开这四个角色的配合:
| 角色 | 常见叫法 | 职责 |
|---|---|---|
| 用户 | User Agent | 携带浏览器 Cookie 发起请求 |
| 业务系统 | SP / Client / 接入方 | 判断本地会话是否存在,不存在则跳转认证中心 |
| 认证中心 | IdP / CAS Server | 唯一持有账号密码,负责签发与校验票据 |
| 票据 | Ticket / Code / Token | 一次性的、有生命周期的信任凭据 |
流程的关键只有一句话:业务系统永远不接触密码,只接触票据。用户没登录时,业务系统把浏览器丢给认证中心;认证中心登录成功后,带着票据把浏览器丢回来;业务系统拿票据去认证中心验一次,验通了就建自己的本地会话。
- 票据必须一次性:验证成功后立刻销毁,防止被重放
- 回跳地址必须白名单校验:否则就是标准的开放重定向漏洞
- 票据生命周期要短:几分钟足够,太长等于把钥匙到处发
三、三种主流方案对比
| 方案 | 票据形态 | 适合场景 | 要留意的地方 |
|---|---|---|---|
| CAS | 服务票据 ST + 票据授权票据 TGT | 内部系统、同公司域名下的一堆老系统 | 协议偏老,移动端对接麻烦 |
| OAuth2 + OIDC | 授权码 Code,换 ID Token | 对外开放、要给第三方授权、有 App 和小程序 | OAuth2 本身是授权协议,认证能力靠 OIDC 补 |
| JWT 共享会话 | 自签名的 JWT | 微服务内部、无状态、系统数量不多 | 签发容易吊销难,密钥泄露等于全线失守 |
选型建议很朴素:内部老系统多就上 CAS,要对外或要接移动端就上 OIDC,纯微服务内部调用用 JWT 就够了,不要为了”先进”硬上 OIDC 再自己写一层兼容层。
四、认证中心的核心代码
不管三种方案哪一套,认证中心要做的事都是一样的四步:发票据、验票据、销毁票据、带令牌回跳。下面是 CAS 思路的最小实现:
<?php
// 认证中心:校验服务票据并签发用户信息
$ticket = $_GET['ticket'] ?? '';
$service = $_GET['service'] ?? '';
// 没有票据,先让用户去登录页,登录后原样回跳
if ($ticket === '' || $service === '') {
header('Location: /login?service=' . urlencode($service));
exit;
}
// 票据必须反查白名单,禁止任意跳转
if (!in_array(parse_url($service, PHP_URL_HOST), TRUSTED_HOSTS, true)) {
exit('非法的服务地址');
}
$user = validateTicket($ticket, $service);
if ($user === null) {
header('Location: /login?service=' . urlencode($service));
exit;
}
// 票据一次性,验证通过立刻销毁,防止重放
destroyTicket($ticket);
// 回跳业务系统,只带回一次性令牌,不带任何用户敏感信息
$token = issueToken($user['id']);
$sep = strpos($service, '?') === false ? '?' : '&';
header('Location: ' . $service . $sep . 'sso_token=' . urlencode($token));
业务系统侧只做一件事:拿到令牌验一次,验通了写本地会话,验不通报错。
const jwt = require('jsonwebtoken');
// 业务系统:校验 SSO 回跳带回的一次性令牌
function ssoGuard(req, res, next) {
const token = req.query.sso_token;
if (!token) {
const back = encodeURIComponent(req.originalUrl);
return res.redirect('https://sso.example.com/login?service=' + back);
}
try {
// 只允许指定算法,别让攻击者自己挑算法
req.user = jwt.verify(token, process.env.SSO_SECRET, { algorithms: ['HS256'] });
return next();
} catch (err) {
return res.status(401).send('令牌无效或已过期,请重新登录');
}
}
五、完整可运行示例
下面这套示例包含认证中心、两个业务系统、单点登出回调,可以直接跑起来对照调试,建议注册后照着改一遍,比只看流程说明快得多。
六、必须提前避开的坑
- 只做单点登录不做单点登出:用户点了退出,其他系统还是登录态,等于假登出
- 票据可复用:验证后不销毁,抓一次 URL 就能无限重放
- 回跳地址不校验:
?service=后面接任何域名都能跳,变成钓鱼跳板 - JWT 用 none 算法或不过期:算法固定白名单 + 短有效期 + Refresh Token
- 把密码或手机号塞进令牌:令牌会出现在 URL、日志、浏览器历史里
- 跨域 Cookie 的 SameSite 漏配:子系统与认证中心不同域时,Cookie 根本发不出去
七、小结
SSO 的复杂度不在协议本身,而在”边界”:票据的有效期边界、回跳地址的白名单边界、登出时的通知边界。把这三条边界收紧,CAS 也能跑得很稳;边界放松,再新的协议也是洞。你的系统现在是几套账号在跑,准备从哪一步开始合并?
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END







