网站采用API-JWT + VUE前端架构设计,用户登录后将 Bearer Token 存储到localStorage或Cookie里,同时设置有效期。在单用户登录时没有问题,但是多个用户在同一浏览器里同时登录时会遇到这样一种场景:
先在浏览器里打开一个标签P1登录了A用户,然后新开一个标签P2登录了B用户,此时P1标签里实际读取的用户token已经变成了B用户,如果此时操作提交数据,你以为你还是操作的A用户,实际上已经是B用户的资料了,这就发生了俗称“串号”的现象。 专业上会说是 Session Bleed(会话渗漏):这是专业、形象的说法。就像液体渗漏一样,一个人的数据流到了另一个人的页面上。
如何在现有网站架构上最低成本解决这个串号问题呢?
飘易提供一种思路,前端 + 后端低成本解决串号:
1、用户登录成功后,利用 sessionStorage 特性:sessionStorage 的生命周期仅限于标签页或窗口存活期间,一旦浏览器进程彻底关闭,数据就会被清空。
登录逻辑里:我们把当前登录成功的用户ID写入 sessionStorage :
sessionStorage.setItem('locked_user_id', user.id);// 防串号-锁死用户ID
setStore({ name: 'userinfo', content: user });// 同时存入localStorage还要考虑一种情况,我们的token是会被持久化存储一段时间的,在这段时间内,如果浏览器被关闭后,重新打开系统页面,此时是不会有 登录逻辑的,这种情况下,我们要在 App.vue 或 路由守卫里设置初始的用户ID:
// 防串号 - App.vue
if (!sessionStorage.getItem('locked_user_id')) {
// 如果是重新打开浏览器,尝试从持久化存储localStorage恢复
let user = getStore({ name: 'userinfo' });
if (user && user.id) {
sessionStorage.setItem('locked_user_id', user.id);
}
}2、axios 全局拦截调整:
请求拦截
//HTTPrequest拦截
axios.interceptors.request.use(config => {
if (getToken()) {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带token
}
const locked_user_id = sessionStorage.getItem('locked_user_id');// 防串号
if(locked_user_id){
config.headers['X-Verify-User-ID'] = locked_user_id;
}
return config
}, error => {
return Promise.reject(error)
});响应拦截:
//HTTPresponse拦截
axios.interceptors.response.use(res => {
const status = Number(res.status) || 200;
const resData = res.data || {};
// 处理身份隔离冲突 (403) - 串号
if (status === 403 || resData.errcode === 403) {
handleIdentityConflict(); // 调用下方定义的弹窗函数
return Promise.reject(new Error('Identity Conflict'));
}
// 其他逻辑...
// 只有完全正确才返回数据
return res;
}, error => {
return Promise.reject(new Error(error));
});handleIdentityConflict 函数的定义:
/**
* 封装身份冲突后的弹窗逻辑
*/
function handleIdentityConflict() {
// 防止重复弹出多个确认框
if (window.isShowingIdentityConfirm) return;
window.isShowingIdentityConfirm = true;
// 确认交互
MessageBox.confirm(
`检测到您已在其他标签页登录了新账号,当前页面已失效。是否切换到最新账号继续操作?`,
'身份环境变更',
{ confirmButtonText: '立即切换', cancelButtonText: '留在原地', type: 'warning' }
).then(() => {
let user = getStore({ name: 'userinfo' });
if (user && user.id) {
sessionStorage.setItem('locked_user_id', user.id);// 更新本标签页的锁,对齐身份
location.reload();// 刷新页面,让页面加载新客户的数据
}
}).finally(() => {
window.isShowingIdentityConfirm = false;
});
}handleIdentityConflict 这个函数需要注意避免一个页面多次触发,因为一个页面里可能有多个ajax请求,如果不做处理,就会触发多次提醒,对用户并不友好,所以我们这里增加一个全局变量,来判断当前页面是否已经触发提醒了。
后端一般在中间件里对请求进行判断,这里飘易的后端是LUMAN,中间件这样修改:
public function handle($request, Closure $next)
{
// 1. 原有的身份认证(获取当前登录的用户对象)
$user = $request->user('user');
if (empty($user)) {
return response()->json([
'errcode' => 401,
'errmsg' => 'Please provide the correct credentials'
]);
}
// 2. 获取前端通过 Header 传过来的“标签页锁定 ID”
$headerUserId = $request->header('X-Verify-User-ID');
// 3. 核心校验:如果前端传了 ID,则必须与当前 Token 所属的 ID 一致
if ($headerUserId && $user->id != $headerUserId) {
return response()->json([
'errcode' => 403,
'errmsg' => '身份环境已变更,请关闭当前页面',
'debug_info' => [
'expected' => $user->id,
'received' => $headerUserId
]
], 403);
}
return $next($request);
}核心是判断请求头里的 X-Verify-User-ID 和token解析出来的用户ID 是否一致,不一致就报 403 错误,让前端收到403状态码再去做对应的提示。
其他框架也是类似处理。