八、双token实现用户验证(nestjs+next.js从零开始一步一步创建通用后台管理系统)

214 阅读8分钟

1、概要

系统划分user模块和auth认证模块,user模块只实现数据表的增删改查,不涉及认证的相关业务,auth模块包括注册、登录等接口,接口中使用user提供的新增用户、查询用户服务层接口,两个模块各负其责。

1、用户模块

1.1、新建用户模块

image.png

image.png

1.2、修改用户实体和dto

//user.entity.ts
import { ApiProperty } from "@nestjs/swagger";
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity('user')
export class UserEntity {
  @ApiProperty({
    example: "自动生成",
    description: "用户ID",
  })
  @PrimaryGeneratedColumn({type: 'int'})
  id: number;

  @ApiProperty({
    example: "admin",
    description: "用户名",
  })
  @Column({ type: 'varchar', length: 32, comment: '用户登录账号' })
  username: string;

  @ApiProperty({
    example: "admin123",
    description: "密码",
  })
  @Column({ type: 'varchar', length: 200, nullable: false, comment: '用户登录密码' })
  password: string;

  @ApiProperty({
    example: "sdfsdfsd",
    description: "哈希加密的盐",
  })
  @Column({ type: 'varchar', length: 50, nullable: false, comment: '哈希加密的盐' })
  salt: string;
  
  @ApiProperty({
    example: "0",
    description: "用户类型 0 管理员 1 普通用户",
  })
  @Column({ type: 'int', comment: '用户类型 0 管理员 1 普通用户', default: 1 })
  userType: number;

  @ApiProperty({
    example: "aa@163.com",
    description: "用户邮箱",
  })
  @Column({ type: 'varchar', comment: '用户邮箱', default: ''})
  email: string;

  @ApiProperty({
    example: "0",
    description: "是否冻结用户 0 不冻结 1 冻结",
  })
  @Column({ type: 'int', comment: '是否冻结用户 0 不冻结 1 冻结', default: 0 })
  freezed: number;

  @ApiProperty({
    example: "",
    description: "用户头像(base64编码的图片字符串)",
  })
  @Column({ type: 'varchar', comment: '用户头像', default: ''})
  avatar: string;

  @ApiProperty({
    example: "一些备注信息",
    description: "用户备注",
  })
  @Column({ type: 'varchar', comment: '用户备注', default: ''})
  desc: string;
  
  @ApiProperty({
    description: "创建时间,自动生成",
  })
  @CreateDateColumn({ type: 'timestamp', comment: '创建时间' })
  createTime: Date
}

//create-user.dto.ts
import { IsString, IsNotEmpty, IsEmail, IsNumber } from 'class-validator'
export class CreateUserDto {
  @IsNotEmpty({ message: '账号不能为空' })
  @IsString({ message: '账号必须为string类型'})
  username: string

  @IsNotEmpty({ message: '密码不能为空' })
  @IsString({ message: '密码必须为string类型'})
  password: string

  @IsNotEmpty({ message: '确认密码不能为空' })
  confirmPassword: string

  @IsNotEmpty({ message: '邮箱不能为空' })
  @IsString({ message: '邮箱必须为string类型'})
  @IsEmail()
  email: string

  @IsNotEmpty({ message: '验证码不能为空' })
  code: string

}

//update-user.dto.ts
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator";

export class UpdateUserDto {
  @IsNumber({}, {message: 'id 类型为number'})
  @IsNotEmpty({ message: 'id 不能为空' })
  id: number;

  @IsString({ message: '账号必须为string类型'})
  @IsOptional()
  username?: string

  @IsString({ message: '邮箱必须为string类型'})
  @IsEmail()
  @IsOptional()
  email?: string

  @IsNumber({}, { message: '冻结状态必须为number类型'})
  @IsOptional()
  freezed?: number

  @IsOptional()
  roleIds?: number[]
}

//user.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity';
import { Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(
    private readonly userRepository: Repository<UserEntity>,
  ) {}
  create(createUserDto: CreateUserDto) {
    //一些业务规则
    return this.userRepository.save(createUserDto);
  }

  findAll() {
    //一些业务规则
    return this.userRepository.find();
  }

  findOne(id: number) {
    //一些业务规则
    return this.userRepository.findOneBy({ id });
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    //一些业务规则
    return this.userRepository.update(id,updateUserDto);
  }

  remove(id: number) {
    //一些业务规则
    return this.userRepository.delete({id});
  }
}

