🔥长文警告!!Vue+NestJS实现双token无感刷新
前言
我们在使用一些APP的时候,如果你每天都打开使用的话就会发现根本不需要重新登录,但是当你有一段时间没用的话比如说一个月没打开某个APP那么就需要重新登录了,这是怎么实现的呢?
创建项目
创建vue项目
npm create vite app
- 选择vue --> TypeScript --> cd app-> npm i
- 下载axios:
npm i axios
创建NestJS项目
nest new server
- 择你喜欢的包管理工具(npm、yarn ...)
- 创建一个用户模块:
nest g res user --no-spec
,选择REST API
之后一直回车就行了。 - 下载TypeORM的依赖:
npm install --save @nestjs/typeorm typeorm mysql2
。关于TypeORM的使用可翻阅官方文档:TypeORM中文网 - 下载jwt依赖包:
npm install --save @nestjs/jwt
后端的编写
首先说明双token是那两个token。一个是access_token就是普通的token的作用,一个是refresh_token,顾名思义这个token是用来刷新token要用到的。
创建数据库
-
创建的用户(user)模块中找到
dto/create-user.dto.ts
。这里涉及到DTO对象的概念,DTO是Data Transfer Object的简称,中文意思就是数据传输对象,说白了就是前端要传递过来的数据,在DTO里可以进行校验操作,这里就不展开讲了。添加如下代码:export class CreateUserDto { username: string; password: string; }
-
在app.module.ts中引入
TypeOrmModule
模块:import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UserModule } from './user/user.module'; import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ UserModule, TypeOrmModule.forRoot({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: '123123', database: 'refresh_token_test', entities: [], synchronize: true, }), ], controllers: [AppController], providers: [AppService], }) export class AppModule {}
forRoot
里面的配置项中synchronize
意思是同步创建数据库表,entities
里面存放实体类,这两个配置项后面还会说到。 -
在
etities/user.entity.ts
创建实体对象,具体的概念就不做过多介绍了,这个对象和数据库的表是对应的,一个属性对应一个字段。添加如下代码:import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() // 主键自增 id: number; @Column({ type: 'varchar', length: 20 }) // 一个varchar(20)的字段 username: string; @Column({ type: 'varchar', length: 20 }) // 一个varchar(20)的字段 password: string; }
接下来将这个对象添加到
entities: []
里面,首先执行npm run start:dev
把项目跑起来,然后添加如下代码:entities: [User],
数据库刷新一下就会看到已经创建了user表。
至此数据的任务已经完成。
用户注册/登录
注册
首先要添加一个用户,一般来说密码要进行一次加密,我们这篇文章主要讲的是无感刷新,所以就不做太细致的操作了,添加一个用户就行了。在user.service.ts
把之前的默认代码删除掉,加如下代码:
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { EntityManager } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UserService {
// 这段代码其实是注入的一个实体管理器,这个实体管理器是typeorm的,用于管理数据库的
constructor(private readonly entityManager: EntityManager) {}
// 注册
create(createUserDto: CreateUserDto) {
// 使用entityManager的save方法向User表中插入数据
return this.entityManager.save(User, createUserDto);
}
}
接着在user.controller.ts
中把之前的自动生成的代码删除添加如下代码:
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('user')
export class UserController {
// 注入userService对象
constructor(private readonly userService: UserService) {}
@Post('register')
register(@Body() createUserDto: CreateUserDto) {
// 调用userService的create方法
return this.userService.create(createUserDto);
}
}
这里的参数是CreateUserDto
类型的,这里你就会发现前面说的DTO对象的用意了。
在ApiFox或者其他的工具测试一下这接口:
注意,接口地址是
http://localhost:3000
我们去数据库里看一下是不是新增了一条记录:
没问题了,添加了一个用户。
登录
这里我们先不用jwt,只是简单的校验用户名和密码,在user.service.ts
中添加如下代码:
// 登录
async login(loginUserDto: LoginUserDto) {
// 使用entityManager的findOne方法查询User表中的数据
const user = await this.entityManager.findOne(User, {
where: {
username: loginUserDto.username,
password: loginUserDto.password,
},
});
if (!user) {
throw new HttpException('用户名或密码错误', HttpStatus.BAD_REQUEST);
}
return user;
}
这里又有了新的DTO对象,新建dto/user-login.dto.ts, 增加如下代码:
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
// 直接继承了CreateUserDto,这里的PartialType是类型工具,将所有的属性变成可选。
export class LoginUserDto extends PartialType(CreateUserDto) {}
接下来要在user.controller.ts
中添加如下代码:
@Post('login')
login(@Body() loginUserDto: LoginUserDto) {
return this.userService.login(loginUserDto);
}
在apifox里测试一下:

可见,当我输入错误的时候就会相应报错。再试一次正确的:

