前言
hello, 我是elk。本文将详细介绍如何在NestJS项目中实现身份验证,包括令牌认证、管道验证、本地认证以及JWT认证。我们还将探讨如何结合Redis来增强令牌验证的安全性。
我们将验证单独作为一个模块服务,里面包含登录、登出、获取验证码等相关验证操作
# 生成auth专属的模块
nest g res auth
一、管道验证
管道验证是NestJS中一种强大的验证机制,它允许我们在请求到达控制器之前对请求参数进行验证。如果参数不符合规则,管道会直接抛出错误,阻止请求继续执行。
插件安装
pnpm install --save class-validator class-transformer
创建验证dto
- create-auth.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import {
IsNotEmpty,
IsString,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
export class CreateAuthDto {
@ApiProperty({ description: '用户名', required: true })
@IsNotEmpty({ message: '用户名不能为空' })
@IsString({ message: '用户名必须为字符串' })
username: string;
@ApiProperty({ description: '密码', required: true })
@IsString({ message: '密码必须为字符串' })
@IsNotEmpty({ message: '密码不能为空' })
@Matches(/^[a-zA-Z0-9]{6,20}$/, {
message: '密码必须为6-20位字母或数字',
})
password: string;
@ApiProperty({ description: '验证码' })
@MinLength(4, { message: '验证码长度不能小于4' })
@MaxLength(4, { message: '验证码长度不能大于4' })
code: string;
}
创建参数验证管道
在src/common/pipes/params-verify.pipe.ts
中,我们创建了一个ParamsVerifyPipe
类,用于验证请求参数:
- src/common/pipes/params-verify.pipe.ts
import {
ArgumentMetadata,
Injectable,
PipeTransform,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class ParamsVerifyPipe implements PipeTransform {
// 参数转换和验证方法
async transform(value: any, metadata: ArgumentMetadata) {
// 验证是否为类
if (!metadata.metatype || !this.toValidate(metadata.metatype)) {
return value;
}
// 将普通对象转换为DTO实例
const DTO = plainToInstance(metadata.metatype, value);
// 验证DTO实例
const errors = await validate(DTO);
// 如果有验证错误,抛出异常
if (errors.length > 0) {
throw new HttpException(this.handleError(errors), HttpStatus.BAD_REQUEST);
}
// 验证通过,返回原始值
return value;
}
// 判断是否需要验证的类型
private toValidate(metatype: Function): boolean {
// 不需要验证的基本类型
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
// 处理验证错误信息
private handleError(errors: any[]) {
// 将错误信息转换为更友好的格式
const messages = errors.map((error) => {
return Object.values({
paramName: error.property, // 参数名称
message: Object.values(error.constraints), // 验证失败信息
});
});
return messages;
}
}
参数验证管道的使用
在auth.controller.ts
中,我们使用ParamsVerifyPipe
来验证登录请求的参数:
- auth.controller.ts
import { ParamsVerifyPipe } from '@/common/pipes/params-verify.pipe';
@Post('login')
@ApiOperation({ summary: '登录', description: '登录接口获取token' })
@ApiBody({ type: CreateAuthDto })
signIn(@Body(new ParamsVerifyPipe()) createAuthDto: CreateAuthDto) {
return this.authService.signIn(createAuthDto);
}
二、本地认证
本地认证是JWT认证的前提,它用于验证用户的用户名和密码是否正确。如果验证成功,用户将获得一个JWT令牌,否则直接抛出无效访问,这样可以做到有效用户才能获取jwt,防止令牌滥用
插件安装
# 主要工具
pnpm install --save @nestjs/passport passport passport-local
# 类型提示
pnpm install --save-dev @types/passport-local
验证用户
在authService
中,我们创建了一个validateUser
方法,用于验证用户的用户名和密码:
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(private readonly usersService: UsersService) {}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.usersService.findOne(username);
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
}
注意:在auth服务中使用user服务中的service方法,需要在authModule中引入userModule『user服务没有全局模块抛出』
本地验证策略
在auth/strategies/local.strategy.ts
中,我们创建了一个LocalStrategy
类,用于本地认证:
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
注册策略
在auth.module.ts
中,我们注册了LocalStrategy
:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
@Module({
imports: [UsersModule, PassportModule],
providers: [AuthService, LocalStrategy],
})
export class AuthModule {}
本地认证守卫
在auth/guards/local.guard.ts
中,我们创建了一个LocalAuthGuard
类,用于本地认证守卫:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
启动本地认证
在auth.controller.ts
中,我们使用LocalAuthGuard
来保护登录接口:
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';
import { ParamsVerifyPipe } from '@/common/pipes/params-verify.pipe';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
// 引入本地验证守卫
import { LocalAuthGuard } from './guards/local.guard';
@Controller('auth')
@ApiTags('鉴权模块')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(LocalAuthGuard) // 使用本地验证守卫
@Post('login')
@ApiOperation({ summary: '登录', description: '登录接口获取token' })
@ApiBody({ type: CreateAuthDto })
signIn(@Body(new ParamsVerifyPipe()) createAuthDto: CreateAuthDto) {
return this.authService.signIn(createAuthDto);
}
}
三、JWT认证
JWT令牌
- 允许用户使用用户名/密码进行身份验证,返回jwt以便后续调用受保护的api接口使用
- 创建受保护的api路由,检查是否存在有效的jwt 进行访问
插件安装
pnpm install --save @nestjs/jwt passport-jwt
# 类型提示
pnpm install -D @types/passport-jwt
-
@nestjs/jwt:jwt操作,生成和验证jwt
-
passport系列:用于jwt认证策略
配置文件JWT
1、环境变量文件新增jwt配置
在.env
文件中,我们添加了JWT相关的配置:
# jwt配置
JWT_SECRET=elk_secret # JWT加密密钥「一般是加密的字符串,不会这么短」
JWT_EXPIRES_IN=30m # 访问令牌有效期
JWT_REFRESH_EXPIRES_IN=7d # 刷新令牌有效期
JWT_COOKIE_SECURE=false # 是否仅在HTTPS下传输cookie
JWT_COOKIE_HTTPONLY=true # 是否禁止JavaScript访问cookie
2、新增jwt专属配置文件
在src/config/jwt.config.ts
中,我们创建了一个JwtConfig
类,用于加载JWT配置:
import { registerAs } from '@nestjs/config';
export interface JwtConfig {
secret: string;
expiresIn: string;
refreshSecret: string;
refreshExpiresIn: string;
cookieSecure: boolean;
cookieHttpOnly: boolean;
}
export default registerAs('jwt', () => ({
secret: process.env['JWT_SECRET'],
expiresIn: process.env['JWT_EXPIRES_IN'],
refreshExpiresIn: process.env['JWT_REFRESH_EXPIRES_IN'],
cookieSecure: process.env['JWT_COOKIE_SECURE'],
cookieHttpOnly: process.env['JWT_COOKIE_HTTPONLY'],
}));
环境变量校验文件新增相关jwt信息
- src/config/schema.config.ts
import * as joi from 'joi';
// 定义配置文件的类型
export interface EnvironmentVariables {
NODE_ENV: 'development' | 'production' | 'staging';
PORT: number;
HOST: string;
DB_TYPE: string;
DB_HOST: string;
DB_PORT: number;
DB_USER: string;
DB_PASSWORD: string;
DB_NAME: string;
any?: any;
}
// 定义配置文件的校验规则
export const configJoiSchema = joi.object({
NODE_ENV: joi.string().valid('development', 'production', 'test').required(),
PORT: joi.number().required().default(3000),
HOST: joi.string().required().default('localhost'),
JWT_SECRET: joi.string().required(),
JWT_EXPIRES_IN: joi.string().required(),
JWT_REFRESH_EXPIRES_IN: joi.string().required(),
JWT_COOKIE_SECURE: joi.boolean().required().default(false),
JWT_COOKIE_HTTPONLY: joi.boolean().required().default(true),
});
# 中间宫格信息为 数据库和redis配置相关信息
JWT认证策略
在auth/strategies/jwt.strategy.ts
中,我们创建了一个JwtStrategy
类,用于JWT认证:
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { JwtConfig } from '@/config/jwt.config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // 不忽略过期时间
secretOrKey: configService.get<JwtConfig>('jwt').secret,
});
}
async validate(payload: any) {
return payload;
}
}
JWT路由守卫
我们上面添加了一个jwt的身份认证策略,但是这个策略还没有执行,我们通过创建一个jwt守卫来调用这个策略
在auth/guards/jwt.guard.ts
中,我们创建了一个JwtGuard
类,用于JWT认证守卫:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {}
后台管理中,一般这个认证守卫除了登录接口,其他接口都需要jwt进行认证,可以将它启动为全局身份验证
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtGuard } from './guards/jwt.guard';
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const { secret, expiresIn } = configService.get('jwt');
return {
global: true,
secret,
signOptions: {
expiresIn,
},
};
},
}),
],
controllers: [AuthController],
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
// 开启全局认证
{
provide: 'APP_GUARD',
useClass: JwtGuard,
},
],
})
export class AuthModule {}
此时Nest会自动将JwtGuard绑定到所有接口上
公共路由装饰器
为了允许某些接口跳过JWT认证,我们在src/common/decorators/jwt.decorator.ts
中创建了一个Public
装饰器:
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
然后,我们修改JwtGuard
类,使其支持跳过认证:
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '@/common/decorators/jwt.decorator';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
/**
* 检查路由是否可以被激活
* @param {ExecutionContext} context - 执行上下文
* @returns {boolean | Promise<boolean> | Observable<boolean>} - 如果路由是公开的或者用户通过验证,则返回true,否则返回false
*/
canActivate(context: ExecutionContext) {
// 检查路由处理程序或控制器类上是否有 @Public 装饰器
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
// 如果路由是公开的,则直接返回true,否则调用父类的 canActivate 方法进行验证
return isPublic || super.canActivate(context);
}
/**
* 处理验证请求的结果
* @param {Error} err - 验证过程中发生的错误
* @param {any} user - 验证通过后的用户信息
* @param {any} info - 验证过程中的额外信息
* @returns {any} - 如果验证通过,则返回用户信息,否则抛出 UnauthorizedException
*/
handleRequest(err, user, info) {
// 如果有错误或者用户信息不存在,则抛出错误或者 UnauthorizedException
if (err || !user) {
throw err || new UnauthorizedException();
}
// 验证通过,返回用户信息
return user;
}
}
结合redis
流程就是登录接口「login」signIn方法中将token存储在redis中,并设置一个过期时间,将token的过期时间交给redis去验证,我们可以在jwt的认证策略中去进行token过去时间验证,如果查询不到则过期,抛出一样,前端得到重新登陆
示例auth.controller.ts | auth.service.ts|jwt.strategy.ts
1、在auth.controller.ts
中,我们使用@Public()
装饰器跳过JWT认证:
// auth.controller.ts
import {
Controller,
Post,
Body,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';
import { ParamsVerifyPipe } from '@/common/pipes/params-verify.pipe';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
// 引入本地验证守卫
import { LocalAuthGuard } from './guards/local.guard';
import { Public } from '@/common/decorators/jwt.decorator';
@Controller('auth')
@ApiTags('鉴权模块')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(LocalAuthGuard) // 使用本地验证守卫
@Public() // 跳过鉴权
@Post('login')
@ApiOperation({ summary: '登录', description: '登录接口获取token' })
@ApiBody({ type: CreateAuthDto })
signIn(@Body(new ParamsVerifyPipe()) createAuthDto: CreateAuthDto) {
return this.authService.signIn(createAuthDto);
}
}
2、在auth.service.ts
中,我们生成令牌并将其存储在Redis中:
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { CreateAuthDto } from './dto/create-auth.dto';
import { UpdateAuthDto } from './dto/update-auth.dto';
import { JwtService } from '@nestjs/jwt';
import { RedisService } from '@/module/common/redis/redis.service'
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly redis: RedisService,
) {}
async validateUser(username: string, pass: string) {
const user = await this.userService.findOne({ username });
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
async signIn(createAuthDto: CreateAuthDto) {
const { username } = createAuthDto;
const user = await this.userService.findOne({ username });
const payload = {
username,
sub: user.userid,
timeOut: new Date().getTime() + 1000 * 60 * 60 * 24 * 7,
};
// 获取token
const token = this.jwtService.sign(payload);
// 缓存token
await this.redis.set(username, token, 1000 * 60 * 60 * 24 * 7);
return {
access_token: token,
};
}
}
3、在jwt.strategy.ts
中,我们从Redis中查询令牌并验证其有效性:
// jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { JwtConfig } from '@/config/jwt.config';
import { RedisService } from '@/module/common/redis/redis.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly redis: RedisService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // 不忽略过期时间
secretOrKey: configService.get<JwtConfig>('jwt').secret,
});
}
async validate(payload: any) {
// redis查询token
const token = await this.redis.get(`${payload.username}&${payload.sub}`);
// 如果token不存在,则抛出异常
if (!token) {
throw new UnauthorizedException('token 已过期');
}
return payload;
}
}
📍 下期预告
《从0搭建NestJS后端服务(八):静态资源访问以及文件上传》
我们将探讨:
🛡️ 文件类型白名单验证
🔐 病毒扫描集成方案
📦 分布式文件存储实践
🌐 CDN加速最佳配置
🤝 互动时间
你在JWT身份验证实践中遇到过哪些“坑”?欢迎留言讨论! 常见问题示例:
- 如何平衡令牌有效期与用户体验?
- JWT令牌在传输过程中如何加密?
- 生物认证(指纹/面部)如何与JWT集成?
欢迎在评论区留下你的实战经验!🚀