Vue3+NestJS实现权限管理系统(四):实现token续期

1,103 阅读5分钟

上一篇文章我们通过 JWT 生成了 token 返回给前端进行鉴权,并且给 token 设置了过期时间。同时留下了一个问题:如果用户一直在操作页面,一直在调用我们的接口,过期时间一到突然让用户退出重新登录显然是不合理的,所以这一节给大家介绍在用户操作页面的过程中如何给 token 续期。

做过后端的同学可能会知道可以使用双 token 进行鉴权:

用户登录成功后,生成两个不同的 token:Access TokenRefresh Token 返回给前端。Access Token 用于访问受保护的资源,通常具有较短的有效期。Refresh Token 用于获取新的 Access Token,通常具有较长的有效期。后端可以验证 Access Token 是否有效,如果 Access Token 失效,则告诉前端使用 Refresh Token 调用刷新 token 接口获取新的 Access TokenRefresh Token

这种方案当然是可行的,但是需要维护两个 token,前后端都有些繁琐,那么有没有只返回给前端一个 token 的同时实现续期呢? 当然是有的,我们的项目中也会采取这种方案实现 token 续期。

具体实现逻辑如下:

用户登录成功后,后端生成 token 后,将 token 以{token:token}的形式存入 redis 或者 session 中并将 token 返回前端。

验证 token 的时候根据前端传来的 token 从缓存中取得 token 值进行 JWT 验证并获取到 token 的过期时间

过期时间-当前时间小于某个值的时候我们重新进行 JWT 注册生成新的 newToken 并以{token:newToken}的形式存如 redis 或者 session 中。

那么只要我们在过期时间-当前时间内调用过需要鉴权的接口,那么真正的newToken就会刷新过期时间

下面我们来看一下在项目中的具体实现

user.service.ts中,用户登录的时候,将 token 存入 redis,同时为了便于测试我们先将验证码逻辑注释

image.png

然后在 token 验证逻辑user.guard.ts先从 redis 中拿到缓存的 token 值进行 token 验证,验证不同则返回验证不通过。验证通过获取到过期时间于当前时间做对比,如果小于一小时(这里便于测试先写 30 秒)就重新生成 token,并以前端传来的 token 为 key,新生成的 token 为 value 存入 redis,同时打印一下间隔时间

image.png

同样的为了测试先将 app.module.ts 中 jwt 的过期时间改为 60 秒

image.png

那么此时登录的逻辑就是只要在30s~60s之间调用过接口那么 token 就会重新注册,否则 token 就会过期

我们试一下效果,先调用登录接口获取到 token image.png

然后将 token 放到测试接口/user/test头里,请求一下发现 token 验证通过了

image.png

看下控制台打印了间隔时间及是否小于 30 秒

image.png

等待一会,30s~60s之后再调用两次发现间隔时间小于 30s ,那么 token 重新注册,过期时间就重新计算了

image.png

如果用户 60s 没都没调用接口那么 token 便真的过期了

相关代码如下:

  • user.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entities/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiException } from 'src/common/filter/http-exception/api.exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
import { LoginDto } from './dto/login-dto';
import { JwtService } from '@nestjs/jwt';
import encry from '../utils/crypto';
import generateCaptcha from 'src/utils/generateCaptcha';
import { CacheService } from 'src/cache/cache.service';
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private jwtService: JwtService,
    private cacheService: CacheService,
  ) {}
  //注册
  async create(createUserDto: CreateUserDto) {
    const { username, password, captcha, id } = createUserDto;

    //缓存的验证码
    const cacheCaptcha = await this.cacheService.get(id);

    if (captcha !== cacheCaptcha) {
      throw new ApiException('验证码错误', ApiErrorCode.COMMON_CODE);
    }
    const existUser = await this.userRepository.findOne({
      where: { username },
    });

    if (existUser)
      throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST);
    try {
      const newUser = new User();
      newUser.username = username;
      newUser.password = password;
      await this.userRepository.save(newUser);
      return '注册成功';
    } catch (error) {
      throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
  }

  async findOne(username: string) {
    const user = await this.userRepository.findOne({
      where: { username },
    });

    if (!user)
      throw new ApiException('用户名不存在', ApiErrorCode.USER_NOTEXIST);
    return user;
  }

  async login(loginDto: LoginDto) {
    const { username, password, captcha, id } = loginDto;
    //缓存的验证码
    const cacheCaptcha = await this.cacheService.get(id);
    // if (captcha !== cacheCaptcha) {
    //   throw new ApiException('验证码错误', ApiErrorCode.COMMON_CODE);
    // }
    const user = await this.findOne(username);
    if (user.password !== encry(password, user.salt)) {
      throw new ApiException('密码错误', ApiErrorCode.PASSWORD_ERR);
    }

    const payload = { username: user.username, sub: user.id };
    const token = await this.jwtService.signAsync(payload);
    this.cacheService.set(token, token, 7200);
    return token;
  }
  getCaptcha() {
    const { id, captcha } = generateCaptcha();
    this.cacheService.set(id, captcha.text, 60);
    console.log(captcha.text);
    return { id, img: captcha.data };
  }
}

  • user.guard.ts
import {
  CanActivate,
  ExecutionContext,
  HttpException,
  HttpStatus,
  Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { CacheService } from 'src/cache/cache.service';
@Injectable()
export class UserGuard implements CanActivate {
  constructor(
    private jwtService: JwtService, // JWT服务,用于验证和解析JWT token
    private configService: ConfigService, // 配置服务,用于获取JWT_SECRET
    private reflector: Reflector,
    private cacheService: CacheService,
  ) {}

  /**
   * 判断请求是否通过身份验证
   * @param context 执行上下文
   * @returns 是否通过身份验证
   */
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
      //即将调用的方法
      context.getHandler(),
      //controller类型
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    const request = context.switchToHttp().getRequest(); // 获取请求对象
    const token = this.extractTokenFromHeader(request); // 从请求头中提取token
    if (!token) {
      throw new HttpException('验证不通过', HttpStatus.FORBIDDEN); // 如果没有token,抛出验证不通过异常
    }
    const realToken = await this.cacheService.get(token);

    try {
      const payload = await this.jwtService.verifyAsync(realToken, {
        secret: this.configService.get('JWT_SECRET'), // 使用JWT_SECRET解析token
      });
      //获取token过期时间
      const { exp } = payload;
      const nowTime = Math.floor(new Date().getTime() / 1000);
      console.log(exp - nowTime);

      const isExpired = exp - nowTime < 3600;
      console.log(isExpired);

      if (isExpired) {
        const newPayLoad = { username: payload.username, sub: payload.sub };
        const newToken = await this.jwtService.sign(newPayLoad);
        this.cacheService.set(token, newToken, 7200);
      }
      request['user'] = payload; // 将解析后的用户信息存储在请求对象中
    } catch {
      throw new HttpException('token验证失败', HttpStatus.FORBIDDEN); // token验证失败,抛出异常
    }

    return true; // 身份验证通过
  }

  /**
   * 从请求头中提取token
   * @param request 请求对象
   * @returns 提取到的token
   */
  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? []; // 从Authorization头中提取token
    return type === 'Bearer' ? token : undefined; // 如果是Bearer类型的token,返回token;否则返回undefined
  }
}

完整代码地址:redis 实现 token 续期

后续将移除JWT,以Redis实现