普通视图

发现新文章,点击刷新页面。
昨天以前首页

双 Token 认证机制:从原理到实践的完整实现

2025年9月10日 17:47

双 Token 认证机制:从原理到实践的完整实现

在现代 Web 应用中,用户认证是保障系统安全的核心环节。随着前后端分离架构的普及,传统的 Session 认证方式逐渐被 Token 认证取代。其中,双 Token(Access Token + Refresh Token)机制凭借其在安全性与用户体验之间的出色平衡,成为主流的认证方案。本文将结合完整的 Express 后端与 Vue 前端代码,详细解析双 Token 机制的实现原理与实践细节。

双 Token 机制的核心原理

双 Token 机制通过两种不同特性的令牌协同工作,解决了 "安全性" 与 "用户体验" 之间的矛盾。其核心设计思想是:

  • Access Token(访问令牌) :短期有效,用于直接访问受保护资源,有效期通常设置为几分钟(示例中为 12 秒,仅用于演示)
  • Refresh Token(刷新令牌) :长期有效,仅用于获取新的 Access Token,有效期可设置为几天甚至几周(示例中为 7 天)

这种设计的优势在于:当 Access Token 被盗取时,攻击者仅有很短的时间窗口可以利用;而 Refresh Token 虽然长期有效,但通常存储在更安全的环境中,且一旦发现异常可立即吊销。

后端实现:Express 框架下的双 Token 系统

后端作为 Token 的签发者和验证者,承担着整个认证系统的核心逻辑。以下从初始化配置、核心工具函数、认证中间件到具体接口,逐步解析实现过程。

基础配置与依赖

const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cors({
    origin: [
        'http://localhost:5173', 'http://localhost:5174', 
        'http://localhost:5175', 'http://localhost:5176'
    ],
    credentials: true // 允许跨域请求携带Cookie
}));
app.use(express.json());
app.use(cookieParser());

这段代码完成了三件关键工作:

  1. 引入 Express 框架及必要中间件(cors 处理跨域,cookie-parser 解析 Cookie)
  1. 配置跨域规则,允许指定前端域名访问并支持跨域携带 Cookie
  1. 启用 JSON 请求体解析和 Cookie 解析功能,为后续处理打好基础

令牌存储与核心工具函数

// 内存存储(生产环境应使用Redis或数据库)
const accessTokens = new Map();
const refreshTokens = new Map();
// 获取当前时间戳(秒)
function now() {
    return Math.floor(Date.now() / 1000);
}
// 生成带前缀的随机令牌
function getRandom(prefix) {
    return `${prefix}-${Math.random().toString(26).slice(2)}${Date.now()}`;
}

为模拟令牌存储,示例使用 Map 对象临时存储令牌信息。在生产环境中,应替换为 Redis 等分布式存储系统,以支持多实例部署和令牌过期自动清理。

令牌签发函数
function getAccessToken(userId, ttlSec = 12) {
    const at = getRandom('AccessToken');
    accessTokens.set(at, { userId, expiresIn: now() + ttlSec });
    return at;
}
function getRefreshToken(userId, ttlSec = 3600 * 24 * 7) {
    const rt = getRandom('RefreshToken');
    refreshTokens.set(rt, { 
        userId, 
        expiresIn: now() + ttlSec, 
        revoked: false 
    });
    return rt;
}

这两个函数分别负责生成 Access Token 和 Refresh Token:

  • 为令牌添加前缀便于区分类型
  • 记录令牌关联的用户 ID 和过期时间
  • 为 Refresh Token 额外添加 "revoked" 状态,支持主动吊销
令牌验证与吊销函数
function verifyAccessToken(at) {
    const result = accessTokens.get(at);
    if (!result || result.expiresIn <= now()) return null;
    return result.userId;
}
function verifyRefreshToken(rt) {
    const result = refreshTokens.get(rt);
    if (!result || result.revoked || result.expiresIn <= now()) return null;
    return result.userId;
}
function revokeRefreshToken(rt) {
    const result = refreshTokens.get(rt);
    if (result) result.revoked = true;
}