具体的业务方法待认证模块用到时再补充

2、认证模块

双 Token(Access Token + Refresh Token)验证逻辑的详细实现方案:

1). 用户登录

-   用户提交凭证(如邮箱+密码)。

-   后端验证通过后生成双 Token:

    -   **Access Token**:短期有效(如 15 分钟),用于 API 请求鉴权。
    -   **Refresh Token**:长期有效(如 7 天),用于刷新 Access Token。

-   后端存储 Refresh Token(如数据库),并通过 **HttpOnly + Secure Cookie** 返回给前端。

-   前端将 Access Token 存储在内存或 `localStorage` 中。

2). API 请求

-   前端在请求头(`Authorization: Bearer <access_token>`)携带 Access Token。

-   后端验证 Access Token 有效性:

    -   有效 → 返回数据。
    -   无效 → 返回 `401 Unauthorized`

3). Access Token 过期处理

-   前端拦截 `401` 错误,发起刷新 Token 请求。

-   后端验证 Refresh Token(从 Cookie 读取):

    -   有效 → 生成新 Access Token 和可选的新 Refresh Token,返回新 Access Token。
    -   无效 → 返回 `401`,前端跳转登录页。

4). 主动登出

-   前端清除本地 Access Token。
-   后端删除或标记 Refresh Token 失效。

代码实现:

2.1、准备工作

2.1.1、环境变量

.env文件中增加生成和验证token的密钥以及超时时间,accessToken和refreshToken两对配置,其中密钥使用一些在线工具随机生成:

//.env
# jwt
JWT_SECRET=jXFECnYwcsc8S06TAgCPrqicJGXr6zIAjCv66dtmPvQ=
JWT_EXPIRES_IN=60s
REFRESH_JWT_SECRET=514oCWLgVJNwC3NicDWPiEnZ/hFcheFHIX/ZDKrGXLA=
REFRESH_JWT_EXPIRES_IN=7d

2.1.2、设置配置类,方便使用

jwt.config.ts:注意选项类型是模块级别的,所以后边生成签名时没有明确使用jwt选项。而refresh.config是签名选项,在生成token时具体写明了使用refreshtokenconfig。

import { registerAs } from '@nestjs/config';
import { JwtModuleOptions } from '@nestjs/jwt';

export default registerAs('jwt',():JwtModuleOptions=>({
    secret:process.env.JWT_SECRET,
    signOptions:{expiresIn:process.env.JWT_EXPIRES_IN},
}))

refresh.config.ts:

import { registerAs } from '@nestjs/config';
import { JwtSignOptions } from '@nestjs/jwt';

export default registerAs("refresh-jwt",():JwtSignOptions=>({
  secret:process.env.REFRESH_JWT_SECRET,
  expiresIn:process.env.REFRESH_JWT_EXPIRES_IN,
}))

2.2、实现登录业务

2.2.1、创建模块:

nest g mo modules/auth
nest g co modules/auth --no-spec
nest g s modules/auth --no-spec

2.2.2、安装依赖

pnpm install --save @nestjs/jwt

2.2.3、auth.service功能实现

import {  Inject, Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigType } from '@nestjs/config';
import refreshConfig from './config/refresh.config';
import jwtConfig from './config/jwt.config';
@Injectable()
export class AuthService {
  constructor(private usersService: UserService,
    private jwtService: JwtService,
    @Inject(jwtConfig.KEY) 
    private jwtTokenConfig: ConfigType<typeof jwtConfig>,
    @Inject(refreshConfig.KEY) 
    private refreshTokenConfig: ConfigType<typeof refreshConfig>,
  ) {}

  async signIn(userId:number,username:string): Promise<any> {
    //登录前未进行用户的验证是因为后期我们会使用守卫来进行验证
    //生成token
    const {accessToken,refreshToken}=await this.generateToken(userId);
    //返回token和用户信息
    return {
      id:userId,
      username,
      accessToken,
      refreshToken
    }
  }

