前面有提到这次实作的系统共有两大资源,分别是:使用者 (user) 与 待办事项 (todo),事实上,以 API 的角度来看会多一个资源,那就是登入与注册的 身分验证 (Authentication)。
运用 Authentication 技巧来实作身分验证。
提醒:Authentication 的技巧可以参考 DAY23 - Authentication (上) 与 DAY24 - Authentication (下)。
我们可以先把本地策略与 JWT 所用到的 Guard 进行包装,在 src/core/guards
下新增 jwt-auth.guard.ts
与 local-auth.guard.ts
:
$ nest generate guard core/guards/jwt-auth
$ nest generate guard core/guards/local-auth
调整 JwtAuthGuard
的内容:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
调整 LocalAuthGuard
的内容:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
建立 index.ts
做汇出管理:
export { JwtAuthGuard } from './jwt-auth.guard';
export { LocalAuthGuard } from './local-auth.guard';
提醒:Guard 的功能可以参考 DAY13 - Guard。
我们需要建立 UserModule
与 UserService
来提供我们取得使用者资讯的操作:
$ nest generate module features/user
$ nest generate service features/user
建立完成後,先不急着更动它们,回想一下我们前面在处理使用者密码的时候,特别设计了一个方法来达成加密,我们先将其建立起来,在 src/core/utils
资料夹下 common.utility.ts
:
import { randomBytes, pbkdf2Sync } from 'crypto';
export class CommonUtility {
public static encryptBySalt(
input: string,
salt = randomBytes(16).toString('hex'),
) {
const hash = pbkdf2Sync(input, salt, 1000, 64, 'sha256').toString('hex');
return { hash, salt };
}
}
调整 UserModule
的内容,将 UserService
汇出并引入 MongooseModule
来建立 UserModel
,进而操作 MongoDB 中使用者的 Collection,而需要带入的 Definition 为 UserDefinition
:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UserDefinition } from '../../common/models/user.model';
import { UserService } from './user.service';
@Module({
imports: [MongooseModule.forFeature([UserDefinition])],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
我们先将注册使用者时会使用到的 CreateUserDto
建立起来,在 src/features/user/dto
资料夹下新增 create-user.dto.ts
:
import { IsEmail, IsEnum, MaxLength, MinLength } from 'class-validator';
import {
USER_PASSWORD_MAX_LEN,
USER_PASSWORD_MIN_LEN,
USER_USERNAME_MAX_LEN,
USER_USERNAME_MIN_LEN,
} from '../../../common/constants/user.const';
import { Role } from '../../../common/enums/role.enum';
export class CreateUserDto {
@MinLength(USER_USERNAME_MIN_LEN)
@MaxLength(USER_USERNAME_MAX_LEN)
public readonly username: string;
@MinLength(USER_PASSWORD_MIN_LEN)
@MaxLength(USER_PASSWORD_MAX_LEN)
public readonly password: string;
@IsEmail()
public readonly email: string;
@IsEnum(Role)
public readonly role: Role;
}
修改 UserService
的内容,透过 @InjectModel
装饰器指定 USER_MODEL_TOKEN
来注入 UserModel
:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { USER_MODEL_TOKEN, UserDocument } from '../../common/models/user.model';
@Injectable()
export class UserService {
constructor(
@InjectModel(USER_MODEL_TOKEN)
private readonly userModel: Model<UserDocument>,
) {}
}
我们这套系统的注册 API 主要是用来注册预设使用者,并透过该预设使用者来新增其他使用者,所以注册 API 只在没有任何使用者的情况下才能使用,故 UserService
不仅需要设计 createUser
方法来建立使用者,还需提供 hasUser
方法来确认是否有使用者资料在资料库中,又因为登入需要进行身分核对,所以还需要设计 findUser
来取得对应的使用者资料:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';
import { CommonUtility } from '../../core/utils';
import { USER_MODEL_TOKEN, UserDocument } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';
@Injectable()
export class UserService {
constructor(
@InjectModel(USER_MODEL_TOKEN)
private readonly userModel: Model<UserDocument>,
) {}
public async createUser(user: CreateUserDto) {
const { username, email, role } = user;
const password = CommonUtility.encryptBySalt(user.password);
const document = await this.userModel.create({
username,
password,
email,
role,
});
return document?.toJSON();
}
public async findUser(filter: FilterQuery<UserDocument>, select?: any) {
const query = this.userModel.findOne(filter).select(select);
const document = await query.exec();
return document?.toJSON();
}
public async hasUser() {
const count = await this.userModel.estimatedDocumentCount().exec();
return count > 0;
}
}
建立 index.ts
来做汇出管理:
export { UserModule } from './user.module';
export { UserService } from './user.service';
export { CreateUserDto } from './dto/create-user.dto';
有了 UserService
让我们可以操作使用者资料之後,就要来实作验证的部分了,需要建立相关元件:
$ nest generate module features/auth
$ nest generate service features/auth
$ nest generate controller features/auth
还记得前面有提过的 Passport 吗?当验证程序通过之後,会将部分资料放入请求物件中,让後续的操作可以使用该资料,我们称它为 载体 (Payload),通常会将部分使用者资讯放入载体中,所以我们要在 src/features/auth/interfaces
资料夹下新增 payload.interface.ts
来将我们的载体定义好介面,主要包含的资料为 id
、username
、role
:
import { Role } from '../../../common/enums/role.enum';
export interface UserPayload {
id: string;
username: string;
role: Role;
}
我们还可以设计装饰器来取得载体内容,在 src/features/auth/decorators
资料夹下新增 payload.decorator.ts
:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const request: Express.Request = context.switchToHttp().getRequest();
return request.user;
},
);
调整一下 AuthModule
的配置,我们需要运用 PassportModule
与 JwtModule
来完成一个完整的登入与注册的身分验证机制,所以将它们引入并从 ConfigService
取得相关环境变数,另外,还需要引入 UserModule
让我们可以使用 UserService
来操作使用者资料:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get('secrets.jwt');
return {
secret,
signOptions: {
expiresIn: '3600s',
},
};
},
}),
UserModule,
],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
接着,调整 AuthService
的内容,在里面设计 validateUser
来验证是否为合法的使用者,以及设计 generateJwt
来产生 JWT 让使用者可以透过该 token
存取资源:
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CommonUtility } from '../../core/utils/common.utility';
import { UserPayload } from './interfaces/payload.interface';
import { UserService } from '../user';
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
public async validateUser(username: string, password: string) {
const user = await this.userService.findUser({ username });
const { hash } = CommonUtility.encryptBySalt(
password,
user?.password?.salt,
);
if (!user || hash !== user?.password?.hash) {
return null;
}
return user;
}
public generateJwt(payload: UserPayload) {
return {
access_token: this.jwtService.sign(payload),
};
}
}
完成 AuthService
以後,我们要把相关的验证策略建立起来,这样才能完整走完 Passport 的验证机制,首先,我们建立 LocalStrategy
来处理登入时使用的验证策略,在 src/features/auth/strategies
资料夹下新增 local.strategy.ts
:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { UserPayload } from '../interfaces/payload.interface';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
public async validate(username: string, password: string) {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
const payload: UserPayload = {
id: user._id,
username: user.username,
role: user.role,
};
return payload;
}
}
有登入使用的验证策略之後,还需要设计登入期间的验证策略,也就是针对 JWT 的验证,在 src/features/auth/strategies
资料夹下新增 jwt.strategy.ts
:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserPayload } from '../interfaces/payload.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('secrets.jwt'),
});
}
validate(payload: UserPayload) {
return payload;
}
}
设计好验证策略後,需要将它们添加到 AuthModule
下的 providers
:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { UserModule } from '../user';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get('secrets.jwt');
return {
secret,
signOptions: {
expiresIn: '3600s',
},
};
},
}),
UserModule,
],
providers: [AuthService, LocalStrategy, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}
最後,就是调整 AuthController
的内容,设计 signup
与 signin
来实作注册与登入的效果:
import {
Body,
Controller,
ForbiddenException,
Post,
UseGuards,
} from '@nestjs/common';
import { LocalAuthGuard } from '../../core/guards';
import { CreateUserDto, UserService } from '../user';
import { User } from './decorators/payload.decorator';
import { UserPayload } from './interfaces/payload.interface';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@Post('/signup')
async signup(@Body() dto: CreateUserDto) {
const hasUser = await this.userService.hasUser();
if (hasUser) {
throw new ForbiddenException();
}
const user = await this.userService.createUser(dto);
const { _id: id, username, role } = user;
return this.authService.generateJwt({ id, username, role });
}
@UseGuards(LocalAuthGuard)
@Post('/signin')
signin(@User() user: UserPayload) {
return this.authService.generateJwt(user);
}
}
建立 index.ts
做汇出管理:
export { AuthModule } from './auth.module';
export { UserPayload } from './interfaces/payload.interface';
export { User } from './decorators/payload.decorator';
预计会设计以下几个使用者相关的 API:
[GET] /users
:取得使用者列表。[POST] /users
:新增使用者。[DELETE] /users/:id
:删除特定使用者。[PATCH] /users/:id
:更新特定使用者。由於有更新使用者的部分,所以还需要设计对应的 DTO,在 src/features/user/dto
资料夹下新增 update-user.dto.ts
,并使用 PartialType
继承 CreateUserDto
的属性:
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
另外,取得使用者列表的部分应当给予单次取得的上限值以及要跳过几笔资料,故需要设计一个 SearchDto
来定义相关的参数,在 src/core/bases
资料夹下新增 search.dto.ts
:
import { IsOptional } from 'class-validator';
export class SearchDto {
@IsOptional()
skip?: number;
@IsOptional()
limit?: number;
}
建立 index.ts
做汇出管理:
export { SearchDto } from './search.dto';
注意:会将
SearchDto
放在core/bases
资料夹下是为了让其他 API 有更多的扩充空间,让它们的 DTO 来继承。
可以透过设计 Pipe 来将输入进来的 limit
与 skip
限制在合理的范围内以及预设值,在 src/core/pipes
资料夹下新增 search.pipe.ts
:
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
@Injectable()
export class SearchPipe implements PipeTransform<Record<string, any>> {
private readonly DEFAULT_LIMIT = 30;
private readonly MAX_LIMIT = 50;
private readonly DEFAULT_SKIP = 0;
transform(value: Record<string, any>, metadata: ArgumentMetadata) {
const { limit, skip } = value;
value.limit = this.setLimit(parseInt(limit));
value.skip = this.setSkip(parseInt(skip));
return value;
}
private setLimit(limit: number): number {
if (!limit) {
return this.DEFAULT_LIMIT;
}
if (limit > this.MAX_LIMIT) {
return this.MAX_LIMIT;
}
return limit;
}
private setSkip(skip: number): number {
if (!skip) {
return this.DEFAULT_SKIP;
}
return skip;
}
}
建立 index.ts
做汇出管理:
export { SearchPipe } from './search.pipe';
接着,修改 UserService
的内容,新增 findUsers
来取得使用者列表、deleteUser
来删除使用者、 updateUser
来更新使用者以及用来检查是否有重复注册使用者的 existUser
:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';
import { CommonUtility } from '../../core/utils';
import { SearchDto } from '../../core/bases/dto';
import { USER_MODEL_TOKEN, UserDocument } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UserService {
constructor(
@InjectModel(USER_MODEL_TOKEN)
private readonly userModel: Model<UserDocument>,
) {}
public async createUser(user: CreateUserDto) {
const { username, email, role } = user;
const password = CommonUtility.encryptBySalt(user.password);
const document = await this.userModel.create({
username,
password,
email,
role,
});
return document?.toJSON();
}
public async findUser(filter: FilterQuery<UserDocument>, select?: any) {
const query = this.userModel.findOne(filter).select(select);
const document = await query.exec();
return document?.toJSON();
}
public async findUsers(search: SearchDto, select?: any) {
const { skip, limit } = search;
const query = this.userModel.find().select(select);
const documents = await query.skip(skip).limit(limit).exec();
return documents.map((document) => document?.toJSON());
}
public async deleteUser(userId: string) {
const document = await this.userModel.findByIdAndRemove(userId).exec();
if (!document) {
return;
}
return {};
}
public async updateUser(userId: string, data: UpdateUserDto, select?: any) {
const obj: Record<string, any> = { ...data };
if (obj.password) {
obj.password = CommonUtility.encryptBySalt(obj.password);
}
const query = this.userModel
.findByIdAndUpdate(userId, obj, { new: true })
.select(select);
const document = await query.exec();
return document?.toJSON();
}
public existUser(filter: FilterQuery<UserDocument>) {
return this.userModel.exists(filter);
}
public async hasUser() {
const count = await this.userModel.estimatedDocumentCount().exec();
return count > 0;
}
}
透过 CLI 产生 UserController
:
$ nest generate controller features/user
修改 UserController
的内容以符合我们的 API 需求:
import {
Body,
ConflictException,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { SearchPipe } from '../../core/pipes';
import { JwtAuthGuard } from '../../core/guards';
import { SearchDto } from '../../core/bases/dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
@UseGuards(JwtAuthGuard)
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
async getUsers(@Query(SearchPipe) query: SearchDto) {
return this.userService.findUsers(query, '-password');
}
@Post()
async createUser(@Body() dto: CreateUserDto) {
const { username, email } = dto;
const exist = await this.userService.existUser({
$or: [{ username }, { email }],
});
if (exist) {
throw new ConflictException('username or email is already exist.');
}
const user = await this.userService.createUser(dto);
const { password, ...result } = user;
return result;
}
@Delete(':id')
async deleteUser(@Param('id') id: string) {
const response = await this.userService.deleteUser(id);
if (!response) {
throw new ForbiddenException();
}
return response;
}
@Patch(':id')
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
const user = await this.userService.updateUser(id, dto, '-password');
if (!user) {
throw new ForbiddenException();
}
return user;
}
}
透过 CLI 快速产生相关元件:
$ nest generate module features/todo
$ nest generate service features/todo
$ nest generate controller features/todo
预计会设计以下几个待办事项相关的 API:
[GET] /todos
:取得待办事项列表。[POST] /todos
:新增待办事项。[DELETE] /todos/:id
:删除特定待办事项。[PATCH] /todos/:id
:更新特定待办事项。由於有新增与更新待办事项的部分,所以还需要设计对应的 DTO,在 src/features/todo/dto
资料夹下新增 create-todo.dto.ts
与 update-todo.dto.ts
:
import { IsOptional, MaxLength, MinLength } from 'class-validator';
import {
TODO_DESCRIPTION_MAX_LEN,
TODO_TITLE_MAX_LEN,
TODO_TITLE_MIN_LEN,
} from '../../../common/constants/todo.const';
export class CreateTodoDto {
@MinLength(TODO_TITLE_MIN_LEN)
@MaxLength(TODO_TITLE_MAX_LEN)
public readonly title: string;
@IsOptional()
@MaxLength(TODO_DESCRIPTION_MAX_LEN)
public readonly description?: string;
@IsOptional()
public readonly completed?: boolean;
}
import { PartialType } from '@nestjs/mapped-types';
import { CreateTodoDto } from './create-todo.dto';
export class UpdateTodoDto extends PartialType(CreateTodoDto) {}
在 TodoModule
中引入 MongooseModule
来建立 TodoModel
,进而操作 MongoDB 中待办事项的 Collection,而需要带入的 Definition 为 TodoDefinition
:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { TodoDefinition } from '../../common/models/todo.model';
import { TodoController } from './todo.controller';
import { TodoService } from './todo.service';
@Module({
imports: [MongooseModule.forFeature([TodoDefinition])],
controllers: [TodoController],
providers: [TodoService],
})
export class TodoModule {}
接着,调整 TodoService
的内容,我们要设计 createTodo
新增待办事项、findTodos
取得待办事项列表、deleteTdod
删除特定待办事项、updateTodo
更新特定待办事项:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { SearchDto } from '../../core/bases/dto';
import { TodoDocument, TODO_MODEL_TOKEN } from '../../common/models/todo.model';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';
@Injectable()
export class TodoService {
constructor(
@InjectModel(TODO_MODEL_TOKEN)
private readonly todoModel: Model<TodoDocument>,
) {}
public async createTodo(data: CreateTodoDto) {
const todo = await this.todoModel.create(data);
return todo?.toJSON();
}
public async findTodos(search: SearchDto, select?: any) {
const { skip, limit } = search;
const query = this.todoModel.find().select(select);
const documents = await query.skip(skip).limit(limit).exec();
return documents.map((document) => document?.toJSON());
}
public async deleteTodo(todoId: string) {
const document = await this.todoModel.findByIdAndRemove(todoId).exec();
if (!document) {
return;
}
return {};
}
public async updateTodo(todoId: string, data: UpdateTodoDto, select?: any) {
const query = this.todoModel
.findByIdAndUpdate(todoId, data, { new: true })
.select(select);
const document = await query.exec();
return document?.toJSON();
}
}
最後,调整 TodoController
的内容以符合我们的 API 需求:
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../core/guards';
import { SearchPipe } from '../../core/pipes';
import { SearchDto } from '../../core/bases/dto';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';
import { TodoService } from './todo.service';
@UseGuards(JwtAuthGuard)
@Controller('todos')
export class TodoController {
constructor(private readonly todoService: TodoService) {}
@Get()
async getTodos(@Query(SearchPipe) query: SearchDto) {
return this.todoService.findTodos(query);
}
@Post()
async createTodo(@Body() dto: CreateTodoDto) {
return this.todoService.createTodo(dto);
}
@Delete(':id')
async deleteTodo(@Param('id') id: string) {
const response = await this.todoService.deleteTodo(id);
if (!response) {
throw new ForbiddenException();
}
return response;
}
@Patch(':id')
async updateTodo(@Param('id') id: string, @Body() dto: UpdateTodoDto) {
const todo = await this.todoService.updateTodo(id, dto);
if (!todo) {
throw new ForbiddenException();
}
return todo;
}
}
今天我们把所有资源的 API 都实作完毕了,包括:auth
、todos
与 users
。今天篇幅较长可能耗费了各位读者们许多精力,加油!只剩下角色授权验证的部分了,明天再冲刺一天吧!
middleware 在上篇文章介绍routing时有提到Party时有传入一个handler不知道...
到底什麽式Vue directives Vue directives简单来说就是一种可以挂在HTML...
昨天在文章中提到如何 Dockerize 你的专案,而设定你自己的 Docker Image 就是一...
What should you do if the partition on your extern...
如何改善痛点问题,不只有在工作,也可以用於纪录自己的生活以及家庭。在每个 Sprint 结束都有 R...