likes
comments
collection
share

🔥长文警告!!Vue+NestJS实现双token无感刷新

作者站长头像
站长
· 阅读数 57

前言

我们在使用一些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表。 🔥长文警告!!Vue+NestJS实现双token无感刷新

    至此数据的任务已经完成。

用户注册/登录

注册

首先要添加一个用户,一般来说密码要进行一次加密,我们这篇文章主要讲的是无感刷新,所以就不做太细致的操作了,添加一个用户就行了。在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或者其他的工具测试一下这接口:

🔥长文警告!!Vue+NestJS实现双token无感刷新 注意,接口地址是http://localhost:3000 我们去数据库里看一下是不是新增了一条记录:

🔥长文警告!!Vue+NestJS实现双token无感刷新

没问题了,添加了一个用户。

登录

这里我们先不用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里测试一下:

🔥长文警告!!Vue+NestJS实现双token无感刷新

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

🔥长文警告!!Vue+NestJS实现双token无感刷新

可见,返回了用户信息,注意一般情况下是不会返回password的,这里只为了方便演示,没有做过多处理。

实现双token

首先在app.module.ts中引入JwtModule:

🔥长文警告!!Vue+NestJS实现双token无感刷新
JwtModule.register({
      global: true, // 这里一定要加上,全局模块,不需要其他模块引入
      secret: 'secret', // token密钥
      signOptions: { expiresIn: '30m' }, // 默认过期是时间这里设置成了30分钟
    }),

接下来在user.service.ts中的login函数修改如下:

🔥长文警告!!Vue+NestJS实现双token无感刷新
// 登录
  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:

🔥长文警告!!Vue+NestJS实现双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接口:

🔥长文警告!!Vue+NestJS实现双token无感刷新

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

🔥长文警告!!Vue+NestJS实现双token无感刷新

正确拿到了用户信息,注意请求的时候需要加上query参数,id为1。

接下来手动把请求头里的token改成错误的模拟token过期,之后用登录获取到的refresh_token为参数请求刷新token的接口,获取到新的access_token之后再次请求info接口获取用户信息:

🔥长文警告!!Vue+NestJS实现双token无感刷新

刷新token:

🔥长文警告!!Vue+NestJS实现双token无感刷新

重新请求:

🔥长文警告!!Vue+NestJS实现双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

🔥长文警告!!Vue+NestJS实现双token无感刷新

实现token的刷新

接下来就要对axios进行封装了,这里也是进行简单的封装。 首先在请求拦截器上给header加上 access_token:

🔥长文警告!!Vue+NestJS实现双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;

接下来增加获取用户信息,要注意这里登录的接口改了:

🔥长文警告!!Vue+NestJS实现双token无感刷新
<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的函数:

🔥长文警告!!Vue+NestJS实现双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过期之后调用刷新函数的代码:

🔥长文警告!!Vue+NestJS实现双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过期失效,在请求获取用户信息的接口:

🔥长文警告!!Vue+NestJS实现双token无感刷新

你会发现,access_token失效后info接口先报错之后自动调用了refresh接口然后重新请求了info接口成功获取到了数据:

🔥长文警告!!Vue+NestJS实现双token无感刷新

看似无感刷新已经可以用了,但是有一个问题,这里我把获取用户信息的接口并发执行:

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就好了,多次无效的请求会给服务器造成不必要的压力:

🔥长文警告!!Vue+NestJS实现双token无感刷新

优化无感刷新

代码做如下修改,其实就是在拦截器里面将错误请求的且把它的 resolve 方法还有 config 加到队列里,等到token刷新成功之后重新发送队列中的请求,并且把结果通过 resolve 返回。

🔥长文警告!!Vue+NestJS实现双token无感刷新

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
评论
请登录