  async generateToken(userId: number) {
    // sub 是 JWT 标准中的一个字段,用于表示用户的唯一标识符。
    const payload = { sub: userId }; 
    //生成双toke,注意生成时第二个参数确定了token的过期时间
    const [ accessToken,refreshToken ] =await Promise.all([
      this.jwtService.signAsync(payload,this.jwtTokenConfig),
      this.jwtService.signAsync(payload,this.refreshTokenConfig)
    ]); 
    // 返回生成的 JWT 令牌作为登录结果的一部分,通常用于后续的请求中进行身份验证和授权。
    return { accessToken,refreshToken }; 
  }
  
}

auth.controller.ts


import { Controller, Post,Request } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post("signin")
  async signin(){
    //因为还没有使用守卫验证用户信息,请求头中不包含user信息,所以此处测试暂时写死
    const req={
      user:{
        id:1,
        name:"admin"
      }
    }
    return await this.authService.signIn(req.user.id,req.user.name)
  }
}

auth.module.ts:注意导入JwtModule,UserEntity,UserService,因为用户服务使用了用户实体类,所以需要引入userEntity。

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { UserService } from '../user/user.service';
import { JwtModule } from '@nestjs/jwt';
import jwtConfig from './config/jwt.config';
import { ConfigModule } from '@nestjs/config';
import refreshConfig from './config/refresh.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';

@Module({
  imports: [
    JwtModule.registerAsync(jwtConfig.asProvider()),
    ConfigModule.forFeature(jwtConfig),
    ConfigModule.forFeature(refreshConfig),
    TypeOrmModule.forFeature([UserEntity])
  ],
  controllers: [AuthController],
  providers: [AuthService,UserService],
})
export class AuthModule {}

测试: 启动服务端程序,在postman中测试:

image.png

2.3、实现刷新token业务

auth.controller.ts增加接口:

  @Post("refresh")
  refreshToken(){
    const req={
      user:{
        id:1,
        username:"admin"
      }
    }
    return this.authService.refreshToken(req.user.id,req.user.username)
  }

auth.service.ts中实现token生成,与登录接口逻辑基本一致

 async refreshToken(userId:number,username:string) {
    const {accessToken,refreshToken}=await this.generateToken(userId);
    return {
      id:userId,
      username,
      accessToken,
      refreshToken
    }
  }

测试:

image.png

2.4、使用守卫实现用户验证

2.4.1、安装依赖

pnpm i passport-local

2.4.2、创建用户验证策略

在/auth下创建strategies目录,创建一个策略文件local.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './../auth.service';
import { Injectable } from '@nestjs/common';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      usernameField: 'username',
      passwordField: 'password',
    });
  }
  async validate(username: string, password: string): Promise<any> {
    return await this.authService.validateUser(username, password);
  }
}

2.4.3、用户验证接口

在auth.service.ts中实现validateUser接口

async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.userService.findOneByName(username);    
    if(!user) throw new UnauthorizedException("用户不存在"); 
    //TODO:下一步优化:使用加密方式验证
    const isPasswordMatch =user.password===pass;
    
    if(!isPasswordMatch) throw new UnauthorizedException("Invalid password");
    return {id:user.id,email:user.email,name:user.username};
  }

2.4.4、模块中引入策略

image.png

2.4.5、测试

strategy实现了用户验证功能,并在验证通过后自动把用户信息写到请求头中,这样我们在接口上实现这个策略的守卫之后,系统就使用了策略中的验证用户方法,不用我们手工在user.service登录接口中手工验证用户。测试方法如下:

image.png

image.png

2.5、实现接口的jwt保护

jwt保护与用户密码验证类似,只是jwt是使用的token验证。 因为刷新token接口验证的refreshToken,与jwt验证基本一致,代码就一块贴出来了。

2.5.1、安装依赖

pnpm i passport-jwt

2.5.2、创建jwt验证策略

auth-jwtPayload.d.ts定义一个payload类型:

@Injectable()
export type AuthJwtPayload={
  sub:number,
}

jwt.strategy.ts

import { Injectable,Inject } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import jwtConfig from "../config/jwt.config";
import { ConfigType } from "@nestjs/config";
import { AuthService } from "../auth.service";
import { AuthJwtPayload } from "../types/auth-jwtPayload";