验证函数通过检查令牌是否存在、是否过期、是否被吊销等状态,决定是否返回有效的用户 ID。这种设计确保了只有符合条件的令牌才能通过验证。

认证中间件:请求的第一道防线

app.use((req, res, next) => {
    // 登录、刷新、登出接口跳过验证
    if (['/auth/login', '/auth/refresh', '/auth/logout', '/login'].includes(req.path)) {
        return next();
    }
    // 从请求头获取AT
    const token = req.headers.token || '';
    const userId = verifyAccessToken(token);
    if (userId) {
        req.userId = userId; // 挂载用户ID到请求对象
        return next();
    }
    // 验证失败
    res.send({ status: 401, msg: "未登录或令牌过期" });
});

这个中间件实现了 "守门人" 功能:

  • 对登录、刷新、登出等特殊接口直接放行
  • 对其他所有请求验证 Access Token 的有效性
  • 验证通过则将用户 ID 挂载到请求对象,供后续接口使用
  • 验证失败则返回 401 错误,提示客户端进行处理

核心接口实现

登录接口:令牌的初始发放
app.post('/auth/login', (req, res) => {
    const { username } = req.body || {};
    const userId = username || 'demoUser';
    const at = getAccessToken(userId, 12);
    const rt = getRefreshToken(userId);
    // 将RT存入httpOnly Cookie
    res.cookie('rt', rt, {
        httpOnly: true, // 禁止前端JS访问,防XSS攻击
        sameSite: 'lax', // 限制跨站请求携带,防CSRF攻击
        secure: false, // 本地开发为false,生产需设为true
        path: '/',
        maxAge: 7 * 24 * 3600 * 1000
    });
    res.send({ status: 200, data: at });
});

登录接口是用户获取初始令牌的入口:

  1. 接收用户身份信息(示例简化为用户名)
  1. 生成 Access Token 和 Refresh Token
  1. 将 Access Token 直接返回给前端(通常存储在 localStorage)
  1. 将 Refresh Token 存入 httpOnly Cookie,提升安全性

特别注意 Cookie 的配置:httpOnly: true防止前端 JavaScript 访问,有效抵御 XSS 攻击;sameSite: 'lax'限制跨站请求携带 Cookie,降低 CSRF 攻击风险。

令牌刷新接口:无感续期的关键
app.post('/auth/refresh', (req, res) => {
    const rt = req.cookies.rt; // 从Cookie获取RT
    if (!rt) return res.send({ status: 401, msg: '无刷新令牌' });
    const userId = verifyRefreshToken(rt);
    if (!userId) return res.send({ status: 401, msg: '刷新令牌失效' });
    // 令牌旋转:吊销旧RT,生成新RT和新AT
    revokeRefreshToken(rt);
    const newRt = getRefreshToken(userId);
    const newAt = getAccessToken(userId);
    // 写入新RT到Cookie,返回新AT
    res.cookie('rt', newRt, { ... });
    res.send({ status: 200, data: newAt });
});

刷新接口实现了 Token 的无感续期:

  1. 从 Cookie 中获取 Refresh Token 并验证其有效性
  1. 采用 "令牌旋转" 机制:吊销旧的 Refresh Token,生成新的一对令牌
  1. 将新的 Refresh Token 存入 Cookie,新的 Access Token 返回给前端

令牌旋转机制大幅提升了安全性,即使 Refresh Token 被盗取,攻击者也只能使用一次。

登出接口:安全终止会话
app.post('/auth/logout', (req, res) => {
    const rt = req.cookies.rt;
    if (rt) revokeRefreshToken(rt); // 吊销RT
    res.clearCookie('rt', { path: '/' }); // 清除Cookie中的RT
    res.send({ status: 200, msg: '已登出' });
});

登出接口通过吊销 Refresh Token 并清除 Cookie,确保用户会话被安全终止,防止后续被恶意使用。

