引言:一次认证,全网通行的前端实现之道
在多系统架构中,用户频繁登录不同子系统的体验割裂问题,催生了单点登录(SSO)技术 ------ 通过统一认证中心实现 "一次登录,多系统访问"。对前端工程师而言,SSO 不仅是跳转逻辑的实现,更是安全与体验的平衡艺术。本文将从前端视角拆解 SSO 的核心原理、实战方案与安全边界,助你掌握从认证流程到跨域同步的完整实现。
一、SSO 的核心价值与前端核心职责
1.1 为什么需要 SSO?
数据显示,普通员工每年因重复登录浪费超 44 小时,而 SSO 可减少 63% 的 IT 支持工单,同时通过集中权限管理降低 87% 的密码泄露风险。其核心价值在于:
- 体验优化:消除重复登录,提升跨系统操作流畅度
- 安全强化:集中管控认证逻辑,降低密码管理风险
- 效率提升:简化多系统权限配置,减少开发冗余
1.2 前端在 SSO 中的角色边界
前端不负责核心认证逻辑,而是承担 "流程衔接者" 与 "安全守护者" 的角色,核心职责包括:
- 认证引导:检测本地令牌状态,触发跳转至认证中心
- 参数传递:安全传递授权码(code)、PKCE 等临时凭证
- 令牌存储:根据安全策略管理令牌(access_token/refresh_token)
- 跨域同步:通过 iframe/postMessage 等实现多系统状态同步
- 安全隔离:严格规避敏感操作(如令牌兑换),防止密钥泄露
二、SSO 核心流程:前端视角的四阶段拆解
阶段 1:触发认证 ------ 从应用到认证中心
当用户访问受保护资源(如 App A 的首页)时,前端需先检查本地令牌有效性:
核心动作:
- 初始化时检测
sessionStorage/localStorage中是否存在有效access_token - 若令牌缺失 / 过期,生成 PKCE 参数(防授权码劫持),重定向至认证中心
关键代码:
javascript
javascript
// 生成PKCE参数(安全增强)
const generatePKCE = () => {
// 生成随机code_verifier(43-128字符)
const codeVerifier = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map(b => String.fromCharCode(b % 62 + (b % 62 < 10 ? 48 : b % 62 < 36 ? 55 : 61)))
.join('');
// 计算code_challenge(SHA-256哈希+Base64URL编码)
const codeChallenge = async (verifier) => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
return { codeVerifier, codeChallenge: await codeChallenge(codeVerifier) };
};
// 触发认证跳转
const initSSO = async () => {
const { codeVerifier, codeChallenge } = await generatePKCE();
// 存储verifier(后续兑换令牌需使用)
sessionStorage.setItem('code_verifier', codeVerifier);
// 构建认证中心URL
const authParams = new URLSearchParams({
response_type: 'code',
client_id: 'APP_A_CLIENT_ID', // 应用唯一标识
redirect_uri: 'https://app-a.com/sso-callback', // 回调地址(预注册)
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `https://sso-server.com/auth?${authParams.toString()}`;
};
阶段 2:捕获授权码 ------ 认证中心回调处理
用户在认证中心完成登录后,会被重定向回应用的回调页面(如/sso-callback),URL 中携带授权码(code)。
核心动作:
- 从 URL 参数中提取
code(授权码) - 调用后端接口,传递
code与之前存储的code_verifier,由后端兑换令牌
关键代码:
javascript
javascript
// 回调页面处理
const handleSSOCallback = async () => {
const urlParams = new URLSearchParams(window.location.search);
const authCode = urlParams.get('code');
const error = urlParams.get('error');
if (error) {
// 处理认证失败(如用户拒绝授权)
alert(`认证失败:${error}`);
return;
}
// 从存储中获取code_verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!authCode || !codeVerifier) {
alert('认证参数缺失');
return;
}
// 调用后端接口兑换令牌(前端不直接请求认证中心)
try {
const res = await fetch('/api/exchange-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: authCode, code_verifier: codeVerifier })
});
const { access_token, refresh_token, expires_in } = await res.json();
// 存储令牌(根据安全策略选择存储方式)
sessionStorage.setItem('access_token', access_token);
// 跳转至原目标页面
window.location.href = sessionStorage.getItem('target_url') || '/';
} catch (err) {
console.error('令牌兑换失败', err);
}
};
安全红线:前端绝不可直接向认证中心发送兑换请求(需携带客户端密钥,会导致泄露),必须通过自身后端中转。
阶段 3:令牌使用与刷新
获取令牌后,前端需在请求中携带令牌以访问受保护接口,并处理令牌过期问题。
核心动作:
- 封装请求拦截器,自动添加
Authorization头 - 监听 401 错误,使用
refresh_token刷新令牌 - 刷新失败时,重定向至认证中心重新认证
关键代码:
javascript
ini
// 请求拦截器(以Axios为例)
axios.interceptors.request.use(config => {
const token = sessionStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器处理令牌过期
axios.interceptors.response.use(
res => res,
async err => {
const originalRequest = err.config;
// 若401且未重试过
if (err.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 调用后端刷新令牌接口(refresh_token通常存在HttpOnly Cookie中)
const { access_token } = await axios.post('/api/refresh-token');
sessionStorage.setItem('access_token', access_token);
// 重试原请求
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return axios(originalRequest);
} catch (refreshErr) {
// 刷新失败,重新触发SSO
sessionStorage.removeItem('access_token');
window.location.href = '/sso-redirect';
return Promise.reject(refreshErr);
}
}
return Promise.reject(err);
}
);
阶段 4:退出登录 ------ 全系统状态清除
退出登录需同步清除所有子系统的令牌,避免单点退出后其他系统仍保持登录状态。
核心动作:
- 清除本地令牌(
access_token/sessionStorage等) - 调用认证中心注销接口, invalidate 全局会话
- 通知其他子系统同步退出(跨域场景)
关键代码:
javascript
ini
const logout = async () => {
// 1. 清除本地存储
sessionStorage.removeItem('access_token');
// 2. 清除同域Cookie(若有)
document.cookie = 'refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
// 3. 调用认证中心注销接口( invalidate 全局会话 )
const logoutUrl = new URL('https://sso-server.com/logout');
logoutUrl.searchParams.set('client_id', 'APP_A_CLIENT_ID');
logoutUrl.searchParams.set('redirect_uri', 'https://app-a.com/logout-callback');
// 4. 跨域通知其他系统(通过iframe触发)
const systems = [
'https://app-b.com/sync-logout',
'https://app-c.com/sync-logout'
];
systems.forEach(url => {
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.display = 'none';
document.body.appendChild(iframe);
// 移除iframe避免内存泄漏
setTimeout(() => iframe.remove(), 1000);
});
// 5. 跳转至认证中心完成注销
window.location.href = logoutUrl.toString();
};
三、跨域场景:SSO 状态同步方案对比
多系统通常分布在不同域名下,如何同步登录状态是 SSO 的核心挑战。以下是主流跨域方案的对比与实现:

3.1 同顶级域名方案(最简单)
当所有子系统共享顶级域名(如*.example.com),可通过共享 Cookie 同步状态:
-
认证中心登录后,设置
Domain=.example.com的 Cookie 存储refresh_token -
子系统通过读取该 Cookie,调用后端接口兑换
access_token
http
ini
// 认证中心设置Cookie(后端操作)
Set-Cookie: refresh_token=xxx;
Domain=.example.com; // 共享给所有子域名
Path=/;
HttpOnly; // 防止前端JS读取,降低XSS风险
Secure; // 仅HTTPS传输
SameSite=Strict; // 防止CSRF攻击
3.2 跨主域方案(iframe + postMessage)
当子系统分布在不同主域(如app-a.com与app-b.com),需通过 iframe 跨域通信:
认证中心部署sync.html:
xml
<!-- https://sso-server.com/sync.html -->
<script>
// 监听子系统的令牌请求
window.addEventListener('message', (event) => {
// 校验请求来源(仅允许可信子系统)
const trustedOrigins = [
'https://app-a.com',
'https://app-b.com'
];
if (!trustedOrigins.includes(event.origin)) return;
// 从Cookie中获取当前登录状态(需后端配合写入)
const token = getCookie('sso_token'); // 伪代码:读取认证中心的令牌Cookie
if (token) {
// 向子系统发送令牌
event.source.postMessage({ token }, event.origin);
}
});
function getCookie(name) {
// 简化的Cookie读取逻辑
return document.cookie.split('; ')
.find(row => row.startsWith(`${name}=`))?.split('=')[1];
}
</script>
子系统监听令牌同步:
javascript
javascript
// 子系统初始化时触发同步
const syncSSOState = () => {
// 创建隐藏iframe加载认证中心的sync.html
const iframe = document.createElement('iframe');
iframe.src = 'https://sso-server.com/sync.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);
// 监听iframe发送的令牌
window.addEventListener('message', (event) => {
// 严格校验来源,防止恶意网站伪造消息
if (event.origin !== 'https://sso-server.com') return;
// 存储令牌并更新UI
if (event.data.token) {
sessionStorage.setItem('access_token', event.data.token);
renderUserInfo(); // 触发页面刷新
}
});
};
// 页面加载时同步状态
syncSSOState();
四、Vue 框架实战:SSO 集成最佳实践
以 Vue 3 为例,结合路由守卫、状态管理与请求拦截器,实现端到端 SSO 集成:
4.1 状态管理(Pinia)
javascript
javascript
// stores/auth.js
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
token: sessionStorage.getItem('access_token') || null,
userInfo: null
}),
getters: {
isAuthenticated: (state) => !!state.token
},
actions: {
// 存储令牌
setToken(token) {
this.token = token;
sessionStorage.setItem('access_token', token);
},
// 清除令牌
clearToken() {
this.token = null;
sessionStorage.removeItem('access_token');
},
// 初始化时检查令牌有效性
async checkToken() {
if (!this.token) return false;
try {
// 调用后端接口校验令牌
const res = await axios.get('/api/verify-token');
this.userInfo = res.data.user;
return true;
} catch (err) {
this.clearToken();
return false;
}
}
}
});
4.2 路由守卫(控制访问权限)
javascript
javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { requiresAuth: true } // 标记需要认证的路由
},
{
path: '/sso-callback',
name: 'SSOCallback',
component: () => import('@/views/SSOCallback.vue') // 处理认证回调
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 全局路由守卫
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const requiresAuth = to.meta.requiresAuth;
if (requiresAuth) {
// 检查令牌有效性
const isTokenValid = await authStore.checkToken();
if (isTokenValid) {
next(); // 令牌有效,允许访问
} else {
// 令牌无效,记录目标地址后跳转至SSO
sessionStorage.setItem('target_url', to.fullPath);
next('/sso-redirect'); // 触发SSO认证
}
} else {
next(); // 无需认证的路由直接放行
}
});
export default router;
4.3 SSO 认证组件(触发跳转与回调)
vue
xml
<!-- views/SSORedirect.vue -->
<script setup>
import { onMounted } from 'vue';
import { generatePKCE } from '@/utils/sso';
onMounted(async () => {
// 生成PKCE参数并跳转至认证中心
const { codeVerifier, codeChallenge } = await generatePKCE();
sessionStorage.setItem('code_verifier', codeVerifier);
const authParams = new URLSearchParams({
response_type: 'code',
client_id: 'VUE_APP_CLIENT_ID',
redirect_uri: `${window.location.origin}/sso-callback`,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `https://sso-server.com/auth?${authParams.toString()}`;
});
</script>
<template>
<div>正在跳转至统一认证中心...</div>
</template>
五、面试高频考点:SSO 核心问题应答策略
1. 如何防止授权码被劫持?
应答要点:
- 采用授权码 + PKCE 模式 :前端生成
code_verifier和code_challenge,认证中心用code_challenge校验code_verifier,防止恶意第三方拦截授权码 - 限制
redirect_uri为预注册地址,避免跳转至恶意网站
2. 前端为什么不能直接处理令牌兑换?
应答要点:
- 令牌兑换需携带
client_secret(客户端密钥),前端存储密钥会导致泄露(通过 JS 即可获取) - 正确流程:前端将
code传递给自身后端,由后端携带client_secret向认证中心兑换令牌,再返回access_token给前端
3. 如何实现多系统同时退出登录?
应答要点:
- 分两步:清除本地状态 +同步全局会话
- 本地:清除
access_token、refresh_token及用户信息 - 全局:调用认证中心注销接口( invalidate 全局会话 ),并通过 iframe/postMessage 通知其他子系统同步清除状态
4. Token 存储方案如何选择?
应答要点:
access_token:存储在sessionStorage(会话级,关闭标签页失效,降低 XSS 泄露风险)refresh_token:存储在HttpOnly Cookie(禁止前端 JS 读取,防 XSS;配合SameSite=Strict防 CSRF)- 禁止使用
localStorage存储敏感令牌(持久化存储,XSS 攻击易导致长期泄露)
结语:前端在 SSO 中的安全底线
单点登录的前端实现,本质是在 "用户体验" 与 "系统安全" 之间寻找平衡。核心原则始终不变:
-
不碰敏感信息:客户端密钥、令牌兑换逻辑必须由后端处理
-
严格校验来源 :跨域通信(如 postMessage)必须验证
origin -
遵循最小权限:令牌仅包含必要权限,且设置合理有效期
随着浏览器对第三方 Cookie 的限制加强(如 Chrome 的 Privacy Sandbox),无 Cookie 的 SSO 方案(如 OAuth 2.0 Device Flow)正逐步兴起,但前端的角色始终是 "流程衔接者"------ 安全永远是不可逾越的底线。