单点登录 SSO 从原理到落地:CAS、OAuth2 与 JWT 怎么选

手里有 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
喜欢就支持一下吧
点赞8 分享