@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy){
  constructor(
    @Inject(jwtConfig.KEY) 
    private jwtConfiguration:ConfigType<typeof jwtConfig>,
    private authService:AuthService, // 注入 AuthService 服务,用于验证 JWT 负载中的用户信息是否合法。
  ){
    
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),      
      secretOrKey: jwtConfiguration.secret,
      ignoreExpiration: false,
    })
  }

  async validate(payload:AuthJwtPayload): Promise<any> {
    const userId=payload.sub; //sub 是 JWT 标准中的一个字段,用于表示用户的唯一标识符。
    return await this.authService.validateJwtUser(userId); // 验证 JWT 负载中的用户信息是否合法。如果合法,则返回用户信息,否则抛出异常。
    
  }
}

refresh-token.strategy.ts

import { Injectable,Inject } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigType } from "@nestjs/config";
import type { AuthJwtPayload } from "../types/auth-jwtPayload";
import { AuthService } from "../auth.service";
import refreshConfig from "../config/refresh.config";


@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy,'refresh-jwt'){
  
  constructor(
    @Inject(refreshConfig.KEY) 
    private refreshTokenConfig:ConfigType<typeof refreshConfig>,
    private authService:AuthService, // 注入 AuthService 服务,用于验证 JWT 负载中的用户信息是否合法。
  ){
    super({
      jwtFromRequest: ExtractJwt.fromBodyField("refresh"),//.fromAuthHeaderAsBearerToken(),      
      secretOrKey: refreshTokenConfig.secret,
      ignoreExpiration: false,
    })
  }

  async validate(payload:AuthJwtPayload): Promise<any> {
    const userId=payload.sub; //sub 是 JWT 标准中的一个字段,用于表示用户的唯一标识符。
    return await this.authService.validateRefreshToken(userId); // 验证 JWT 负载中的用户信息是否合法。如果合法,则返回用户信息,否则抛出异常。
    
  }
}

2.5.3、jwt验证接口

auth.service.ts中增加下面接口:

 async validateJwtUser(userId: number) {
    const user = await this.userService.findOne(userId);
    if(!user){
      throw new UnauthorizedException("用户不存在");
    }
    const currentUser={id:user.id};
    return currentUser;
  }

  async validateRefreshToken(userId: number) {
    const user = await this.userService.findOne(userId);
    if(!user){
      throw new UnauthorizedException("用户不存在");
    }
    const currentUser={id:user.id};
    return currentUser;
  }

2.5.4、模块中引入策略

image.png

2.5.5、测试

在auth.controller中增加一个测试接口,受jwt保护:

@UseGuards(AuthGuard("jwt"))
  @Get("protected")
  getAll(@Request() req){
    console.log('auth.controller--------getAll', req.user);
    return {
      message:`Now you can access protected API,this is your user id:${req.user.id}`
    }
  }

image.png

image.png

image.png

2.6、其他技巧

2.6.1、正确使用守卫

上面代码中我们在controller接口上使用了路由守卫,在守卫中验证本地策略(用户名+密码)、jwt策略(jwtToken),refresh策略(refreshToken),但是有时候会自定义一些其他的守卫,多个守卫在一个路由接口上是有先后执行顺序的,如下: 写法1:guard是从下往上执行的。

  @UseGuards(AuthGuard("local"))
  @UseGuards(AdminGuard)
  @Post("signin")
  async signin(@Request() req){
   }

假设AdminGuard是一个自定义守卫,用于验证角色,但是因为在AuthGuard("local")之前运行,导致角色验证时request中还不存在user信息。 写法2:从前往后执行

@UseGuards(AdminGuard,AuthGuard("local"))
@Post("signin")
  async signin(@Request() req){
   }

2.6.2、三种jwt策略

我们上边演示的都是路由守卫,jwt守卫也可以加在controller上,也可以配置全局守卫。

image.png

全局守卫有两个地方可以配置,在main.ts中: image.png

在app.module中: 与在main.ts中启用全局守卫相比,此方法可以自动注册守卫中用到的一些其他依赖,如依赖userService,所以更推荐使用这种方式。 image.png

2.6.3、无token接口的处理方法