前端实现:Vue 中的令牌管理

前端作为令牌的持有者和使用方,需要妥善处理令牌的存储、传递和刷新逻辑。以下从路由守卫、请求拦截器到页面组件,解析前端实现细节。

路由守卫:控制页面访问权限

router.beforeEach((to, from, next) => {
    // 处理URL中的token参数
    const token = to.query.token;
    if (token) {
        localStorage.setItem("token", token);
        next({ path: to.path, query: {} });
        return;
    }
    // 检查是否需要认证
    if (to.meta.requiresAuth) {
        const currentToken = localStorage.getItem('token');
        if (!isValidToken(currentToken)) {
            // 没有有效token,跳转到登录中心
            window.open(`http://localhost:5174/login?resource=${window.location.origin}${to.path}`);
            return;
        }
    }
    next();
})

路由守卫实现了页面级别的访问控制:

  • 处理 URL 中携带的 token 参数,存储到 localStorage
  • 对标记为需要认证的路由(如/about),检查 token 有效性
  • 没有有效 token 时,引导用户到登录页面

Axios 拦截器:自动处理令牌

// 请求拦截器:添加token到请求头
request.interceptors.request.use((config) => {
    const token = localStorage.getItem("token");
    config.headers = config.headers || {}
    if (isValidToken(token)){
        config.headers.token = token;
    }else{
        localStorage.removeItem('token');
    }
    return config;
})
// 响应拦截器:处理token过期
request.interceptors.response.use(async (res) => {
    if (res.data && res.data.status === 401) {
        const original = res.config || {}
        if (original._retried) {
            // 已重试过仍失败,跳登录中心
            window.open(`http://localhost:5174/login?resource=${window.location.origin}`)
            return res;
        }
        original._retried = true
        if (!isRefreshing) {
            isRefreshing = true
            refreshPromise = request.post('/auth/refresh', {})
                .then(r => {
                    if (r.data && r.data.status === 200) {
                        const newToken = r.data.data
                        localStorage.setItem('token', newToken)
                        return newToken
                    }
                    throw new Error('refresh failed')
                })
                .catch(() => {
                    localStorage.removeItem('token')
                    throw new Error('refresh failed')
                })
                .finally(() => {
                    isRefreshing = false
                })
        }
        try {
            const newToken = await refreshPromise
            original.headers.token = newToken
            return request(original)
        } catch (e) {
            window.open(`http://localhost:5174/login?resource=${window.location.origin}`)
            return res
        }
    }
    return res;
})

拦截器是实现 "无感刷新" 的核心:

  • 请求拦截器自动为每个请求添加 Access Token
  • 响应拦截器在收到 401 错误时,自动尝试刷新令牌
  • 使用isRefreshing和refreshPromise避免并发刷新请求
  • 刷新成功则用新令牌重试原请求,失败则引导用户重新登录

登录页面组件:处理身份验证

<script setup>
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 windowPostMessage(token, resource) {
  if (window.opener) {
    window.opener.postMessage({ token }, resource.value)
  }
}
watch(
  () => route.query.resource,
  (val) => {
    resource.value = val ? decodeURIComponent(val) : "";
    if (token) {
      windowPostMessage(token, resource.value)
    }
  },
  { immediate: true }
);
function login() {
  request.get("/auth/login").then((res) => {
    const apitoken = res.data.data;
    localStorage.setItem("token", apitoken);
    windowPostMessage(apitoken, resource.value)
    window.location.href = `${resource.value}?token=${apitoken}`;
    window.close()
  });
}
</script>

登录页面处理用户身份验证流程:

  • 通过 URL 参数接收跳转来源(resource)
  • 登录成功后,通过 postMessage 通知父窗口
  • 将新令牌通过 URL 参数传递给来源页面
  • 关闭登录窗口,完成登录流程

双 Token 机制操作流程演示

为了更直观地理解双 Token 机制的实际运行过程,以下是一个 GIF 演示,展示了从用户登录到令牌刷新、访问资源以及登出的完整操作流程:

