文章目录
- 初次授权与发放Token:
- Access Token的作用:
- Refresh Token的作用:
- 无感刷新:
- 安全机制:
- 后端创建nest项目
- AppController 添加login、refresh、getinfo接口
- 创建user.dto.ts
- AppController添加模拟数据
- 前端Hbuilder创建VUE3项目
- 安装axios
- 根目录下添加.env配置环境
- 根目录下创建vite.config.js配置代理
双token机制,尤其是指在OAuth 2.0授权协议中广泛使用的access token(访问令牌)和refresh token(刷新令牌)组合,用来实现无感刷新登录状态的原理如下:
初次授权与发放Token:
用户登录时,通过用户名、密码或其他认证方式向认证服务器请求授权。认证成功后,服务器不仅返回一个短期有效的access token(通常几分钟到几小时),还会发放一个长期有效的refresh token(几天到几个月)。
Access Token的作用:
access token是客户端访问受保护资源的临时凭证,每次客户端发起对受保护资源的请求时,都需要在HTTP请求头中携带access token。一旦access token过期,请求就会失败。
Refresh Token的作用:
refresh token的目的是在access token过期后,无需用户重新登录,客户端可以使用refresh token向认证服务器申请新的access token。通常refresh token的生命周期较长,而且存储得更为安全,因为它涉及到长期的授权。
无感刷新:
当客户端检测到access token即将过期或已经过期时,自动在后台向认证服务器发起请求,携带refresh token换取新的access token。这个过程对用户来说是无感知的,即用户不需要重新登录,页面也不会中断或刷新,因此被称为“无感刷新”。
安全机制:
为了保证安全性,refresh token一般具备一定的安全措施,例如限制其使用次数(防止无限刷新)、设置有效期(过期后必须重新登录)以及严格的存储策略(通常不会在客户端明文存储,而是存储在服务器端或经过加密存储在客户端本地)。
通过这种双token机制,可以在保障用户隐私和安全性的同时,大大提升用户体验,让用户在长时间操作过程中无需反复登录,实现所谓的“无感刷新登录状态”。
下载完整例子源码(vue+nest):https://download.csdn.net/download/ruancexiaoming/88913949
后端创建nest项目
# 创建
npx nest new token-test
#运行
cd token-test
npm run start
AppController 添加login、refresh、getinfo接口
// 登录请求
@Post('api/login')
login(@Body() userDto: UserDto) {
console.log(userDto);
const user = users.find(item => item.username === userDto.username);
if (!user) {
throw new BadRequestException('用户不存在');
}
if (user.password !== userDto.password) {
throw new BadRequestException("密码错误");
}
const accessToken = this.jwtService.sign({
username: user.username,
email: user.email
}, {
expiresIn: '0.5h'
});
//access_token 过期时间半小时
const refreshToken = this.jwtService.sign({
username: user.username
}, {
expiresIn: '7d'
})
//refresh_token 过期时间 7 天
return {
userInfo: {
username: user.username,
email: user.email
},
accessToken,
refreshToken
};
}
// 刷新token请求
@Post('api/refresh')
refresh(@Body() body: any) {
try {
console.log('refresh token');
console.log(body.token);
const data = this.jwtService.verify(body.token);
const user = users.find(item => item.username === data.username);
const accessToken = this.jwtService.sign({
username: user.username,
email: user.email
}, {
expiresIn: '0.5h'
});
const refreshToken = this.jwtService.sign({
username: user.username
}, {
expiresIn: '7d'
})
return {
accessToken,
refreshToken
};
} catch (e) {
throw new UnauthorizedException('token 失效,请重新登录');
}
}
// 验证token获取用户信息
@Get('api/getinfo')
getinfo(@Req() req: Request) {
const authorization = req.headers['authorization'];
if (!authorization) {
throw new UnauthorizedException('用户未登录');
}
try {
const token = authorization.split(' ')[1];
const data = this.jwtService.verify(token);
return {
userInfo: {
username: data.username,
email: data.email
}
};
} catch (e) {
throw new UnauthorizedException('token 失效,请重新登录');
}
}
创建user.dto.ts
export class UserDto {
username: string;
password: string;
}
AppController添加模拟数据
const users = [
{ username: 'test', password: 'success', email: 'abc@163.com' }
]
前端Hbuilder创建VUE3项目
安装axios
pnpm i axios
src目录下创建以下两个文件
utils/request.js
//request.js
import axios from "axios";
import { resolveResError } from "./helpers";
const server = axios.create({
baseURL: "/api",
timeout: 1000 * 10,
headers: {
"Content-type": "application/json"
}
})
var requesting = false
/*请求拦截器*/
function reqResolve(config) {
let accessToken = localStorage.getItem('access_token')
if (accessToken) {
config.headers.Authorization = 'Bearer ' + accessToken
}
return config
}
function reqReject(error) {
return Promise.reject(error)
}
const SUCCESS_CODES = [0, 200, 201, 202, 203, 204, 205]
/*响应拦截器*/
function resResolve(response) {
const { data, status, config, statusText, headers } = response
if (headers['content-type']?.includes('json')) {
//获取状态码
const code = data?.code ?? status
//检查是否保持
if (SUCCESS_CODES.includes(code)) {
return Promise.resolve(data)
}
// 根据code处理对应的操作,并返回处理后的message
const message = resolveResError(code, data?.message ?? statusText)
//需要错误提醒(是否不需要提示)
!config?.noNeedTip && message && window.$message?.error(message)
return Promise.reject({ code, message, error: data ?? response })
}
return Promise.resolve(data ?? response)
}
async function resReject(error) {
if (!error || !error.response) {
const code = error?.code
/** 根据code处理对应的操作,并返回处理后的message */
const message = resolveResError(code, error.message)
window.$message?.error(message)
return Promise.reject({ code, message, error })
}
const { data, status, config } = error.response
const code = data?.code ?? status
const message = resolveResError(code, data?.message ?? error.message)
let originalRequest = error.config;
let refreshToken = localStorage.getItem('refresh_token');
switch (code) {
case 400:
if (message == '用户不存在') {
return Promise.reject({ code, message, error })
}
break;
case 401:
if (refreshToken && !originalRequest._retry && !requesting) {
originalRequest._retry = true;
requesting = true
try {
// 使用refresh token尝试获取新的tokens/
refreshToken = localStorage.getItem('refresh_token');
console.log("刷新refreshToken");
console.log(refreshToken);
const refreshResponse = await axios.post('/api/refresh', {
"token": refreshToken
}).then((res) => {
return res;
}).catch((e) => {
// 刷新token失效会跳转下面的catch
return e;
})
if (refreshResponse?.data.accessToken) {
localStorage.setItem('access_token', refreshResponse.data.accessToken);
localStorage.setItem('refresh_token', refreshResponse.data.refreshToken);
// 在原始请求中添加新的access token,并标记为重试请求
originalRequest.headers.Authorization = `Bearer ${refreshResponse.accessToken}`;
requesting = false
// 重新发起请求
return await server(originalRequest);
}
} catch (refreshError) {
// 若刷新token失败,清除存储的tokens并通知用户重新登录
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('登录过期,请重新登录');
console.log("刷新token失败");
requesting = false
}
} else {
// 若无refresh token,直接提示用户重新登录
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
console.log("无刷新token");
alert('登录过期,请重新登录');
}
break;
case 403:
console.log("没有权限");
break;
}
/** 需要错误提醒 */
!config?.noNeedTip && message && window.$message?.error(message)
return Promise.reject({ code, message, error: error.response?.data || error.response })
}
server.interceptors.request.use(reqResolve, reqReject)
server.interceptors.response.use(resResolve, resReject)
export default server
unitls/helper.js
export function resolveResError(code, message) {
switch (code) {
case 401:
message = '登录已过期,是否重新登录'
break
case 11007:
case 11008:
message = '退出登录'
break
case 403:
message = '请求被拒绝'
break
case 404:
message = '请求资源或接口不存在'
break
case 500:
message = '服务器发生异常'
break
default:
message = message ?? `【${code}】: 未知异常!`
break
}
return message
}
根目录下添加.env配置环境
VITE_TITLE = '待煎的闲鱼'
# 是否使用Hash路由
VITE_USE_HASH = 'true'
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/'
# 代理配置-target 本地服务
VITE_PROXY_TARGET = 'http://localhost:3000'
根目录下创建vite.config.js配置代理
import path from 'path'
import { defineConfig, loadEnv } from 'vite'
import Vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig(({ command, mode }) => {
const isBuild = command === 'build'
const viteEnv = loadEnv(mode, process.cwd())
const { VITE_TITLE, VITE_PUBLIC_PATH, VITE_PROXY_TARGET } = viteEnv
return {
plugins: [Vue()],
base: VITE_PUBLIC_PATH || '/',
resolve: {
alias: {
'@': path.resolve(process.cwd(), 'src'),
'~': path.resolve(process.cwd()),
},
},
server: {
port: 3200, // 设置服务启动端口号
// open: true, // 设置服务启动时是否自动打开浏览器
cors: true, // 允许跨域
// 设置代理,根据我们项目实际情况配置
proxy: {
'/api': { //api是自行设置的请求前缀,按照这个来匹配请求,有这个字段的请求,就会进到代理来
target: "http://localhost:3000", //是自己需要调的接口的前缀域名
ws: false,
changeOrigin: true
},
}
}
}
})