Nest 身份验证
Nest 身份验证
身份验证是大多数应用程序中的重要部分。处理身份验证有许多不同的方法和策略。
一个应用程序可能会有非常多种的注册方式,比如本地账号注册方式、使用社交账号注册等,每一种账号注册方式都有一套自己的策略 (Strategy),那要怎么管理各种账户验证 (Authentication) 的策略就非常重要,我们希望各种策略都能采用同一套标准来进行开发,这时候就可以通过一些工具来辅助我们处理,Nest 使用了 passport 这个库来处理身份验证。
passport 介绍
passport 采用了策略模式来管理各种验证方式,它主要由两个部分构成整个账户验证程序,分别为是 passport 与 passport strategy。passport 本身是用来处理验证流程的,而 passport strategy 则是验证机制,两者缺一不可,整个 passport 生态系有上百种的验证机制让开发人员使用,如本地验证、社交账号验证等,完美解决了各种验证机制的处理。
在 Nest 中,passport strategy 会与守卫进行搭配,通过 AuthGuard 将 strategy 包装起来,就可以通过 Nest 的守卫机制来与 passport 进行搭配。
安装 passport
npm install @nestjs/passport passport实现注册
在开始身份验证之前,需要先设计一个注册的 API。
连接数据库
使用 Prisma 连接 MySQL 数据库,用于将用户数据存储到数据库中。
其中 user 数据模型如下。
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
password String
}创建 Auth 模块
使用如下命令创建 AuthModule、AuthService 和 AuthController。
nest generate module auth
nest generate service auth
nest generate controller auth修改 auth.module.ts 文件,引入 PrismaService 来操作数据库。
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PrismaService } from "src/prisma/prisma.service";
@Module({
controllers: [AuthController],
providers: [AuthService, PrismaService],
})
export class AuthModule {}安装 class-validator 和 class-transformer 来实现对用户注册信息进行验证。
npm install class-validator class-transformer设计一个 DTO 来对用户注册信息进行验证。在 src/auth/dto 目录下创建 register.dto.ts 文件。
import { IsNotEmpty, MaxLength, MinLength } from "class-validator";
export class RegisterDto {
@MinLength(6)
@MaxLength(16)
public readonly username: string;
@IsNotEmpty()
public readonly email: string;
@MinLength(8)
@MaxLength(20)
public readonly password: string;
}然后在 auth.service.ts 文件中实现注册方法。对密码进行哈希加密,可以防止数据库泄露后密码被破解。
这里使用 Argon2 库来实现。
npm i argon2import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { RegisterDto } from "./dto/register.dto";
import * as argon2 from "argon2";
@Injectable()
export class AuthService {
constructor(private readonly prisma: PrismaService) {}
async register(registerObj: RegisterDto) {
const { username, email } = registerObj;
const password = await argon2.hash(registerObj.password);
return this.prisma.user.create({
data: {
username,
email,
password,
},
});
}
}在 auth.controller.ts 文件中实现注册方法。
import { Controller, Post, Body } from "@nestjs/common";
import { RegisterDto } from "./dto/register.dto";
import { AuthService } from "./auth.service";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("register")
register(@Body() registerObj: RegisterDto) {
return this.authService.register(registerObj);
}
}实现本地验证
本地验证是使用用户名和密码进行验证,通常会使用 passport-local 这个策略与 passport 进行搭配。安装 passport-local 策略。
npm install passport-local
npm install @types/passport-local -D实现策略
在 AuthService 中实现一个 validate 方法,来验证用户名和密码,如果验证成功,则返回用户信息,否则返回 null。
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { RegisterDto } from "./dto/register.dto";
import * as argon2 from "argon2";
@Injectable()
export class AuthService {
constructor(private readonly prisma: PrismaService) {}
async register(registerObj: RegisterDto) {
const { username, email } = registerObj;
const password = await argon2.hash(registerObj.password);
return this.prisma.user.create({
data: {
username,
email,
password,
},
});
}
async validate(username: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
username,
},
});
if (!user) {
return null;
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
return null;
}
return user;
}
}完成了验证方法后,需要将它与 passport 搭配,我们需要建立一个提供者作为 strategy,通过该提供者与 passport 进行搭配。
在 src/auth 下面创建一个 strategy 文件夹并创建 local.strategy.ts,在这个文件中实现一个 LocalStrategy 的 class,需要特别注意的是这个 class 要继承 passport-local 的 strategy。但是需要通过 Nest 的函数连接,并实现 validate(username: string, password: string) 方法,这个方法就是 passport 流程的入口,在这里我们调用 AuthService 的 validate 方法进行验证。
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { AuthService } from "../auth.service";
import { Strategy } from "passport-local";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(username: string, password: string) {
const user = await this.authService.validate(username, password);
if (!user) {
throw new UnauthorizedException();
}
return { username: user.username, email: user.email };
}
}还需要在 auth.module.ts 文件中注册 LocalStrategy。
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LocalStrategy } from "./strategy/local.strategy";
@Module({
controllers: [AuthController],
providers: [AuthService, PrismaService, LocalStrategy],
})
export class AuthModule {}实现守卫
实现完 strategy 以后,就要实现一个 API 来处理登录验证,我们在 AuthController 中添加一个 login 方法并套用 AuthGuard,因为我们是使用 passport-local 这个 strategy,所以要在 AuthGuard 带入 local 这个字符串,passport 会自动与本地策略进行搭配,然后 passport 会将 LocalStrategy 的 validate 方法进行调用,并传入 username 和 password。
import { Controller, Post, Body, UseGuards, Req } from "@nestjs/common";
import { RegisterDto } from "./dto/register.dto";
import { AuthService } from "./auth.service";
import { AuthGuard } from "@nestjs/passport";
import { Request } from "express";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("register")
register(@Body() registerObj: RegisterDto) {
return this.authService.register(registerObj);
}
@UseGuards(AuthGuard("local"))
@Post("login")
login(@Req() req: Request) {
return req.user;
}
}这样我们就实现了本地验证的登录功能。
Token 验证
我们已经处理好注册与登录的部分,但一个完整的账户机制还需要包含登录后的身份识别,要实现这样的识别功能有很多种做法,Token 是其中一个被广泛运用的方案。
Token 就是一个用来表示身份的媒介,当使用者成功登录时,系统会产生出一个独一无二的 Token,并将该 Token 返回给使用者,只要在 Token 有效期间内,该使用者在请求中带上该 Token,系统便会识别出此操作的使用者是谁。
目前最常见的 Token 生成方式是使用 JWT。JWT 是一种较新的 Token 设计方法,它最大的特点是可以在 Token 中含有使用者信息,不过仅限于较不敏感的内容,比如:使用者名称、性别等,原因是 JWT 是用 Base64 进行编码,使用者信息可以透过 Base64 进行还原,使用上需要特别注意。
安装 JWT
安装依赖。
npm install @nestjs/jwt passport-jwt
npm install @types/passport-jwt -D生成 JWT
首先在 .env 文件中添加 JWT_SECRET,用于存储 JWT 的密钥。
JWT_SECRET=your-secret-key引入 @nestjs/config,用于读取 .env 文件中的配置。
npm install @nestjs/config --save然后修改 app.module.ts 文件,引入 ConfigModule,并使其全局可用。
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
AuthModule,
ConfigModule.forRoot({
isGlobal: true,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}完成密钥配置后,在处理验证的 AuthModule 中导入 JwtModule,并使用 registerAsync 方法来配置 JWT。
import { Module } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { LocalStrategy } from "./strategy/local.strategy";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get("JWT_SECRET");
return {
secret,
signOptions: {
expiresIn: "1d",
},
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, PrismaService, LocalStrategy],
})
export class AuthModule {}上面我们是让用户登录后获得用户的信息,这里我们将会把这个机制换成回传 JWT,让用户可以顺利拿到它来使用授权的功能,所以我们要在 AuthService 中设计一个 generateJwt 方法来调用 JwtService 的 sign 方法产生 JWT,该方法需要传入要放在 “内容” 部分的数据,这里我们就放入用户的 id 和 username。
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { RegisterDto } from "./dto/register.dto";
import * as argon2 from "argon2";
import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client";
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService
) {}
async register(registerObj: RegisterDto) {
const { username, email } = registerObj;
const password = await argon2.hash(registerObj.password);
return this.prisma.user.create({
data: {
username,
email,
password,
},
});
}
async validate(username: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
username,
},
});
if (!user) {
return null;
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
return null;
}
return user;
}
generateJwt(user: User) {
const payload = { username: user.username, sub: user.id };
return {
access_token: this.jwtService.sign(payload),
};
}
}上面我们在 LocalStrategy 中只返回了 username 和 email,现在我们要修改一下,返回整个用户的信息。
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { AuthService } from "../auth.service";
import { Strategy } from "passport-local";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(username: string, password: string) {
const user = await this.authService.validate(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}最后在 AuthController 中,我们需要修改一下 login 方法,返回 token。
import { Controller, Post, Body, UseGuards, Req } from "@nestjs/common";
import { RegisterDto } from "./dto/register.dto";
import { AuthService } from "./auth.service";
import { AuthGuard } from "@nestjs/passport";
import { Request } from "express";
import { User } from "@prisma/client";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("register")
register(@Body() registerObj: RegisterDto) {
return this.authService.register(registerObj);
}
@UseGuards(AuthGuard("local"))
@Post("login")
login(@Req() req: Request) {
return this.authService.generateJwt(req.user as User);
}
}此时,我们登录后,会返回一个 token。
验证 JWT
接下来我们需要实现 JwtStrategy 与 passport 进行配合,跟 LocalStrategy 的实现方式差不多,必须继承 passport-jwt 的 strategy,不同的地方在于 super 传入的参数。在 src/auth/strategy 下创建一个 jwt.strategy.ts 文件。
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get("JWT_SECRET")!,
});
}
validate(payload: any) {
return {
id: payload.sub,
username: payload.username,
};
}
}然后在 AuthModule 中引入 JwtStrategy。
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LocalStrategy } from "./strategy/local.strategy";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get("JWT_SECRET");
return {
secret,
signOptions: {
expiresIn: "1d",
},
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, PrismaService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}保护路由
使用 @UseGuards(AuthGuard('jwt')) 装饰器来保护需要验证的路由。
全局启用守卫
在任何模块中使用如下结构可以将 JwtAuthGuard 注册为全局守卫。
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
];但是我们必须提供一种机制来声明某些路由是公开的。我们可以通过创建一个自定义装饰器来实现这一点。
在 src/auth/decorator 下创建一个 auth.decorator.ts 文件。
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);在 src/auth/guard 下创建一个 jwt-auth.guard.ts 文件,来扩展 AuthGuard。
import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "../decorator/auth.decorator";
import { Reflector } from "@nestjs/core";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private 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);
}
}此时我们可以在任何路由上使用 @Public() 装饰器来声明该路由是公开的。
