多端单点登录(SSO)实战
前端视角下的单点登录(SSO)从原理到实战
引言:一次认证,全网通行的前端实现之道
在多系统架构中,用户频繁登录不同子系统的体验割裂问题,催生了单点登录(SSO)技术 —— 通过统一认证中心实现 "一次登录,多系统访问"。对前端工程师而言,SSO 不仅是跳转逻辑的实现,更是安全与体验的平衡艺术。本文将从前端视角拆解 SSO 的核心原理、实战方案与安全边界,助你掌握从认证流程到跨域同步的完整实现。
一、SSO 的核心价值与前端核心职责
为什么需要 SSO?
数据显示,普通员工每年因重复登录浪费超 44 小时,而 SSO 可减少 63% 的 IT 支持工单,同时通过集中权限管理降低 87% 的密码泄露风险。其核心价值在于:
- 体验优化:消除重复登录,提升跨系统操作流畅度
- 安全强化:集中管控认证逻辑,降低密码管理风险
- 效率提升:简化多系统权限配置,减少开发冗余
前端在 SSO 中的角色边界
前端不负责核心认证逻辑,而是承担 "流程衔接者" 与 "安全守护者" 的角色,核心职责包括:
- 认证引导:检测本地令牌状态,触发跳转至认证中心
- 参数传递:安全传递授权码(
code
)、PKCE
等临时凭证 - 令牌存储:根据安全策略管理令牌(
access_token
/refresh_token
) - 跨域同步:通过
iframe
/postMessage
等实现多系统状态同步 - 安全隔离:严格规避敏感操作(如令牌兑换),防止密钥泄露
二、SSO 核心流程:前端视角的四阶段拆解
阶段 1:触发认证 —— 从应用到认证中心
当用户访问受保护资源(如 App A 的首页)时,前端需先检查本地令牌有效性:
核心动作:
- 初始化时检测
sessionStorage
/localStorage
中是否存在有效access_token
- 若令牌缺失 / 过期,生成
PKCE
参数(防授权码劫持),重定向至认证中心
关键代码:
// 生成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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
阶段 2:捕获授权码 —— 认证中心回调处理
用户在认证中心完成登录后,会被重定向回应用的回调页面(如 /sso-callback
),URL 中携带授权码(code
)。
核心动作:
- 从 URL 参数中提取
code
(授权码) - 调用后端接口,传递
code
与之前存储的code_verifier
,由后端兑换令牌
关键代码:
// 回调页面处理
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);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
安全红线:前端绝不可直接向认证中心发送兑换请求(需携带客户端密钥,会导致泄露),必须通过自身后端中转。
阶段 3:令牌使用与刷新
获取令牌后,前端需在请求中携带令牌以访问受保护接口,并处理令牌过期问题。核心动作:
- 封装请求拦截器,自动添加
Authorization
头 - 监听 401 错误,使用
refresh_token
刷新令牌 - 刷新失败时,重定向至认证中心重新认证
关键代码:
// 请求拦截器(以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);
}
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
阶段 4:退出登录 —— 全系统状态清除
退出登录需同步清除所有子系统的令牌,避免单点退出后其他系统仍保持登录状态。
核心动作:
- 清除本地令牌(
access_token
/sessionStorage
等) - 调用认证中心注销接口, invalidate 全局会话
- 通知其他子系统同步退出(跨域场景)
关键代码:
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();
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
三、跨域场景:SSO 状态同步方案对比
多系统通常分布在不同域名下,如何同步登录状态是 SSO 的核心挑战。以下是主流跨域方案的对比与实现:
方案 | 适用场景 | 实现原理 | 安全级别 | 局限性 |
---|---|---|---|---|
同顶级域名 Cookie | 子系统域名统一(如 a.example.com , b.example.com ) | 通过 domain.example.com 共享 Cookie | 中(须配合 HttpOnly、SameSite) | 无法跨主域(如 example.com 与 example.net ) |
iframe & postMessage | 跨主域场景 | 认证中心部署 sync.html,子系统通过 iframe 接收令牌 | 高(需校验 origin) | 部分浏览器对第三方 Cookie 限制严格 |
URL 参数传递 | 临时授权场景 | 加密令牌通过 URL 啊承诺书传递(如 sso_token=xxx ) | 低(易泄露) | 仅适合短期一次性授权 |
同顶级域名方案(最简单)
当所有子系统共享顶级域名(如 *.example.com
),可通过共享 Cookie 同步状态:
认证中心登录后,设置
Domain=.example.com
的 Cookie 存储refresh_token
子系统通过读取该 Cookie,调用后端接口兑换
access_token
// 认证中心设置Cookie(后端操作)
Set-Cookie: refresh_token=xxx;
Domain=.example.com; // 共享给所有子域名
Path=/;
HttpOnly; // 防止前端JS读取,降低XSS风险
Secure; // 仅HTTPS传输
SameSite=Strict; // 防止CSRF攻击
2
3
4
5
6
7
跨主域方案(iframe + postMessage)
当子系统分布在不同主域(如 app-a.com
与 app-b.com
),需通过 iframe
跨域通信:
认证中心部署 sync.html
:
<!-- 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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
子系统监听令牌同步:
// 子系统初始化时触发同步
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();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
四、Vue 框架实战:SSO 集成最佳实践
以 Vue 3 为例,结合路由守卫、状态管理与请求拦截器,实现端到端 SSO 集成:
状态管理(Pinia)
// 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;
}
}
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
路由守卫(控制访问权限)
// 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;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
SSO 认证组件(触发跳转与回调)
<!-- 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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
五、面试高频考点:SSO 核心问题应答策略
如何防止授权码被劫持?
应答要点:
- 采用授权码 + PKCE 模式:前端生成
code_verifier
和code_challenge
,认证中心用code_challenge
校验code_verifie
r,防止恶意第三方拦截授权码 - 限制
redirect_uri
为预注册地址,避免跳转至恶意网站
前端为什么不能直接处理令牌兑换?
应答要点:
- 令牌兑换需携带
client_secret
(客户端密钥),前端存储密钥会导致泄露(通过 JS 即可获取) - 正确流程:前端将
code
传递给自身后端,由后端携带client_secret
向认证中心兑换令牌,再返回access_token
给前端
如何实现多系统同时退出登录?
应答要点:
- 分两步:清除本地状态+同步全局会话
- 本地:清除
access_token
、refresh_token
及用户信息 - 全局:调用认证中心注销接口( invalidate 全局会话 ),并通过 iframe/postMessage 通知其他子系统同步清除状态
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)正逐步兴起,但前端的角色始终是 "流程衔接者"—— 安全永远是不可逾越的底线。
单点登录(SSO)在多端应用中的设计与实现
在现代 Web 应用架构中,用户往往需要同时使用多个关联的业务系统(如电商平台的商品页、购物车、支付中心)。单点登录(SSO)技术通过 “一次登录,多系统通行” 的特性,彻底解决了用户在多系统间重复登录的痛点。本文基于简单的实际项目案例,详细介绍 SSO 在多端应用中的设计思路与实现方案,包含整页重定向与弹窗通信两种核心模式,并结合 client1、client2、client3 的具体代码实现进行说明。
一、总体架构设计
这个项目我采用 “三端两模式” 架构(多终端协同 + 认证 - 业务分离),通过统一的登录中心串联多个业务应用,实现跨应用的身份共享。
核心角色划分
业务应用(Client) :client1、client3(基于 Vite + Vue Router 构建的前端应用),提供具体业务功能,需依赖登录状态访问受保护资源。两者代码结构基本相同,都通过路由守卫和请求拦截器处理登录相关逻辑。
登录中心(Auth Server) :client2(独立前端应用),负责统一身份认证、token 发放与登录状态管理,是整个 SSO 体系的信任源。
后端服务(Backend) :提供 API 接口的后端服务(运行在
localhost:5000
),通过校验 token 合法性控制资源访问,当检测到未授权请求(401 错误)时触发登录流程。
两种交互模式
SSO 的核心是解决 “登录状态跨应用传递” 的问题,本项目实现了两种典型交互模式:
整页重定向模式:业务应用通过页面跳转将用户引导至登录中心,登录成功后携带 token 重定向回原应用,适用于简单场景或弹窗被拦截时的降级方案。
弹窗 + postMessage 模式:业务应用通过
window.open
弹出登录中心,登录成功后利用postMessage
API 将token
安全传递回主应用,保留用户操作上下文,是本项目的推荐方案。
二、核心登录流程(弹窗 + postMessage 模式)
弹窗模式通过 “主应用 - 弹窗 - 主应用” 的通信闭环实现登录状态传递,既保证安全性又优化用户体验,核心流程分为三个阶段:
触发登录:业务应用检测未登录状态
当用户访问业务应用的受保护资源时,系统通过两种方式判断未登录状态:
请求拦截:API 请求返回 401 错误(后端检测
token
无效或缺失)。路由守卫:路由跳转时,前端路由守卫检测到本地无有效
token
。
此时,业务应用通过 window.open
打开登录中心,并携带自身 origin
(如 http://localhost:5173
)作为 resource
参数,用于登录成功后的回调定位。
以 client1 的响应拦截器代码为例:
// client1的request.js响应拦截器
request.interceptors.response.use((res) => {
if (res.data.status === 401) {
// 打开登录中心弹窗,携带当前应用origin
window.open(`http://localhost:5174/login?resource=${window.location.origin}`);
}
return res;
})
2
3
4
5
6
7
8
监听回调:业务应用准备接收 token
业务应用在初始化时注册 message
事件监听,专门接收登录中心通过 postMessage
传递的 token
信息。为防止恶意网站伪造消息,需严格校验发送方的 origin
。
client1 在 main.js 中注册监听:
// client1的main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
window.addEventListener("message",(event) => {
const token = event.data.token;
// 存储接收到的token
localStorage.setItem("token",token);
// 刷新页面使token生效
window.location.reload()
})
app.use(router).mount('#app')
2
3
4
5
6
7
8
9
10
11
12
13
14
回传 token:登录中心完成认证并响应
登录中心加载时解析 resource
参数(业务应用的 origin
),用户完成登录后,通过 postMessage
将 token
定向回传至业务应用。为保证安全性,需处理 opener
丢失(如弹窗被刷新)的边缘情况。
client1 中处理登录成功回传 token 的相关代码:
// client1中登录相关逻辑
import request from "../server/request";
import { useRoute } from "vue-router";
import { watch, ref } from "vue";
const route = useRoute();
const resource = ref("");
const token = localStorage.getItem("token");
function postMessage(token, resource){
window.opener.postMessage({
token:token
},resource.value)
}
watch(
() => route.query.resource,
(val) => {
resource.value = val ? decodeURIComponent(val) : "";
if (token) {
postMessage(token,resource.value)
}
},
{ immediate: true }
);
function login() {
request.get("/login").then((res) => {
const apitoken = res.data.data;
localStorage.setItem("token", apitoken);
// 向打开登录页的业务应用回传token
window.opener.postMessage({token:apitoken},resource.value)
// 方案之一:整页重定向
window.location.href = `${resource.value}?token=${token}`;
// 关闭登录弹窗
window.close()
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
三、路由与令牌接入方案
业务应用需通过路由守卫和请求拦截器实现 token
的自动管理,确保登录状态在路由跳转和 API 请求中无缝生效。
路由守卫:控制页面访问权限
路由守卫负责在页面跳转时校验登录状态,对未登录用户拦截并触发登录流程,同时处理整页重定向模式下的?token=参数。
client1 的路由守卫代码:
// client1的router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '../pages/HomePage.vue'
import AboutPage from '../pages/AboutPage.vue'
const routes = [
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
// 处理整页重定向带回的token
const token = to.query.token;
if (token) {
localStorage.setItem("token", token);
}
next();
})
export default router
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
请求拦截器:自动携带与刷新 token
通过 Axios 拦截器实现 token 的自动携带,以及 401 错误的统一处理,确保 API 请求的安全性。
client1 的请求拦截器代码:
// client1的request.js请求拦截器
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
config.headers = config.headers || {}
if (token) config.headers.token = token
return config
})
2
3
4
5
6
7
四、各端代码说明
client1 代码特点
client1 作为主要的业务应用,实现了完整的 SSO 逻辑:
请求拦截器自动携带 token
响应拦截器在 401 时打开登录中心弹窗
路由守卫处理整页重定向带来的 token
注册 message 事件监听接收登录中心回传的 token
实现了通过 postMessage 向业务应用回传 token 的功能
client2(登录中心)代码特点
client2 作为登录中心,提供登录页面和登录功能:主要是为了获取 token
<template>
<div>
<div>账号名:<input /></div>
<div>密码:<input /></div>
<div>
<button
@click="() => login()"
>
登录
</button>
</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
其请求拦截器与 client1 类似,但登录页一般不做 401 重定向,避免循环跳转:
// client2的request.js
request.interceptors.response.use((res) => {
if (res.data.status === 401) {
window.open(`http://localhost:5174/login?resource=${window.location.origin}`)
}
return res;
})
2
3
4
5
6
7
client3 代码特点
client3 与 client1 代码结构基本相同,作为另一个业务应用,验证 SSO 的跨应用登录效果:
<script setup>
import request from '../server/request'
// 触发一次请求,用于检测登录状态
request.get('/api1').catch(() => {})
</script>
<template>
<main>
<h1>Client3 首页(SSO 演示)</h1>
<p>如果已在其他项目登录,这里将直接显示;否则会被重定向到登录页。</p>
<router-link to="/">Home</router-link>
<router-view></router-view>
</main>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
五、关键细节与避坑指南
单点登录的实现涉及多端交互和安全校验,以下细节直接影响方案的稳定性和安全性:
- postMessage 的 targetOrigin 校验
必须使用业务应用的 origin(如 http://localhost:5173
)作为 targetOrigin,禁止使用 *
(允许任意域名接收),否则可能导致 token 被恶意网站窃取。
- window.opener 的可用性处理
仅当登录中心通过window.open打开时,opener才指向业务应用窗口;若用户刷新登录弹窗,opener会变为null,需降级为整页重定向,如 client1 的 login 函数中同时实现了 postMessage 和整页重定向。
- resource 参数的编解码
业务应用传递origin时需用 encodeURIComponent 编码(处理特殊字符),登录中心接收后用 decodeURIComponent 解码,避免 URL 解析错误,如 client1 中 watch 监听 resource 参数时进行了解码处理。
- 路由守卫必须调用
next()
无论是否允许路由跳转,beforeEach 守卫都必须调用 next()
,否则会导致页面卡死(不渲染),client1 的路由守卫中正确实现了这一点。
- 弹窗被浏览器拦截的兼容
若浏览器因 “非用户主动触发” 拦截弹窗(如自动执行的window.open),需捕获错误并降级为整页重定向。
六、两种模式的选用场景
模式 | 优势 | 劣势 | 适用场景 |
---|---|---|---|
弹窗 + postMessage | 不刷新页面,保留用户操作上下文;体验流畅 | 需处理弹窗拦截、opener丢失等边缘情况 | 主流场景:用户主动触发的登录(如点击 “我的” 按钮) |
整页重定向 | 实现简单,无浏览器兼容性问题 | 刷新页面,丢失当前操作状态 | 降级方案:弹窗被拦截时;简单应用或旧浏览器 |
推荐实现 “智能兼容” 逻辑:登录中心优先尝试 postMessage 回传,检测到 opener 无效时自动切换为整页重定向,确保所有场景下的可用性,client1 的登录函数已实现此逻辑。
七、总结
单点登录(SSO)的核心是通过 “统一认证 + 跨域通信” 实现多应用的身份共享。本项目通过 “弹窗 + postMessage” 模式优化用户体验,同时以整页重定向作为兜底,兼顾了安全性与兼容性。
关键成功要素包括:
严格的origin校验,防止 token 跨域泄露;
完善的边缘情况处理(如opener丢失、弹窗拦截);
统一的 token 管理机制(路由守卫 + 请求拦截器)。
通过这套示例,业务应用(client1、client3)无需关心登录逻辑,只需专注自身业务;登录中心(client2)统一处理身份认证,实现 “一次登录,全平台通行” 的目标,为用户提供无缝的跨应用体验。实际应用中,可根据具体业务场景对这套代码进行扩展和优化,如增加 token 过期刷新机制、完善错误处理等。