作者:你的代码僚机(一个在屎山里挖宝藏的前端)
开篇暴击:当老板说"所有系统统一登录"
"我们有 10 个业务系统,每个都要单独登录,用户天天骂!明天上线 SSO 统一登录!"
听到这句话的那一刻,我的手在抖,腿在颤,内心只有一个念头: "这 SSO 到底是 Single Sign On 还是 Single Sign Of Death?" 💀
作为一个在 SSO 项目里反复横跳三年的老前端,今天我就用最直白的语言,把单点登录的原理、实战代码和避坑指南全掏出来!不讲废话,直接上干货!
一、原理篇:SSO 到底是个啥?
1.1 传统登录 vs SSO 登录
传统登录(一把梭) :
用户登录 → 生成 Session → 存 Cookie → 下次请求带 Cookie → 验证 Session
问题来了:用户访问系统 A 登录了,访问系统 B 还要登录!用户:???老板:开除你!
SSO 登录(单点登录) :
css
用户访问系统 A → 重定向到 SSO 服务器 → 登录成功 → 生成令牌 → 重定向回系统 A → 系统 A 验证令牌 → 登录成功
用户访问系统 B → 重定向到 SSO 服务器 → 检测已登录 → 直接返回系统 B → 登录成功
核心思想 :一处登录,处处通行! 🚪
1.2 OAuth2.0 四种模式,前端用哪种?
OAuth2.0 有四种授权模式,我们前端最常用的是:
| 模式 | 全称 | 适用场景 | 前端用吗? |
|---|---|---|---|
| 授权码模式 | Authorization Code | Web 应用(最安全) | ✅ 推荐 |
| 隐式模式 | Implicit | 旧版单页应用 | ❌ 已废弃 |
| 密码模式 | Resource Owner Password Credentials | 信任的内部系统 | ⚠️ 谨慎使用 |
| 客户端模式 | Client Credentials | 机器对机器 | ❌ 不适用 |
结论 :我们用 授权码模式!为什么?因为它支持令牌刷新,安全性高,而且符合我们的需求!
1.3 JWT:SSO 的灵魂伴侣
JWT(JSON Web Token)是 SSO 的核心组件,它由三部分组成:
css
Header.Payload.Signature
- Header :声明类型和加密算法(如
{"alg": "HS256", "typ": "JWT"}) - Payload :承载用户信息(如
{"sub": "1234567890", "name": "John Doe", "exp": 1516239022}) - Signature :签名,防止令牌被篡改(
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret))`
重点提醒 :JWT 的 Payload 是明文传输的!不要放密码、身份证号等敏感信息!敏感信息要用加密方式存储在服务端!
1.4 完整流程图解
css
系统 BSSO 服务器系统 A用户系统 BSSO 服务器系统 A用户场景 1:首次访问系统 A场景 2:访问系统 B(已登录)1. 访问系统 A2. 未登录,重定向到 SSO 登录页3. 显示登录表单4. 输入账号密码5. 验证成功,生成授权码6. 重定向到系统 A 的回调地址(携带授权码)7. 用授权码换令牌8. 返回访问令牌9. 保存令牌10. 登录成功,显示首页11. 访问系统 B12. 检测未登录,重定向到 SSO13. 检测已登录(有 SSO 会话)14. 直接重定向到系统 B 的回调地址15. 用授权码换令牌16. 返回访问令牌17. 保存令牌18. 登录成功,显示首页
关键点:
- 用户只需要在 SSO 服务器登录一次
- 每个系统通过"授权码"换"访问令牌",而不是直接传密码
- 令牌有过期时间,支持刷新机制
二、实战篇:手把手搭建 SSO 系统
2.1 环境准备
我们需要三个服务:
- SSO 服务器 :
http://sso.local:3000(模拟授权服务器) - 系统 A :
http://system-a.local:3001(业务系统 A) - 系统 B :
http://system-b.local:3002(业务系统 B)
技术栈:
- SSO 服务器:Node.js + Express + JWT
- 系统 A/B:Vue 3 + TypeScript + Axios
2.2 SSO 服务器实现
###
// sso-server/index.js
const express = require('express');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// 模拟用户数据库
const users = [
{ id: '1', username: 'admin', password: '123456' },
{ id: '2', username: 'user', password: 'password' }
];
// 模拟授权码存储(实际应该存数据库)
const authorizationCodes = new Map();
// 模拟访问令牌存储
const accessTokens = new Map();
// 配置
const JWT_SECRET = 'your-secret-key-change-this-in-production';
const JWT_EXPIRES_IN = '2h'; // 令牌 2 小时过期
const REFRESH_TOKEN_EXPIRES_IN = '7d'; // 刷新令牌 7 天过期
// 生成访问令牌
function generateAccessToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
// 生成刷新令牌
function generateRefreshToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
}
// 登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = users.find(u => u.username === username && u.password === password);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const accessToken = generateAccessToken(user.id);
const refreshToken = generateRefreshToken(user.id);
accessTokens.set(accessToken, {
userId: user.id,
createdAt: Date.now()
});
res.json({
accessToken,
refreshToken,
expiresIn: JWT_EXPIRES_IN
});
});
// 授权码生成接口(模拟)
app.get('/authorize', (req, res) => {
const { clientId, redirectUri, state } = req.query;
// 校验参数
if (!clientId || !redirectUri || !state) {
return res.status(400).json({ error: '缺少必要参数' });
}
// 生成授权码
const code = Math.random().toString(36).substring(2, 15);
authorizationCodes.set(code, {
clientId,
redirectUri,
state,
createdAt: Date.now()
});
// 重定向到回调地址(携带授权码)
res.redirect(`${redirectUri}?code=${code}&state=${state}`);
});
// 用授权码换令牌接口
app.post('/token', (req, res) => {
const { code, clientId, clientSecret, redirectUri } = req.body;
const authCode = authorizationCodes.get(code);
if (!authCode) {
return res.status(400).json({ error: '授权码无效' });
}
// 校验 clientId 和 redirectUri
if (authCode.clientId !== clientId || authCode.redirectUri !== redirectUri) {
return res.status(400).json({ error: '参数校验失败' });
}
// 生成新的访问令牌和刷新令牌
const userId = '1'; // 模拟用户 ID
const accessToken = generateAccessToken(userId);
const refreshToken = generateRefreshToken(userId);
accessTokens.set(accessToken, {
userId,
createdAt: Date.now()
});
// 删除已使用的授权码
authorizationCodes.delete(code);
res.json({
accessToken,
refreshToken,
expiresIn: JWT_EXPIRES_IN,
tokenType: 'Bearer'
});
});
// 刷新令牌接口
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, JWT_SECRET);
const newAccessToken = generateAccessToken(decoded.userId);
res.json({
accessToken: newAccessToken,
expiresIn: JWT_EXPIRES_IN,
tokenType: 'Bearer'
});
} catch (error) {
res.status(401).json({ error: '刷新令牌无效' });
}
});
// 获取用户信息接口
app.get('/userinfo', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '缺少访问令牌' });
}
const accessToken = authHeader.substring(7);
const tokenInfo = accessTokens.get(accessToken);
if (!tokenInfo) {
return res.status(401).json({ error: '访问令牌无效' });
}
const user = users.find(u => u.id === tokenInfo.userId);
res.json({
id: user.id,
username: user.username
});
});
app.listen(3000, () => {
console.log('SSO 服务器运行在 http://localhost:3000');
});
2.3 系统 A 实现(Vue 3 + TypeScript)
javascript
// src/utils/auth.ts
import axios from 'axios';
const SSO_SERVER = 'http://sso.local:3000';
const CLIENT_ID = 'system-a';
const REDIRECT_URI = 'http://system-a.local:3001/callback';
// 存储令牌
let accessToken: string | null = null;
let refreshToken: string | null = null;
let tokenExpiresAt: number = 0;
// 初始化
export function initAuth() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
// 有授权码,用授权码换令牌
exchangeCodeForToken(code);
} else {
// 检查是否已登录
checkLoginStatus();
}
}
// 用授权码换令牌
async function exchangeCodeForToken(code: string) {
try {
const response = await axios.post(`${SSO_SERVER}/token`, {
code,
clientId: CLIENT_ID,
clientSecret: 'your-client-secret', // 实际应该用更安全的方式存储
redirectUri: REDIRECT_URI
});
const { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn } = response.data;
// 保存令牌
setTokens(newAccessToken, newRefreshToken, expiresIn);
// 清除 URL 参数
window.history.replaceState({}, document.title, window.location.pathname);
// 跳转到首页
window.location.href = '/';
} catch (error) {
console.error('交换令牌失败:', error);
// 跳转到登录页
login();
}
}
// 设置令牌
function setTokens(accessToken: string, refreshToken: string, expiresIn: string) {
accessToken = accessToken;
refreshToken = refreshToken;
// 解析过期时间
const [value, unit] = expiresIn.match(/(\d+)([a-z]+)/i) || [2, 'h'];
const expiresInSeconds = unit === 'm' ? parseInt(value) * 60 : parseInt(value) * 3600;
tokenExpiresAt = Date.now() + expiresInSeconds;
// 保存到 localStorage(实际应该用更安全的方式)
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
localStorage.setItem('token_expires_at', tokenExpiresAt.toString());
}
// 检查登录状态
function checkLoginStatus() {
const storedAccessToken = localStorage.getItem('access_token');
const storedRefreshToken = localStorage.getItem('refresh_token');
const storedExpiresAt = localStorage.getItem('token_expires_at');
if (storedAccessToken && storedRefreshToken && storedExpiresAt) {
accessToken = storedAccessToken;
refreshToken = storedRefreshToken;
tokenExpiresAt = parseInt(storedExpiresAt);
// 检查令牌是否过期
if (Date.now() >= tokenExpiresAt) {
// 令牌已过期,尝试刷新
refreshAccessToken();
} else {
// 令牌有效,获取用户信息
getUserInfo();
}
} else {
// 未登录,跳转到 SSO 登录页
login();
}
}
// 刷新访问令牌
async function refreshAccessToken() {
try {
const response = await axios.post(`${SSO_SERVER}/refresh`, {
refreshToken
});
const { accessToken: newAccessToken, expiresIn } = response.data;
// 更新令牌
setTokens(newAccessToken, refreshToken, expiresIn);
// 重新获取用户信息
getUserInfo();
} catch (error) {
console.error('刷新令牌失败:', error);
// 令牌刷新失败,跳转到登录页
login();
}
}
// 获取用户信息
async function getUserInfo() {
try {
const response = await axios.get(`${SSO_SERVER}/userinfo`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
console.log('用户信息:', response.data);
// 保存用户信息到状态管理(如 Pinia)
// store.commit('setUser', response.data);
// 用户已登录,继续访问系统
} catch (error) {
console.error('获取用户信息失败:', error);
// 跳转到登录页
login();
}
}
// 跳转到 SSO 登录页
export function login() {
// 生成随机 state 参数(防止 CSRF 攻击)
const state = Math.random().toString(36).substring(2, 15);
// 保存 state 到 sessionStorage
sessionStorage.setItem('sso_state', state);
// 重定向到 SSO 登录页
const loginUrl = `${SSO_SERVER}/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&state=${state}&response_type=code`;
window.location.href = loginUrl;
}
// 退出登录
export function logout() {
// 清除本地令牌
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('token_expires_at');
// 清除状态管理
// store.commit('setUser', null);
// 跳转到 SSO 退出页(可选)
// window.location.href = `${SSO_SERVER}/logout?redirect_uri=${encodeURIComponent(window.location.origin)}`;
// 跳转到登录页
window.location.href = '/';
}
2.4 系统 B 实现(与系统 A 类似)
系统 B 的实现与系统 A 几乎完全相同,只需要修改以下配置:
ini
// src/utils/auth.ts
const CLIENT_ID = 'system-b'; // 修改为 system-b
const REDIRECT_URI = 'http://system-b.local:3002/callback'; // 修改为 system-b 的回调地址
三、避坑篇:SSO 的 10 大坑,踩过才知道有多痛
坑 1:JWT 过期后静默刷新的坑
问题描述: 用户正在使用系统,突然令牌过期,页面白屏,用户:???
错误做法:
scss
// ❌ 错误:直接跳转登录页
if (Date.now() >= tokenExpiresAt) {
login(); // 用户正在编辑文档,突然跳转登录页,文档丢失!
}
正确做法:
scss
// ✅ 正确:静默刷新令牌
if (Date.now() >= tokenExpiresAt - 5 * 60 * 1000) { // 提前 5 分钟刷新
await refreshAccessToken(); // 静默刷新
// 继续用户操作
}
原理:
- JWT 过期前,提前 5 分钟用刷新令牌换新令牌
- 用户无感知,操作不中断
- 刷新令牌过期后,才跳转登录页
坑 2:回调 URL 白名单校验缺失
问题描述 : 攻击者构造恶意回调地址:http://evil.com?code=xxx,用户点击后,攻击者可以获取授权码!
错误做法:
javascript
// ❌ 错误:不校验 redirect_uri
app.get('/authorize', (req, res) => {
const { clientId, redirectUri, state } = req.query;
// 直接生成授权码,不校验 redirect_uri 是否在白名单
const code = Math.random().toString(36).substring(2, 15);
res.redirect(`${redirectUri}?code=${code}&state=${state}`);
});
正确做法:
javascript
// ✅ 正确:校验 redirect_uri 白名单
const ALLOWED_REDIRECT_URIS = [
'http://system-a.local:3001/callback',
'http://system-b.local:3002/callback'
];
app.get('/authorize', (req, res) => {
const { clientId, redirectUri, state } = req.query;
if (!ALLOWED_REDIRECT_URIS.includes(redirectUri)) {
return res.status(400).json({ error: '回调地址不在白名单内' });
}
// 生成授权码
const code = Math.random().toString(36).substring(2, 15);
res.redirect(`${redirectUri}?code=${code}&state=${state}`);
});
坑 3:跨域 Cookie 无法携带凭证
问题描述: SSO 服务器和业务系统域名不同,Cookie 无法携带,导致无法维持登录状态!
解决方案:
方案 1:JSONP + PostMessage(不推荐)
arduino
// ❌ 不推荐:JSONP 有安全风险
方案 2:CORS + withCredentials(推荐)
php
// SSO 服务器
app.use(cors({
origin: ['http://system-a.local:3001', 'http://system-b.local:3002'],
credentials: true
}));
// 业务系统
axios.defaults.withCredentials = true;
方案 3:Token 传递(最推荐)
arduino
// 令牌通过 URL 参数或 localStorage 传递,不依赖 Cookie
坑 4:state 参数未校验导致 CSRF 攻击
问题描述 : 攻击者诱导用户访问:http://sso.local:3000/authorize?client_id=xxx&redirect_uri=http://attacker.com,用户登录后,攻击者可以获取授权码!
错误做法:
ini
// ❌ 错误:不校验 state 参数
const code = authorizationCodes.get(code);
if (!code) {
return res.status(400).json({ error: '授权码无效' });
}
// 直接重定向,不校验 state
res.redirect(`${code.redirectUri}?code=${code}&state=${state}`);
正确做法:
javascript
// ✅ 正确:校验 state 参数
const code = authorizationCodes.get(code);
if (!code) {
return res.status(400).json({ error: '授权码无效' });
}
// 校验 state
const storedState = sessionStorage.getItem('sso_state');
if (code.state !== storedState) {
return res.status(400).json({ error: 'state 参数校验失败' });
}
// 删除已使用的 state
sessionStorage.removeItem('sso_state');
// 重定向
res.redirect(`${code.redirectUri}?code=${code}&state=${code.state}`);
坑 5:令牌存储在 localStorage 的安全风险
问题描述: localStorage 可以被 XSS 攻击读取,导致令牌泄露!
解决方案:
方案 1:HttpOnly Cookie(最安全)
ini
// 服务端设置 HttpOnly Cookie
res.setHeader('Set-Cookie', `access_token=${accessToken}; HttpOnly; Secure; SameSite=Strict`);
方案 2:内存存储 + 刷新页面丢失(折中方案)
csharp
// 令牌存储在内存变量中,刷新页面后需要重新登录
let accessToken: string | null = null;
方案 3:加密存储(次安全)
javascript
// 使用加密库(如 crypto-js)加密存储
import CryptoJS from 'crypto-js';
const encrypted = CryptoJS.AES.encrypt(accessToken, 'secret-key').toString();
localStorage.setItem('access_token', encrypted);
坑 6:刷新令牌被重复使用
问题描述: 攻击者截获刷新令牌,可以无限期地刷新访问令牌,导致长期控制用户账户!
错误做法:
ini
// ❌ 错误:刷新令牌可以重复使用
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
const decoded = jwt.verify(refreshToken, JWT_SECRET);
const newAccessToken = generateAccessToken(decoded.userId);
res.json({ accessToken: newAccessToken });
});
正确做法:
php
// ✅ 正确:刷新令牌一次性使用
const refreshTokens = new Map();
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
// 检查刷新令牌是否已使用
if (refreshTokens.has(refreshToken)) {
return res.status(401).json({ error: '刷新令牌已被使用' });
}
try {
const decoded = jwt.verify(refreshToken, JWT_SECRET);
const newAccessToken = generateAccessToken(decoded.userId);
// 标记刷新令牌已使用
refreshTokens.set(refreshToken, true);
// 生成新的刷新令牌
const newRefreshToken = generateRefreshToken(decoded.userId);
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: JWT_EXPIRES_IN
});
} catch (error) {
res.status(401).json({ error: '刷新令牌无效' });
}
});
坑 7:SSO 会话与系统会话不同步
问题描述: 用户在系统 A 退出登录,但在系统 B 仍然显示登录状态!
错误做法:
javascript
// ❌ 错误:只清除本地会话
app.post('/logout', (req, res) => {
localStorage.removeItem('access_token');
res.json({ success: true });
});
正确做法:
javascript
// ✅ 正确:同步清除 SSO 会话和本地会话
app.post('/logout', async (req, res) => {
// 清除本地令牌
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// 通知 SSO 服务器清除会话
await axios.post(`${SSO_SERVER}/logout`, {
accessToken
});
res.json({ success: true });
});
坑 8:令牌刷新失败后的用户体验差
问题描述: 令牌刷新失败后,直接跳转登录页,用户正在编辑的内容丢失!
错误做法:
javascript
// ❌ 错误:刷新失败直接跳转登录页
async function refreshAccessToken() {
try {
const response = await axios.post(`${SSO_SERVER}/refresh`, { refreshToken });
setTokens(response.data.accessToken);
} catch (error) {
login(); // 用户正在编辑文档,突然跳转登录页!
}
}
正确做法:
scss
// ✅ 正确:刷新失败后提示用户并保存内容
async function refreshAccessToken() {
try {
const response = await axios.post(`${SSO_SERVER}/refresh`, { refreshToken });
setTokens(response.data.accessToken);
} catch (error) {
// 保存用户当前操作
saveDraft();
// 提示用户重新登录
ElMessage.warning('登录已过期,请重新登录');
// 延迟跳转,让用户有机会保存内容
setTimeout(() => {
login();
}, 3000);
}
}
坑 9:并发请求导致多次刷新令牌
问题描述: 多个请求同时发现令牌过期,导致多次刷新令牌,SSO 服务器压力大!
错误做法:
javascript
// ❌ 错误:每个请求都独立刷新令牌
async function refreshTokenIfExpired() {
if (Date.now() >= tokenExpiresAt) {
await refreshAccessToken(); // 多个请求同时执行
}
}
正确做法:
ini
// ✅ 正确:使用锁机制避免并发刷新
let isRefreshing = false;
let refreshPromise: Promise<void> | null = null;
async function refreshTokenIfExpired() {
if (Date.now() >= tokenExpiresAt) {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshAccessToken().finally(() => {
isRefreshing = false;
});
}
await refreshPromise;
}
}
坑 10:SSO 服务器单点故障
问题描述: SSO 服务器宕机,所有业务系统都无法登录!
解决方案:
方案 1:SSO 服务器集群 + 负载均衡
用户 → 负载均衡 → SSO 服务器集群(多台)
方案 2:本地缓存 + 离线登录
ini
// 本地缓存最近登录的用户信息
const cachedUser = localStorage.getItem('cached_user');
if (cachedUser && Date.now() - cachedUser.timestamp < 24 * 60 * 60 * 1000) {
// 允许离线登录
user = JSON.parse(cachedUser.data);
}
方案 3:降级方案
scss
// SSO 服务器不可用时,使用本地认证
try {
await ssoLogin();
} catch (error) {
// 降级到本地认证
await localLogin(username, password);
}
四、总结
SSO 不是银弹,但用好了是神器!记住这几点:
- 安全第一:校验 redirect_uri、state 参数,使用 HttpOnly Cookie
- 用户体验:静默刷新令牌,避免用户操作中断
- 防御性编程:所有接口都要校验参数,不要信任客户端传来的数据
- 监控告警:监控令牌刷新失败、授权码重复使用等异常情况
最后送大家一句话:
"代码可以重写,但用户流失无法挽回。"
SSO 是用户体验的放大器,用好了用户赞你,用不好用户骂你!所以,别再被 SSO 骗了,把它玩明白!