可见,返回了用户信息,注意一般情况下是不会返回password的,这里只为了方便演示,没有做过多处理。
实现双token
首先在app.module.ts中引入JwtModule:

JwtModule.register({
global: true, // 这里一定要加上,全局模块,不需要其他模块引入
secret: 'secret', // token密钥
signOptions: { expiresIn: '30m' }, // 默认过期是时间这里设置成了30分钟
}),
接下来在user.service.ts中的login函数修改如下:

// 登录
async login(loginUserDto: LoginUserDto) {
// 使用entityManager的findOne方法查询User表中的数据
const user = await this.entityManager.findOne(User, {
where: {
username: loginUserDto.username,
password: loginUserDto.password,
},
});
if (!user) {
throw new HttpException('用户名或密码错误', HttpStatus.BAD_REQUEST);
}
// 生成access_token,内容是用户名和id
const access_token = this.jwtService.sign(
{
username: user.username,
id: user.id,
},
{
expiresIn: '30m', // 30分钟
},
);
// 生成refresh_token,内容是id
const refresh_token = this.jwtService.sign(
{
id: user.id,
},
{
expiresIn: '7d', // 7天
},
);
return { refresh_token, access_token };
}
刷新token
通过以上的代码我们可见,普通的access_token过期时间就30分钟,refresh_token过期时间是7天。可见当access_token过期之后我们要通过传入的refresh_token进行刷新token得到新的token,这样access_token过期时间又变成了30分钟,refresh_token过期时间是7天,当我们七天没使用APP后refresh_token也就过期了,就需要重新登录了。接下来实现一下refresh函数,在user.service.ts中添加代码:
// 刷新token
async refreshToken(refresh_token: string) {
try {
// 验证refresh_token
const decoded = this.jwtService.verify(refresh_token);
// 获取用户信息
const user = await this.entityManager.findOne(User, {
where: {
id: decoded.id,
},
});
// 生成access_token
const access_token = this.jwtService.sign(
{
username: user.username,
id: decoded.id,
},
{
expiresIn: '30m', // 30分钟
},
);
// 生成refresh_token
const newRefresh_token = this.jwtService.sign(
{
id: decoded.id,
},
{
expiresIn: '7d', // 7天
},
);
return { data: { refresh_token: newRefresh_token, access_token } };
} catch (error) {
throw new HttpException('refresh_token已过期', HttpStatus.BAD_REQUEST);
}
}
以上代码看着很长,其实没有什么难的,就是先校验refresh_token拿到id, 然后通过id获取用户信息,之后分别生成refresh_tokn和access_token,如果校验失败就抛出报错信息。继续在user.controller.ts中添加路由:
@Get('refresh')
refresh(@Query('refreshToken') refreshToken: string) {
return this.userService.refreshToken(refreshToken);
}
然后在apifox里测试一下:
用登录得到的refresh_token请求得到新的token:

接下来写一个测试接口,用来测试咱们的token刷新是否可行。首先创建一个登录的路由守卫:nest g guard --no-spec --flat
,添加如下代码:
import { JwtService } from '@nestjs/jwt';
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
@Injectable()
export class LoginGuard implements CanActivate {
@Inject(JwtService)
private jwtService: JwtService;
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// 获取request对象
const request: Request = context.switchToHttp().getRequest();
// 从请求头中获取token
const token = request.headers.authorization;
// 如果没有token,抛出异常
if (!token) {
throw new UnauthorizedException('Token not found');
}
try {
// 验证token
this.jwtService.verify(token);
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}
创建获取用户信息的服务和路由,在user.service.ts和user.controller.ts中分别添加如下代码:
// 获取用户信息
async getUserInfo(id: number) {
// 使用entityManager的findOne方法查询User表中的数据
const user = await this.entityManager.findOne(User, {
where: {
id,
},
});
if (!user) {
throw new HttpException('用户不存在', HttpStatus.BAD_REQUEST);
}
return { data: user };
}
以下代码中将LoginGuard
添加上了
@Get('info')
@UseGuards(LoginGuard)
getUserInfo(@Query('id') id: number) {
return this.userService.getUserInfo(+id);
}
接下来header上不带token来请求一遍这个info接口:

可见返回来401的报错,接下来登录一下拿到access_token之后在请求头里加上:

正确拿到了用户信息,注意请求的时候需要加上query参数,id为1。
接下来手动把请求头里的token改成错误的模拟token过期,之后用登录获取到的refresh_token为参数请求刷新token的接口,获取到新的access_token之后再次请求info接口获取用户信息:

刷新token:

重新请求:

OK,后端接口都跑通了,后面就是前端的工作了。
前端的编写
登录
这里我们直接在App.vue中写吧。删除自动生成的代码,只留一个基本的结构,先把基本的登录写上。 使用elementUI了这里。App.vue中添加如下代码,注意这里我的axiso直接用的,没有进行二次封装:
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
const form = ref({
username: '',
password: ''
})
const login = async () => {
const res = await axios.post('http://localhost:3000/user/login', form.value)
localStorage.setItem('access_token', res.data.access_token)
localStorage.setItem('refresh_token', res.data.refresh_token)
}
</script>
<template>
<div>
<el-form label-width="80px" style="max-width: 400px">
<el-form-item lang=" username ">
<el-input v-model="form.username" style="width: 144.928rpx" placeholder="username" />
</el-form-item>
<el-form-item lang=" password ">
<el-input v-model="form.password" type="password" style="width: 144.928rpx" placeholder="password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="login">登录</el-button>
</el-form-item>
</el-form>
</div>
</template>
<style scoped></style>
输入正确的username和password之后就会获取得双token
实现token的刷新
接下来就要对axios进行封装了,这里也是进行简单的封装。 首先在请求拦截器上给header加上 access_token:

import axios from "axios";
const request = axios.create({
baseURL: "/user/login",
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response;
},
(error) => {
return Promise.reject(error);
}
);
export default request;
接下来增加获取用户信息,要注意这里登录的接口改了:

<script setup lang="ts">
import axios from '../src/axios/index'
const getUserInfo = async () => {
const res = await axios.get(`user/info?id=1`)
console.log(res)
}
</script>
<template>
<div>
<div>
<el-button type="primary" @click="getUserInfo">获取用户信息</el-button>
</div>
</div>
</template>
<style scoped></style>
接下来到了增加刷新token的时候了,首先新增一个刷新token的函数:

// 刷新token
const refreshToken = async () => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
return Promise.reject("refresh_token is empty");
}
const res = await axios.get(`user/refresh?refreshToken=${refreshToken}`);
return res.data;
};
在拦截器里面增加当access_token过期之后调用刷新函数的代码:
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response;
},
(error) => {
const { data, config } = error.response;
// 如果是登录过期并且请求的地址不是 /user/refresh,就调用refreshToken
if (data.statusCode === 401 && config.url !== "/user/refresh") {
return refreshToken()
.then((res) => {
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
// 重新发送请求, 这里面的config其实就是请求的配置: {url, method, data, headers, ...}
return request(config);
})
.catch(() => {
// 如果刷新token失败,跳转到登录页, 此处代码省略
// ......
});
}
return Promise.reject(error);
}
);
我们在手动修改access_token,模拟token过期失效,在请求获取用户信息的接口:
你会发现,access_token失效后info接口先报错之后自动调用了refresh接口然后重新请求了info接口成功获取到了数据:
看似无感刷新已经可以用了,但是有一个问题,这里我把获取用户信息的接口并发执行:
const getUserInfo = async () => {
// 并发请求
await Promise.all([
axios.get('user/info?id=1'),
axios.get('user/info?id=1'),
axios.get('user/info?id=1')
])
}
再次重复上面修改access_token模拟过期,请求获取用户信息的接口。你会发现请求了三次refresh接口,其实我们只需要执行一次refresh就好了,多次无效的请求会给服务器造成不必要的压力:
优化无感刷新
代码做如下修改,其实就是在拦截器里面将错误请求的且把它的 resolve 方法还有 config 加到队列里,等到token刷新成功之后重新发送队列中的请求,并且把结果通过 resolve 返回。
axios的完整代码如下:
import axios, { AxiosRequestConfig } from "axios";
const request = axios.create({
baseURL: "http://localhost:3000/",
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 刷新token
const refreshToken = async () => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
return Promise.reject("refresh_token is empty");
}
// 这里会等到并发的请求都执行完之后再执行
const res = await request.get(`user/refresh?refreshToken=${refreshToken}`);
return res.data;
};
interface PendingTask {
config: AxiosRequestConfig;
resolve: Function;
}
// 是否还需要刷新token的标识
let refreshing = false;
// 存储未完成的请求
const task: PendingTask[] = [];
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const { data, config } = error.response;
// 如果正在刷新token,则将失败的请求挂起,
// 存入task中等待刷新token完成在全部执行出来
if (refreshing) {
return new Promise((resolve) => {
task.push({
config,
resolve,
});
});
}
// 如果是登录过期并且请求的地址不是 /user/refresh,就调用refreshToken
if (data.statusCode === 401 && config.url !== "/user/refresh") {
// 此时需要刷新了
refreshing = true;
try {
const res = await refreshToken();
// 刷新token成功
refreshing = false;
// 重新发送请求
task.forEach((item) => {
item.resolve(request(item.config));
});
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
return request(config);
} catch (error) {
// refreshToken失败,跳转到登录页
// ......
}
}
return Promise.reject(error);
}
);
export default request;
至此无感刷新完成了!!!!
转载自:https://juejin.cn/post/7363193808521379879