双tokenPlus.gif

演示内容说明:

  1. 用户未登录时在Home页面跳转登录页面后输入信息并登录,前端获取 Access Token 并存储,Refresh Token 通过 Cookie 存储
  1. 登录后访问受保护资源/api1,请求头携带 Access Token,成功获取资源
  1. 等待 Access Token 过期后再次访问/api1,前端拦截 401 错误,自动调用刷新接口获取新令牌
  1. 使用新的 Access Token 重新请求/api1,成功获取资源,用户无感知
  1. 点击登出按钮,前端清除本地存储的 Access Token,后端吊销 Refresh Token 并清除 Cookie

通过这个演示可以清晰看到,整个过程中用户无需多次输入账号密码,在 Access Token 过期时实现了无感续期,既保证了安全性又提升了用户体验。

双 Token 机制的安全性考量

双 Token 机制的安全性建立在多个层面的防护措施上:

  1. 令牌存储安全
  • Access Token 存储在 localStorage,便于前端管理但存在 XSS 风险
  • Refresh Token 存储在 httpOnly Cookie,防止前端 JS 访问,抵御 XSS 攻击
  1. 通信安全
  • 生产环境应启用 HTTPS,防止令牌在传输过程中被窃听
  • 合理设置 Cookie 的 secure 属性,确保仅通过 HTTPS 传输
  1. 令牌生命周期
  • Access Token 短期有效,减少被盗用后的风险窗口
  • Refresh Token 长期有效但支持主动吊销,平衡安全性与用户体验
  1. 防御机制
  • 令牌旋转机制确保 Refresh Token 只能使用一次
  • sameSite Cookie 属性降低 CSRF 攻击风险
  • 严格的令牌验证逻辑防止无效令牌被使用

总结与扩展

双 Token 机制通过 Access Token 和 Refresh Token 的协同工作,在安全性和用户体验之间取得了出色的平衡。本文提供的完整代码实现了从令牌签发、验证、刷新到登出的全流程,包含了前端和后端的关键处理逻辑。

在实际应用中,还可以进一步扩展:

  • 使用 Redis 等分布式存储替换内存存储,支持集群部署
  • 实现令牌黑名单机制,处理已吊销但未过期的令牌
  • 添加令牌撤销通知,在用户修改密码等场景立即失效所有令牌
  • 结合 JWT(JSON Web Token)实现无状态令牌验证,减轻服务器负担

通过理解和实践双 Token 机制,开发者可以为 Web 应用构建更加安全、可靠的认证系统,为用户提供流畅的使用体验同时保障系统安全。

多端单点登录(SSO)实战:从架构设计到代码实现

2025年9月4日 17:56

单点登录(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 的核心是解决 “登录状态跨应用传递” 的问题,本项目实现了两种典型交互模式:

  1. 整页重定向模式:业务应用通过页面跳转将用户引导至登录中心,登录成功后携带 token 重定向回原应用,适用于简单场景或弹窗被拦截时的降级方案。
  1. 弹窗 + postMessage 模式:业务应用通过window.open弹出登录中心,登录成功后利用postMessageAPI 将 token 安全传递回主应用,保留用户操作上下文,是本项目的推荐方案。

二、核心登录流程(弹窗 + postMessage 模式)

弹窗模式通过 “主应用 - 弹窗 - 主应用” 的通信闭环实现登录状态传递,既保证安全性又优化用户体验,核心流程分为三个阶段:

1. 触发登录:业务应用检测未登录状态

当用户访问业务应用的受保护资源时,系统通过两种方式判断未登录状态:

  • 请求拦截: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. 监听回调:业务应用准备接收 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')

3. 回传 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()
  });
}

三、路由与令牌接入方案

业务应用需通过路由守卫和请求拦截器实现 token 的自动管理,确保登录状态在路由跳转和 API 请求中无缝生效。

1. 路由守卫:控制页面访问权限

