本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。
【在线预览地址】账号:test,密码:d.12345
本章节内容: 1. 登录接口,包含单点登录、jwt 守卫、jwt 身份认证策略、class-validator 参数验证;2. 全局异常处理器,包含统一返回数据格式、错误日志记录;3. 登出接口,使用黑名单机制; 4. token 刷新接口。
1. 创建 User 模块
在项目根目录下打开终端窗口,输入 nest generate res user 命令,回车,选择 REST API,然后选择不生成 CRUD 模版代码,至此,就成功创建了 User 模块(含 module、controller、service 及两个测试文件)。
现在打开 /src/user/user.service.ts 文件,添加 findUser 方法:
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
@Injectable()
export class UserService {
constructor(private readonly prismaService: PrismaService) {}
async findUser(userName: string): Promise<{
id: string;
userName: string;
password: string;
disabled: boolean;
} | null> {
const user = await this.prismaService.user.findUnique({
where: { userName, deleted: false },
select: { id: true, userName: true, password: true, disabled: true },
});
return user;
}
}
该方法里使用了 PrismaService 来访问数据库的 user 表,然后调用了 findUnique 方法并指定了用户名、删除状态与禁用状态,来查询用户表中符合条件的唯一记录,然后通过 select 属性指定查询成功时返回的字段。
提示:如果你无法通过
this.prismaService访问user表,或者表的字段不对等问题,可以运行npx prisma generate命令重新生成prisma client类型。
最后我们需要在 user.module.ts 中导出 UserService 服务,稍后才能在 auth 模块中使用。
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService], // 导出服务
})
export class UserModule {}
单元测试代码请查看代码仓库。
2. 添加用来生成 token 的密钥文件
使用 @nestjs/jwt 库生成 token 时,我们需要设置用来加解密的公钥与私钥,当然也可以直接使用一个字符串来加密 token。为了安全性考虑,这里采用密钥文件来加密 token。
在根目录下创建 key 文件夹,然后打开终端窗口运行以下命令:
# 进入 key 目录
cd ./key
# 生成私钥并指定使用 RSA 算法
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
# 生成公钥
openssl rsa -pubout -in private_key.pem -out public_key.pem
打开 .gitignore 文件,添加 *.pem 配置,让 git 忽略密钥文件,避免上传到远程仓库。
3. 添加 JWT 相关环境变量
打开 .env.development 文件,添加 jwt 相关配置:
# jwt
JWT_ALGORITHM="RS256"
JWT_PUBLIC_KEY_PATH="./key/public_key.pem"
JWT_PRIVATE_KEY_PATH="./key/private_key.pem"
JWT_EXPIRES_IN=1800
JWT_REFRESH_TOKEN_EXPIRES_IN="7d"
打开 /src/common/config/index.ts 文件,在 getBaseConfig 方法的返回值中添加 jwt 配置:
import type { JwtSignOptions } from '@nestjs/jwt';
...
jwt: {
algorithm: configService.get('JWT_ALGORITHM') as JwtSignOptions['algorithm'],
publicKeyPath: configService.get<string>('JWT_PUBLIC_KEY_PATH'),
privateKeyPath: configService.get<string>('JWT_PRIVATE_KEY_PATH'),
expiresIn: +configService.get<number>('JWT_EXPIRES_IN'),
refreshTokenIn: configService.get<string>('JWT_REFRESH_TOKEN_EXPIRES_IN'),
},
4. 登录功能
4.1 注册 JwtModule 模块
首先运行 pnpm add @nestjs/jwt 命令安装库,然后在 auth.module.ts 中导入:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from 'src/user/user.module';
import { JwtModule, type JwtModuleOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { getBaseConfig } from 'src/common/config';
import { join } from 'path';
import { readFileSync } from 'fs';
@Module({
imports: [
UserModule,
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => {
const config = getBaseConfig(configService);
const staticPath = join(__dirname, '../../../');
const privateKey = readFileSync(
join(staticPath, config.jwt.privateKeyPath),
);
const publicKey = readFileSync(
join(staticPath, config.jwt.publicKeyPath),
);
const options: JwtModuleOptions = {
privateKey,
publicKey,
};
return options;
},
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
这里使用了 JwtModule.registerAsync 方法来异步注册 JWT 模块的配置,并使用了 useFactory 工厂函数动态生成配置,还使用了 inject 将 ConfigService 注入到工厂函数中。在这个工厂函数中,我们通过注入的 ConfigService 拿到密钥文件路径,然后从文件系统中读取密钥信息,并将其返回,用于配置 JWT 模块的签名和验证功能。
这里还导入了 UserModule 模块,因为后续要使用 UserService 的 findUser 方法,所以也需要导入用户模块。
4.2 使用 class-validator 验证参数
在此应用中,将使用 class-validator 来验证接口参数,运行以下命令安装相关库:
pnpm add class-validator class-transformer
打开 main.ts 文件,添加以下代码开启自动验证:
...
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
...
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
...
}
将 whitelist 设置为 true 后可以过滤掉接口不接受的额外参数。
现在删除 /src/auth/dto 文件夹下的两个 dto 文件,然后创建一个 auth.dto.ts 文件,并添加以下代码:
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';
export class LoginDto {
@Matches(/^[a-zA-Z][a-zA-Z0-9]{2,10}$/, { message: '用户名格式错误' })
@IsNotEmpty({ message: '用户名不能为空' })
@ApiProperty({ description: '用户名' })
userName: string;
@Matches(/^[a-zA-Z](?=.*[.?!&_])(?=.*\d)[a-zA-Z\d.?!&_]{5,15}$/, {
message: '密码格式错误',
})
@IsNotEmpty({ message: '密码不能为空' })
@ApiProperty({ description: '密码' })
password: string;
@Matches(/^[a-zA-Z0-9]{4}$/, { message: '验证码格式错误' })
@IsNotEmpty({ message: '验证码不能为空' })
@ApiProperty({ description: '验证码' })
captcha: string;
}
以上代码使用了 IsNotEmpty 装饰器来校验参数是否为空,如果为空,则返回设置的错误信息。如果不为空,则通过 Matches 装饰器继续校验参数格式。
@ApiProperty 用来为 Swagger 文档提供接口参数数据的类型说明。
4.3 添加 redis 相关方法
在 redis.service.ts 中添加以下方法:
generateSSOKey(userId: string) {
return `sso: ${userId}`;
}
setSSO(key: string, value: string, expiresIn?: number) {
if (expiresIn === undefined) {
expiresIn = getBaseConfig(this.configService).jwt.expiresIn;
}
return this.redis.set(key, value, 'EX', expiresIn);
}
getSSO(key: string) {
return this.redis.get(key);
}
delSSO(key: string) {
return this.redis.del(key);
}
generateSignInErrorsKey(ip: string, userAgent: string) {
const data = `${ip}:${userAgent}`;
const key = createHash('sha256').update(data).digest('hex');
return `sign-in:errors: ${key}`;
}
setSignInErrors(key: string, value: number) {
const expiresIn = getBaseConfig(this.configService).signInErrorExpireIn;
return this.redis.set(key, value, 'EX', expiresIn);
}
async getSignInErrors(key: string) {
const result = (await this.redis.get(key)) || 0;
return +result;
}
delSignInErrors(key: string) {
return this.redis.del(key);
}
在每次登录时,调用 setSSO 方法将生成的访问 token 保存到 redis 中,以实现单点登录功能(还需在 jwt 策略中校验,后面会讲到)。
setSignInErrors 方法用来将用户登录错误的次数记录到 redis 中,登录时可以校验错误次数,如果错误次数过多则可以拒绝用户登录。
在 env 文件中添加登录错误次数相关变量:
SIGN_IN_ERROR_LIMIT=3
SIGN_IN_ERROR_EXPIRES_IN=300
在 /src/common/config/index 中添加以下变量:
signInErrorLimit: +configService.get<number>('SIGN_IN_ERROR_LIMIT', 5),
signInErrorExpireIn: +configService.get<number>(
'SIGN_IN_ERROR_EXPIRES_IN',
60 * 5,
),
4.4 添加登录方法
首先在 /src/common 下新建一个 types 文件夹,然后添加以下文件和内容:
// src/common/types/index.d.ts
export interface IPayload {
userName: string;
userId: string;
}
后面我们将会使用这两个信息来生成访问凭证。
打开 auth.service.ts,添加以下代码:
...
import { BadRequestException } from '@nestjs/common';
import { RedisService } from 'src/redis/redis.service';
import { LoginDto } from './dto/auth.dto';
import { UserService } from 'src/user/user.service';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';
import type { IPayload } from 'src/common/types';
@Injectable()
export class AuthService {
constructor(
private readonly redisService: RedisService,
private readonly configService: ConfigService,
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
async validateCaptcha(ip: string, userAgent: string, captcha: string) {
const key = this.redisService.generateCaptchaKey(ip, userAgent);
const value = await this.redisService.getCaptcha(key);
if (value && value.toLowerCase() === captcha.toLowerCase()) {
this.redisService.delCaptcha(key);
return true;
}
return false;
}
validateUser(
userName: string,
password: string,
isValidatePwd?: true,
): Promise<false | IPayload>;
validateUser(
userName: string,
password: undefined,
isValidatePwd: false,
): Promise<false | IPayload>;
async validateUser(
userName: string,
password: string,
isValidatePwd?: boolean,
): Promise<false | IPayload> {
const user = await this.userService.findUser(userName);
if (!user || user.disabled) {
return false;
}
if (isValidatePwd === false) {
return { userName: user.userName, userId: user.id };
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return false;
}
return { userName: user.userName, userId: user.id };
}
generateTokens(payload: IPayload) {
const config = getBaseConfig(this.configService);
const algorithm = config.jwt.algorithm;
const accessToken = this.jwtService.sign(payload, {
algorithm,
expiresIn: config.jwt.expiresIn,
});
const refreshToken = this.jwtService.sign(payload, {
algorithm,
expiresIn: config.jwt.refreshTokenIn,
});
return { accessToken, refreshToken };
}
async login(data: LoginDto, ip: string, userAgent: string) {
const signInErrorsKey = this.redisService.generateSignInErrorsKey(
ip,
userAgent,
);
const signInErrors =
await this.redisService.getSignInErrors(signInErrorsKey);
if (signInErrors >= getBaseConfig(this.configService).signInErrorLimit) {
const expiresIn =
getBaseConfig(this.configService).signInErrorExpireIn / 60;
throw new BadRequestException(
`验证码/用户名/密码错误次数过多,请${expiresIn}分钟后再试`,
);
}
const isCaptchaValid = await this.validateCaptcha(
ip,
userAgent,
data.captcha,
);
if (!isCaptchaValid) {
this.redisService.setSignInErrors(signInErrorsKey, signInErrors + 1);
throw new BadRequestException('验证码错误');
}
const payload = await this.validateUser(data.userName, data.password);
if (!payload) {
this.redisService.setSignInErrors(signInErrorsKey, signInErrors + 1);
throw new BadRequestException('用户名或密码错误,或账号已被禁用');
}
const tokenObj = this.generateTokens(payload);
this.redisService.setSSO(
this.redisService.generateSSOKey(payload.userId),
tokenObj.accessToken,
);
return tokenObj;
}
}
validateCaptcha 这个方法用来比对 redis 中的验证码与用户登录时传过来的验证码是否一致。
validateUser 方法用来验证用户登录信息,这里我们使用了函数重载,这是为了后续在 JwtStrategy 中验证用户时,可以不用传入密码。
提示:因为保存到数据库中的用户密码是使用
bcrypt库加密后的密文,所以也只能使用bcrypt库的compare方法去比较。
generateTokens 方法用来生成访问凭证与刷新凭证,后续在刷新 token 方法中也会用到。
在 login 方法中,我们首先校验用户登录错误次数是否超过了限定的值,如果超过了,则直接返回错误,让用户一段时间后再试。然后校验数据库中是否存在该用户,存在,则生成两个 token 返回,并将 accessToken 保存到 redis 中。
为什么要生成两个 token ?
双token:服务端返回两个token,一个有效期短(30分钟过期)的 accessToken 用来认证用户身份,一个有效期长(7天)的 refreshToken 用来刷新token。
优点:1. 用户只需要7天内登录过一次,即可刷新登录状态,不用频繁重新登录;2. accessToken 有效期短,被盗损失小,而 refreshToken 只在第一次获取和刷新 accessToken 时才在网络中传输,被盗风险小,万一被盗,刷新 accessToken 也需要同时提供两个 token。
4.5 添加登录接口
打开 auth.entity.ts 文件,添加以下实体类:
export class LoginEntity {
@ApiProperty({ description: '访问令牌' })
accessToken: string;
@ApiProperty({ description: '刷新令牌' })
refreshToken: string;
}
将用来为 Swagger 文档提供登录接口响应数据的类型说明。
打开 auth.controller.ts 文件,添加登录接口:
...
import { ... , Body, Post } from '@nestjs/common';
import { AuthEntity, LoginEntity } from './entities/auth.entity';
import { LoginDto } from './dto/auth.dto';
import { BaseResponseEntity } from 'src/common/entities/base-response.entity';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
...
@ApiOperation({
summary: '登录',
})
@ApiBaseResponse(LoginEntity)
@Post('login')
login(
@Body() data: LoginDto,
@Ip() ip: string,
@Headers('user-agent') userAgent: string,
): Promise<LoginEntity> {
return this.authService.login(data, ip, userAgent);
}
}
@ApiOperation() 装饰器用来为 Swagger 文档的登录接口添加说明。
@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来:
- 定义 API 端点的成功响应;
- 为 Swagger 文档提供该接口响应数据的类型说明。
@Post() 装饰器用来声明一个 POST 类型的接口。@Body() 装饰器用来获取接口请求体中的参数数据,这里还指定使用 LoginDto 类对获取的参数进行格式校验。
现在打开 Swagger API 文档可以看到登录接口了。
我们点击 Try it out 按钮发送一个请求试试
发送请求后,可以看到接口返回了错误信息,因为我们参数传了空字符串。
但我们会发现,它返回的 message 错误信息是一个数组,而不是一个字符串,格式与成功响应时不一致,那我们要如何解决呢?
5. 全局异常过滤器
虽然 NestJS 内置的基础异常过滤器可以处理很多情况,但是有时候我们还是希望能够在异常时能记录一些日志信息,或在出现异常时返回自定义的响应内容。
让我们来添加一个捕获一切异常的过滤器吧。首先创建 /src/common/filter/all-exception.filter.ts 文件,然后添加以下代码:
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { Inject } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BaseResponseEntity } from '../entities/base-response.entity';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {}
catch(exception: unknown, host: ArgumentsHost): void {
let httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
httpStatus = exception.getStatus();
const res = exception.getResponse() as { message: string[] };
message = res?.message?.join ? res?.message[0] : exception.message;
this.handleHttpException(exception, httpStatus);
} else if (
exception instanceof Prisma.PrismaClientKnownRequestError ||
exception instanceof Prisma.PrismaClientUnknownRequestError
) {
message = exception.message;
this.handlePrismaException(exception);
} else if (exception instanceof Error) {
this.handleGenericError(exception);
} else {
this.handleUnknownError(exception);
}
const responseBody: BaseResponseEntity = {
statusCode: httpStatus,
data: null,
message: message,
};
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
private handleHttpException(exception: HttpException, status: number): void {
this.logger.error(`HTTP Exception: ${exception.message}`, {
status,
stack: exception.stack,
});
}
private handlePrismaException(
exception:
| Prisma.PrismaClientKnownRequestError
| Prisma.PrismaClientUnknownRequestError,
): void {
if (exception instanceof Prisma.PrismaClientUnknownRequestError) {
this.logger.error(`Prisma Unknown Exception: ${exception.message}`, {
stack: exception.stack,
});
return;
}
this.logger.error(`Prisma Exception: ${exception.message}`, {
code: exception.code,
meta: exception.meta,
stack: exception.stack,
});
}
private handleGenericError(exception: Error): void {
this.logger.error(`Unhandled Exception: ${exception.message}`, {
stack: exception.stack,
});
}
private handleUnknownError(exception: unknown): void {
this.logger.error('Unknown error', { exception });
}
}
在这个异常过滤器中使用了 httpAdapter.reply 方法来修改响应内容,并分别处理了 Http 异常、 prisma 异常、 已知异常、未知异常。
接下来我们需要将这个异常过滤器注册为全局异常过滤器,在 main.ts 中添加以下代码:
...
import { AllExceptionsFilter } from './common/filter/all-exception.filter';
async function bootstrap() {
...
const logger = app.get(WINSTON_MODULE_NEST_PROVIDER);
app.useLogger(logger);
// 主要增加部分
app.useGlobalFilters(
new AllExceptionsFilter(app.get(HttpAdapterHost), logger),
);
...
}
我们注册了一个全局过滤器,并向这个过滤器传递了两个参数,一个 HttpAdapterHost 修改响应内容时需要用到,一个 logger 记录日志时需要使用。
现在我们再打开 Swagger API 在线文档,发送一个参数为空字符串的请求,可以发现 message 错误信息只是一个字符串了,而不是像之前一样返回了数组了。
6. 身份验证
在本文中将使用 passport 、 @nestjs/passport 与 passport-jwt 来实现身份验证策略。
passport 拥有丰富的 strategies 生态系统,实现了各种身份验证机制。这里我们使用 passport-jwt 来实现特定的身份验证策略。
@nestjs/passport 可以通过请求头的 token 来验证用户,并将通过身份验证的用户的信息附加到 Request 对象,以便在路由处理程序中进一步使用。
运行以下命令安装相关库:
pnpm add passport passport-jwt @nestjs/passport
6.1 添加身份认证策略
在 /src/auth 文件夹下新建一个 jwt.strategy.ts 文件并添加以下内容:
import { ExtractJwt, Strategy, StrategyOptionsWithRequest } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from 'src/redis/redis.service';
import { AuthService } from './auth.service';
import { IPayload } from 'src/common/types';
import { join } from 'path';
import { readFileSync } from 'fs';
import { getBaseConfig } from 'src/common/config';
import { Request } from 'express';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService,
private readonly authService: AuthService,
) {
const staticPath = join(__dirname, '../../../');
const publicKeyPath = getBaseConfig(configService).jwt.publicKeyPath;
const publicKey = readFileSync(join(staticPath, publicKeyPath));
const options: StrategyOptionsWithRequest = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: publicKey,
passReqToCallback: true,
};
super(options);
}
async validate(req: Request, payload: IPayload): Promise<IPayload> {
const token = req.headers.authorization?.split(' ')[1];
const validToken = await this.redisService.getSSO(
this.redisService.generateSSOKey(payload.userId),
);
if (validToken !== token) {
throw new UnauthorizedException('账号已在其他地方登录');
}
const isValidUser = await this.authService.validateUser(
payload.userName,
undefined,
false,
);
if (!isValidUser) {
throw new UnauthorizedException('用户不存在或账号已被禁用');
}
return payload;
}
}
ExtractJwt.fromAuthHeaderAsBearerToken 这是一个用于从 HTTP 请求头中提取 JWT Token 的方法,它会查找请求头中的 Authorization 字段并提取“Bearer”后面的 token 字符串。
ignoreExpiration 设置为 false 表示不忽略过期时间,即 token 过期直接返回错误。
secretOrKey 设置为使用公钥验证 token。
passReqToCallback 这个配置是用来决定是否将原始请求对象传递给 validate 回调函数。如果设置为 false ,则 validate 函数只能接受到 payload 数据。
在 validate 方法中,首先我们校验了单点登录,如果用户在其他地方登录了,在登录方法中会生成新 token 并保存到 redis 中,那我们通过比对 redis 中的 token 是否与请求头中的一致即可知道是否在其他地方登录了。然后,校验了用户是否有效,以防账号被禁用或删除后,还能进行操作。
现在打开 auth.module.ts 文件添加以下代码:
...
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
...
PassportModule,
],
...
providers: [..., JwtStrategy],
})
export class AuthModule {}
这里我们导入了 PassportModule 与 JwtStrategy,并添加到对应位置。
6.2 添加 jwt 守卫
上面我们添加了 jwt 身份认证策略,但是现在这个策略还不会执行,我们还需要创建一个守卫来调用这个策略。
首先创建 /src/common/decorator/public.decorator.ts 文件并添加以下代码:
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
这个装饰器将用来标注接口是否需要携带 token ,如果接口上添加了这个装饰器,则不进行身份认证。
现在创建 /src/common/guard/jwt-auth.guard.ts 文件并添加以下代码:
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorator/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
我们创建了一个 jwt 守卫,它继承于 AuthGuard 守卫,并指定使用“jwt”策略(前面我们创建的策略的默认名称即为“jwt”)。这个守卫将在用户访问需要携带 token 信息的接口时,执行 jwt 身份认证策略代码。
提示:如果你想自定义策略名称,可以这样设置
export class JwtStrategy extends PassportStrategy(Strategy, 'name')
因为我们只有登录与获取验证码接口不需要携带 token 信息,因此我们可以将这个 JwtAuthGuard 守卫注册为全局守卫。
打开 app.module.ts 添加以下代码:
...
import { JwtAuthGuard } from './common/guard/jwt-auth.guard';
@Module({
...
providers: [
...
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
现在打开 API 在线文档,发送一个获取验证码接口请求,会发现返回了 401 错误。这是因为我们没有给这个接口标注不校验 token 。
在接口上添加以下代码:
...
import { Public } from 'src/common/decorator/public.decorator';
@ApiOperation({
summary: '获取验证码',
})
@ApiBaseResponse(AuthEntity)
@Public() // 标注不需要身份认证
@Get('captcha')
getCaptcha(
@Ip() ip: string,
@Headers('user-agent') userAgent: string,
): AuthEntity {
return this.authService.generateCaptcha(ip, userAgent);
}
在接口上添加 @Public() 装饰器后,再发送一次请求,会发现正常返回验证码数据了。也需要在登录接口上加上这个装饰器哦,不然将无法登录。
7. 登出
要实现用户登出,仅需将 token 标记为失效,然后在 jwt 守卫中验证 token 是否有效。
首先,可以将已失效的 token 保存到 redis 中,在 redis.service.ts 中添加以下方法:
generateBlackListKey(token: string) {
return `blacklist: ${token}`;
}
setBlackList(token: string) {
const key = this.generateBlackListKey(token);
const expiresIn = getBaseConfig(this.configService).jwt.expiresIn;
this.redis.set(key, 'logout', 'EX', expiresIn);
}
async isBlackListed(token: string) {
const key = this.generateBlackListKey(token);
const has = await this.redis.exists(key);
return !!has;
}
我们将已失效的 token 作为键,‘logout’ 作为值保存到 redis 中。isBlackListed 方法通过判断键是否存在,来判断 token 是否已失效。
然后,在 jwt.strategy.ts 中添加验证代码:
async validate(req: Request, payload: IPayload): Promise<IPayload> {
const token = req.headers.authorization?.split(' ')[1];
const isBlackListed = await this.redisService.isBlackListed(token);
if (isBlackListed) {
throw new UnauthorizedException('请重新登录');
}
...
}
最后,在 auth.service.ts 中添加登出方法:
async logout(accessToken: string, userId: string) {
this.redisService.setBlackList(accessToken);
this.redisService.delSSO(this.redisService.generateSSOKey(userId));
}
这里还移除用来实现单点登录而在 redis 保存的信息。
现在,打开 auth.controller.ts 添加登出接口:
@ApiOperation({
summary: '用户登出',
})
@ApiBearerAuth()
@ApiBaseResponse()
@Post('logout')
logout(
@Headers('authorization') token: string,
@Req() req: { user: IPayload },
) {
return this.authService.logout(token.split(' ')[1], req.user.userId);
}
ApiBearerAuth 从 @nestjs/swagger 中导入,用来在 Swagger 文档中标记这个接口需要认证信息。IPayload 从 src/common/types 导入。
Req 从 @nestjs/common 中导入,这里可以拿到 user 信息,是因为我们在 JwtStrategy 中继承了 PassportStrategy,这个策略会将通过验证的 token 解密并将解密后的信息挂载到 req 上。
运行项目后,打开在线 API 文档,可以看到有了登出接口了。
8. 刷新 token
打开 auth.service.ts 文件,添加刷新方法:
async refreshToken(accessToken: string, refreshToken: string) {
const isBlackListed = await this.redisService.isBlackListed(accessToken);
if (isBlackListed) {
throw new UnauthorizedException('请重新登录');
}
let payload: IPayload;
try {
payload = this.jwtService.verify<IPayload>(refreshToken);
} catch {
throw new UnauthorizedException('请重新登录');
}
const validToken = await this.redisService.getSSO(
this.redisService.generateSSOKey(payload.userId),
);
if (accessToken !== validToken) {
throw new UnauthorizedException('该账号已在其他地方登录,请重新登录');
}
const validUser = await this.validateUser(
payload.userName,
undefined,
false,
);
if (validUser === false) {
throw new UnauthorizedException('用户不存在或账号已被禁用');
}
const tokenObj = this.generateTokens(payload);
this.redisService.setSSO(
this.redisService.generateSSOKey(payload.userId),
tokenObj.accessToken,
);
return tokenObj;
}
在该方法中,我们首先检查了访问凭证 token 是否在黑名单中,如果在,则代表该账号已登出。然后,验证了 refreshToken 是否有效,如果有效,则验证用户是否已在其他地方登录。通过以上验证后,重新生成两个 token 返回。
打开 auth.dto.ts 文件,添加以下代码:
export class RefreshTokenDto {
@IsString({ message: 'refreshToken 必须是字符串' })
@IsNotEmpty({ message: 'refreshToken 不能为空' })
@ApiProperty({ description: '刷新令牌' })
refreshToken: string;
}
该 dto 将用来验证接口参数格式是否正确并为 swagger 文档提供参数说明。
现在,打开 auth.controller.ts 文件,添加刷新接口:
@ApiOperation({ summary: '刷新 token' })
@ApiBearerAuth()
@ApiBaseResponse(LoginEntity)
@Public()
@Post('refresh-token')
refreshToken(
@Headers('authorization') token: string,
@Body() data: RefreshTokenDto,
): Promise<LoginEntity> {
if (!token) {
throw new BadRequestException('请求头中必须包含 authorization 属性');
}
return this.authService.refreshToken(
token.split(' ')[1],
data.refreshToken,
);
}
为什么使用 @Public() 将刷新 token 接口标记为公开接口?因为访问该接口时,token 已过期。
正因为我们将它设为了公开接口,所以它不会经过 JwtStrategy 的验证,所以我们在刷新方法里做了大量校验。
下一章节见~