本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
前面有提到這次實作的系統共有兩大資源,分別是:使用者 (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
。今天篇幅較長可能耗費了各位讀者們許多精力,加油!只剩下角色授權驗證的部分了,明天再衝刺一天吧!