路由守卫负责在页面跳转时校验登录状态,对未登录用户拦截并触发登录流程,同时处理整页重定向模式下的?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. 请求拦截器:自动携带与刷新 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
})

四、各端代码说明

1. client1 代码特点

client1 作为主要的业务应用,实现了完整的 SSO 逻辑:

  • 请求拦截器自动携带 token
  • 响应拦截器在 401 时打开登录中心弹窗
  • 路由守卫处理整页重定向带来的 token
  • 注册 message 事件监听接收登录中心回传的 token
  • 实现了通过 postMessage 向业务应用回传 token 的功能

2. client2(登录中心)代码特点

client2 作为登录中心,提供登录页面和登录功能:主要是为了获取token

<template>
  <div>
    <div>账号名:<input /></div>
    <div>密码:<input /></div>
    <div>
      <button
        @click="() => login()"
      >
        登录
      </button>
    </div>
  </div>
</template>

其请求拦截器与 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;
})

3. 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>

五、关键细节与避坑指南

单点登录的实现涉及多端交互和安全校验,以下细节直接影响方案的稳定性和安全性:

  1. postMessage targetOrigin 校验

必须使用业务应用的origin(如http://localhost:5173)作为targetOrigin,禁止使用*(允许任意域名接收),否则可能导致 token 被恶意网站窃取。

  1. window.opener 的可用性处理

仅当登录中心通过window.open打开时,opener才指向业务应用窗口;若用户刷新登录弹窗,opener会变为null,需降级为整页重定向,如 client1 的 login 函数中同时实现了 postMessage 和整页重定向。

  1. resource 参数的编解码

业务应用传递origin时需用encodeURIComponent编码(处理特殊字符),登录中心接收后用decodeURIComponent解码,避免 URL 解析错误,如 client1 中 watch 监听 resource 参数时进行了解码处理。

  1. 路由守卫必须调用 next()

无论是否允许路由跳转,beforeEach守卫都必须调用next(),否则会导致页面卡死(不渲染),client1 的路由守卫中正确实现了这一点。

  1. 弹窗被浏览器拦截的兼容

若浏览器因 “非用户主动触发” 拦截弹窗(如自动执行的window.open),需捕获错误并降级为整页重定向。

六、两种模式的选用场景

模式 优势 劣势 适用场景
弹窗 + postMessage 不刷新页面,保留用户操作上下文;体验流畅 需处理弹窗拦截、opener丢失等边缘情况 主流场景:用户主动触发的登录(如点击 “我的” 按钮)
整页重定向 实现简单,无浏览器兼容性问题 刷新页面,丢失当前操作状态 降级方案:弹窗被拦截时;简单应用或旧浏览器

推荐实现 “智能兼容” 逻辑:登录中心优先尝试postMessage回传,检测到opener无效时自动切换为整页重定向,确保所有场景下的可用性,client1 的登录函数已实现此逻辑。

七、总结

单点登录(SSO)的核心是通过 “统一认证 + 跨域通信” 实现多应用的身份共享。本项目通过 “弹窗 + postMessage” 模式优化用户体验,同时以整页重定向作为兜底,兼顾了安全性与兼容性。

关键成功要素包括:

  • 严格的origin校验,防止 token 跨域泄露;
  • 完善的边缘情况处理(如opener丢失、弹窗拦截);
  • 统一的 token 管理机制(路由守卫 + 请求拦截器)。

通过这套示例,业务应用(client1、client3)无需关心登录逻辑,只需专注自身业务;登录中心(client2)统一处理身份认证,实现 “一次登录,全平台通行” 的目标,为用户提供无缝的跨应用体验。实际应用中,可根据具体业务场景对这套代码进行扩展和优化,如增加 token 过期刷新机制、完善错误处理等。


如果您觉得这篇文章对您有帮助,欢迎点赞和收藏,大家的支持是我继续创作优质内容的动力🌹🌹🌹也希望您能在😉😉😉我的主页 😉😉😉找到更多对您有帮助的内容。

  • 致敬每一位赶路人
❌
❌