双token
Access Token(访问令牌):
Refresh Token(刷新令牌):
双token的流程
1、用户登录:用户提供正确的凭证(如用户名和密码)后,服务端生成一个 Access Token 和一个 Refresh Token,发送给客户端。
2、请求带 Access Token:客户端每次请求时会携带 Access Token,服务端验证 Token 是否有效。如果有效,则允许访问受保护资源。
3、Access Token 过期:由于 Access Token 的生命周期较短,当它过期后,客户端无法再用它访问资源。
4、使用 Refresh Token 刷新 Access Token:客户端检测到 Access Token 过期后,向服务端发送 Refresh Token,请求新的 Access Token。
5、返回新的 Access Token:如果 Refresh Token 仍然有效,服务端会返回一个新的 Access Token(以及可能更新的 Refresh Token),客户端继续使用新的 Access Token 进行请求。
6、Refresh Token 过期:当 Refresh Token 过期时,用户需要重新登录,以获得新的 Access Token 和 Refresh Token。
为什么要使用双token
使用双 Token 机制主要是出于以下几个目的:提高安全性、提升用户体验、减轻服务器负担。具体来说,双 Token 的设计有以下优势:
1. 提高安全性
- 短期的 Access Token 减少了被盗用的风险: Access Token 的有效期较短(通常几分钟到几小时),即使被攻击者窃取,也只能在有限的时间内使用它。一旦过期,攻击者无法继续利用它,降低了风险。
- Refresh Token 受保护更好: Refresh Token 通常有效期较长(例如几天或几周),但其使用频率远低于 Access Token,因此暴露的机会更少。用户的客户端只在 Access Token 过期时才会使用 Refresh Token 请求新的 Access Token,降低了 Refresh Token 被拦截的可能性。
2. 提升用户体验
- 避免频繁重新登录: 如果只使用一个短期有效的 Token,那么每当 Token 过期后,用户就需要重新登录。使用双 Token 机制,当 Access Token 过期时,客户端可以自动使用 Refresh Token 获取新的 Access Token,用户无需感知这一过程,从而提供了更加流畅的使用体验。
- 无感知的 Token 刷新: 双 Token 机制允许在后台自动刷新 Access Token。用户的登录状态可以保持较长时间,避免打断操作或要求频繁输入密码。
3. 减轻服务器负担
- 减少用户登录次数: 如果每次 Access Token 失效后都要求用户重新登录,服务器需要频繁处理用户的登录请求。而双 Token 机制允许用户只需在初次登录时验证一次凭证,之后通过 Refresh Token 自动续期,减少了对登录系统的压力。
- 降低密码验证频率: 双 Token 机制减少了频繁验证用户名和密码的需求,从而减轻了服务器的压力。只有当 Refresh Token 也失效时,用户才需要重新登录。
4. 更灵活的 Token 管理
- 更好的控制权限: Access Token 可以嵌入用户的权限信息,因此短期的 Access Token 能方便地进行权限的动态调整。例如,如果某个用户的权限发生了变化,系统可以通过颁发新 Token 反映这些变化,过期的旧 Token 会自然失效。
- 强制失效机制: 如果检测到某个用户的 Access Token 或 Refresh Token 被盗,服务器可以通过黑名单或撤销 Token 的方式立即使其失效。而 Refresh Token 允许在不影响用户体验的情况下更新 Access Token,实现更加灵活的会话管理。
5. 提升安全性和便捷性的平衡
- 短期的 Access Token 与长期的会话状态: 双 Token 机制在安全性和用户体验之间找到了平衡点。Access Token 的短期有效期可以限制 Token 被盗后的风险,而 Refresh Token 提供了长时间保持登录状态的能力,让用户不必频繁重新登录。
6. 典型应用场景
- 单页应用(SPA) :如 Vue.js 或 React.js 这样的前端应用,需要频繁与后端 API 进行交互。如果只使用短期的 Access Token,用户的会话可能会频繁中断。通过双 Token 机制,Access Token 失效时可以无感刷新,从而提升用户体验。
- 移动应用:移动应用用户通常期望登录后长时间保持会话状态。双 Token 机制允许用户长时间使用应用,同时仍保持较高的安全性。
实例(vue+pinia)
- 项目准备 (安装依赖)
npm install axios
npm install pinia
npm install vue-router@4
|--mock
|--index.ts
|-- src
|--router
|--index.ts
|--service
|--api.ts
|--store
|--auth.ts
|--index.ts
|--views
|--Dashboard.vue
|--Login.vue
|--App.vue
|--main.ts
(1)模拟后端mock
import { MockMethod } from 'vite-plugin-mock'
const PostUserLogin = {
"username": "a",
"password": "1",
"accessToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
"refreshToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
"accessTokenExpiry": 30 * 60 * 1000,
"refreshTokenExpiry": 7 * 30 * 24 * 60 * 1000
}
const UpdateUserToken = {
"accessToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
"refreshToken":'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjMzODMxODY4OTA2MzQ0NDQ4LCJpc3MiOiJ6dHkiLCJleHAiOjE3MjIwMDg2MzMsImlhdCI6MTcyMTQwMzgzM30.52S54luvyzc3SwB-M3oClgFPgjzXcJrkFjYIcZ8A7ug',
"accessTokenExpiry": 30 * 60 * 1000,
"refreshTokenExpiry": 7 * 30 * 24 * 60 * 1000
}
function validateCredentials(username, password) {
return username === 'a' && password === '1';
}
const mockLogin = (options) => {
const { body } = options;
if (!body || !body.username || !body.password) {
return { code: 400, msg: "缺少用户名或密码" };
}
if (validateCredentials(body.username, body.password)) {
return {
code: 200,
data: PostUserLogin,
msg: "登录成功"
};
} else {
return {
code: 401,
msg: "用户名或密码错误"
};
}
};
const mockRefresh = () => {
return {
code: 200,
data: UpdateUserToken,
msg: "更新双token成功"
};
};
export default [
{
url: '/api/auth/login',
method: 'post',
response: mockLogin
},
{
url: '/api/auth/refresh',
method: 'post',
response: mockRefresh
}
] as MockMethod[];
(2)配置@别名
import { defineConfig } from "vite";
import path from "path";
import vue from "@vitejs/plugin-vue";
const pathSrc = path.resolve(__dirname, "src");
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
"@": pathSrc,
},
},
});
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// 别名
"baseUrl": ".",
"paths": {
"@/*":["src/*"]
},
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
(3)全局配置
<script setup lang="ts">
</script>
<template>
<RouterView></RouterView>
</template>
<style scoped>
</style>
import { createApp } from 'vue'
import App from './App.vue'
import {router} from "@/router";
import { setupStore } from "@/store";
createApp(App).use(setupStore).use(router).mount('#app')
(3)路由
import { createRouter, createWebHashHistory } from "vue-router";
import login from '@/views/Login.vue';
import dashboard from '@/views/Dashboard.vue'; // 假设你有一个 Dashboard.vue 组件
const routes = [
{
path: '/',
redirect: '/login' // 初始重定向到登录页面
},
{
path: '/login',
name: 'Login',
component: login,
},
{
path: '/dashboard',
name: 'Dashboard',
component: dashboard,
meta: { requiresAuth: true } // 可以添加一个 meta 字段来标记需要认证的路由
},
// 你可以继续添加其他路由
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
router.beforeEach((to, from, next) => {
const isLogin = Boolean(localStorage.getItem("token"));
// 检查目标路由是否需要认证
if (to.matched.some(record => record.meta.requiresAuth)) {
// 需要认证但用户未登录,则重定向到登录页面
if (!isLogin) {
next({
name: "Login",
query: { redirect: to.fullPath } // 将要跳转路由的 path 作为参数,传递到登录页面
});
} else {
// 用户已登录,继续到目标路由
next();
}
} else {
// 路由不需要认证,或者用户已登录,继续到目标路由
next();
// 如果想要用户登录后自动跳转到 dashboard(即使他们尝试访问其他不需要认证的路由),可以在这里添加逻辑
// 例如:如果已登录且目标不是 dashboard,则重定向到 dashboard
if (isLogin && to.name !== 'Dashboard') {
next({ name: 'Dashboard' });
}
}
});
export { router };
(3)响应拦截和请求拦截
import axios from 'axios';
import { useAuthStore } from '@/store/auth';
export const api = axios.create({
baseURL: '/api',
timeout: 10000,
});
// 请求拦截器:添加 Authorization 头
api.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:处理 401 错误并刷新 Token
api.interceptors.response.use(
(response) => response,
async (error) => {
const authStore = useAuthStore();
const originalRequest = error.config;
// 如果是 401 Unauthorized,并且未重试过
if (error.response && error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (!authStore.isRefreshTokenExpired) {
try {
await authStore.refreshAccessToken(); // 刷新 Access Token
return api(originalRequest); // 重新发送原始请求
} catch (error) {
authStore.logout(); // 刷新失败则注销
}
} else {
authStore.logout(); // Refresh Token 过期,直接注销
}
}
return Promise.reject(error);
}
);
(4)双token使用
import { defineStore } from 'pinia';
import { api } from '@/service/api';
export const useAuthStore = defineStore('auth', {
state: () => ({
token: '',
refreshToken: '',
tokenExpiry:0,
refreshTokenExpiry:0,
}),
getters: {
// 计算属性来判断 refreshToken 是否过期
isRefreshTokenExpired: (state) => {
return new Date().getTime() > state.refreshTokenExpiry;
},
},
actions: {
async login(username: string, password: string) {
try {
const response = await api.post('/auth/login', { username, password });
if (response.data.code == 200) {
this.updateTokens(response.data);
this.persistTokens();
return {
token: response.data.data.accessToken,
refreshToken: response.data.data.refreshToken
};
} else {
console.log('账号或密码错误');
return null;
}
} catch (error) {
console.error('Error refreshing token:', error);
this.logout();
throw error;
}
},
logout() {
this.resetTokens();
this.clearPersistedTokens();
console.log('login out');
},
async refreshAccessToken() {
try {
const refreshToken = this.refreshToken || localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post<{ code: number; data: TokenData }>('/auth/login', { username, password });
if (response.data.code === 200) {
// 将秒转换为毫秒
const { accessToken, refreshToken, expiresIn, refreshExpiresIn } = response.data.data;
this.updateTokens({
token: accessToken,
refreshToken,
tokenExpiry: Date.now() + expiresIn * 1000,
refreshTokenExpiry: Date.now() + refreshExpiresIn * 1000,
});
this.persistTokens();
console.log('Login successful');
return { token: accessToken, refreshToken };
} else {
console.log('账号或密码错误');
return null;
}
} catch (error) {
console.error('Error logging in:', error);
this.logout();
throw error;
}
},
// 封装更新 tokens 的逻辑
updateTokens(tokens: {
token: string;
refreshToken: string;
tokenExpiry: number; // 毫秒为单位
refreshTokenExpiry: number; // 毫秒为单位
}) {
this.token = tokens.token;
this.refreshToken = tokens.refreshToken;
this.tokenExpiry = tokens.tokenExpiry;
this.refreshTokenExpiry = tokens.refreshTokenExpiry;
},
// 封装持久化 tokens 的逻辑
persistTokens() {
localStorage.setItem('token', this.token);
localStorage.setItem('refreshToken', this.refreshToken);
localStorage.setItem('token_expiry', this.tokenExpiry);
localStorage.setItem('refreshToken_expiry', this.refreshTokenExpiry);
},
// 封装重置 tokens 的逻辑
resetTokens() {
this.token = '';
this.refreshToken = '';
this.tokenExpiry = 0;
this.refreshTokenExpiry = 0;
},
// 封装清除持久化 tokens 的逻辑
clearPersistedTokens() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('token_expiry');
localStorage.removeItem('refreshToken_expiry');
}
},
});
(5)配置pinia
import type { App } from "vue";
import { createPinia } from "pinia";
const store = createPinia();
export function setupStore(app: App<Element>) {
app.use(store);
}
export { store };
(6)页面
<template>
<div>
<h1>登录</h1>
<form @submit.prevent="handleLogin">
<input v-model="username" placeholder="用户名" required />
<input v-model="password" type="password" placeholder="密码" required />
<button type="submit">登录</button>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useAuthStore } from '@/store/auth';
import { useRouter } from 'vue-router';
const username = ref('');
const password = ref('');
const authStore = useAuthStore();
const router = useRouter();
const handleLogin = async () => {
try {
const res = await authStore.login(username.value, password.value);
if (res) {
const redirect = router.currentRoute.value.query.redirect || '/dashboard';
router.push(redirect); ;
} else {
console.error('登录失败');
}
} catch (error) {
console.error('登录失败:', error);
}
};
</script>
<script setup lang='ts'>
</script>
<template>
<div>成功登录页面</div>
</template>
<style scoped></style>