如果开启了全局守卫或controller守卫,如登录时获取验证码是不需要token验证的,那怎么办呢?可以自定义一个不需要jwt认证的注解,并在jwt守卫中识别这个注解,给这个接口放行。实现如下:

步骤1:创建一个不需要token的注解

// decorators/token.decorator.ts
import { SetMetadata } from '@nestjs/common'

/**
 * 接口允许token访问
 */
export const ALLOW_NO_TOKEN = 'allowNoToken'

export const AllowNoToken = () => SetMetadata(ALLOW_NO_TOKEN, true)

步骤2:创建自定义守卫

//guards/jwt-auth.guard.ts
import { ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ALLOW_NO_TOKEN } from '../decorators/token.decorator';
import { AuthService } from '../auth.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
 constructor(
   private reflector: Reflector,
   private authService: AuthService
 ) {
   super();
 }

 async canActivate(ctx: ExecutionContext): Promise<boolean> {
   // 接口是否允许无 token 访问
   const allowNoToken = this.reflector.getAllAndOverride<boolean>(ALLOW_NO_TOKEN, [ctx.getHandler(), ctx.getClass()])
   if (allowNoToken) return true
   // 验证用户是否登录
   const req = ctx.switchToHttp().getRequest()
   const access_token = req.get('Authorization')
   if (!access_token) throw new HttpException('您还未登录,请先登录后使用', HttpStatus.UNAUTHORIZED)
   const userId = this.authService.verifyToken(access_token) 
   // 判断是否登录过期
   if (!userId) throw new HttpException('登录过期,请重新登录', HttpStatus.UNAUTHORIZED)
   return super.canActivate(ctx) as Promise<boolean>
 }
}

步骤3:auth.service增加token验证 token验证使用jwtService.verify方法:

async verifyToken(token: string): Promise<number> {   
   if (!token) return null;
   try{
     const id =await this.jwtService.verifyAsync(token.replace('Bearer ', ''));
     return id;
   }
   catch(err){
     throw new UnauthorizedException('登录过期,请重新登录');
   }
 }

步骤4:开启全局守卫

//user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';

@Module({
 controllers: [UserController],
 providers: [
   UserService,
   // 应用jwt登录态验证守卫
   {
     provide: APP_GUARD,
     useClass: JwtAuthGuard,
   },
 ],
 imports: [
   TypeOrmModule.forFeature([UserEntity])
 ],
})
export class UserModule {}

步骤5:测试

测试接口如下:

import { Controller, Get, Post, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowNoToken } from './decorators/token.decorator'; 
import { LocalAuthGuard } from './guards/local-auth.guard';
import { RefreshAuthGuard } from './guards/refresh-auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}


  /*
  * 登录@Request() req
  */
  @Post("signin")
  @AllowNoToken()
  @UseGuards(LocalAuthGuard) 
  async signin(@Request() req){   
    return await this.authService.signIn(req.user.id,req.user.username)
  }

  @Get("protected")
  getAll(@Request() req){
    return {
      message:`Now you can access protected API,this is your user id:${req.user.id}`
    }
  }


  @Post("refresh")
  @AllowNoToken()
  @UseGuards(LocalAuthGuard,RefreshAuthGuard)
  refreshToken(@Request() req){
    return this.authService.refreshToken(req.user.id,req.user.username)
  } 

}

1)登录接口测试 登录接口的注解 @AllowNoToken()表示登录接口不验证token,登录成功后才返回token。 注解@UseGuards(LocalAuthGuard) 表示使用用户名和密码到数据库中进行验证,存在该用户则在request中自动增加上user,所以该接口不再需要到数据库中验证,直接生成token。

image.png

2)受保护接口测试 拷贝登录接口返回的accessToken和refreshToken,后边测试使用。

image.png

image.png

3)刷新token接口测试 刷新窗口的注解@AllowNoToken()表示不验证accesstoken,因为此时因为accesstoken已过期,所以才使用refreshtoken进行重新生成token,所以不再验证accesstoken。 注解@UseGuards(LocalAuthGuard,RefreshAuthGuard),第一个注解和登录接口一个意思,验证用户名和密码。第二个注解是验证refreshtoken并返回新的一对token。

image.png