本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
前面有提到這次要設計的系統共有三種角色,並且會使用 Casbin 來做授權機制。
提醒:Authorization 的技巧可以參考 DAY25 - Authorization & RBAC。
Casbin 的授權機制由存取控制模型與政策模型所組成,我們先將存取控制模型進行定義,在 rbac 資料夾下新增 model.conf,並將請求、政策、角色定義、效果與驗證器設置好:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == '*')
接著,在 rbac 資料夾下建立 policy.csv,將下方的存取條件進行配置:
admin:/users、/todos 的所有資源皆可以操作。member:可對 /todos 與 /users 的資源做 read 並且對 /todos/:id 進行 update 的操作。manager:繼承 member 的存取權,並可對 /todos 進行 create 操作、/todos/:id 進行 delete 操作。p, role:admin, /api/users, *
p, role:admin, /api/users/:id, *
p, role:admin, /api/todos, *
p, role:admin, /api/todos/:id, *
p, role:manager, /api/todos, create
p, role:manager, /api/todos/:id, delete
p, role:member, /api/todos, read
p, role:member, /api/todos/:id, update
p, role:member, /api/users, read
g, role:manager, role:member
我們在 Authorization 那篇有實作把 Casbin 包裝成模組,這裡我們同樣需要實作一遍,在 src/core/modules 資料夾下透過 CLI 產生 AuthorizationModule 與 AuthorizationService:
$ nest generate module core/modules/authorization
$ nest generate service core/modules/authorization
Casbin 需要使用 enforcer 來 套用 model.conf 與 policy.csv,這種第三方物件可以透過自訂 Provider 的方式進行處理,在 src/core/modules/authorization/constants 資料夾下新增 token.const.ts,設計一個注入 enforcer 用的 token:
export const AUTHORIZATION_ENFORCER = 'authorization_enforcer';
我希望 model.conf 與 policy.csv 是可以透過外部提供的,故我們需要運用 DynamicModule 的設計方法來處理,輸入值的介面也需要進行制定,在 src/core/modules/authorization/interfaces 資料夾下新增 option.interface.ts:
export interface RegisterOptions {
  modelPath: string;
  policyAdapter: any;
  global?: boolean;
}
調整 AuthorizationModule 的內容,將其修改成 DynamicModule,並將 AuthorizationService 與 enforcer 匯出:
import { DynamicModule, Module } from '@nestjs/common';
import { newEnforcer } from 'casbin';
import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { RegisterOptions } from './interfaces/option.interface';
import { AuthorizationService } from './authorization.service';
@Module({})
export class AuthorizationModule {
  static register(options: RegisterOptions): DynamicModule {
    const { modelPath, policyAdapter, global = false } = options;
    const providers = [
      {
        provide: AUTHORIZATION_ENFORCER,
        useFactory: async () => {
          const enforcer = await newEnforcer(modelPath, policyAdapter);
          return enforcer;
        },
      },
      AuthorizationService,
    ];
    return {
      global,
      providers,
      module: AuthorizationModule,
      exports: [...providers],
    };
  }
}
設計一個 enum 來與我們角色的資源操作做配對,在 src/core/modules/authorization 下新增一個 enums 資料夾並建立 action.enum.ts:
export enum AuthorizationAction {
  CREATE = 'create',
  READ = 'read',
  UPDATE = 'update',
  DELETE = 'delete',
  NONE = 'none',
}
AuthorizationService 負責做權限檢查以及把 HttpMethod 轉換成 AuthorizationAction:
import { Inject, Injectable } from '@nestjs/common';
import { Enforcer } from 'casbin';
import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { AuthorizationAction } from './enums/action.enum';
@Injectable()
export class AuthorizationService {
  constructor(
    @Inject(AUTHORIZATION_ENFORCER) private readonly enforcer: Enforcer,
  ) {}
  public checkPermission(subject: string, object: string, action: string) {
    return this.enforcer.enforce(subject, object, action);
  }
  public mappingAction(method: string): AuthorizationAction {
    switch (method.toUpperCase()) {
      case 'GET':
        return AuthorizationAction.READ;
      case 'POST':
        return AuthorizationAction.CREATE;
      case 'PATCH':
      case 'PUT':
        return AuthorizationAction.UPDATE;
      case 'DELETE':
        return AuthorizationAction.DELETE;
      default:
        return AuthorizationAction.NONE;
    }
  }
}
建立 index.ts 做匯出管理:
export { AuthorizationModule } from './authorization.module';
export { AuthorizationService } from './authorization.service';
export { AUTHORIZATION_ENFORCER } from './constants/token.const';
接下來我們需要實作一個 RoleGuard 來針對角色的存取權做驗證,透過 CLI 在 src/core/guards 資料夾下建立 RoleGuard:
$ nest generate guard core/guards/role
RoleGuard 會透過 AuthorizationService 對角色進行審查,並將結果回傳:
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { AuthorizationService } from '../modules/authorization';
@Injectable()
export class RoleGuard implements CanActivate {
  constructor(private readonly authorizationService: AuthorizationService) {}
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    const { user, path, method } = request;
    const action = this.authorizationService.mappingAction(method);
    if (!user) {
      throw new UnauthorizedException();
    }
    return this.authorizationService.checkPermission(
      `role:${(user as any).role}`,
      path,
      action,
    );
  }
}
調整 index.ts,將 RoleGuard 進行匯出:
export { JwtAuthGuard } from './jwt-auth.guard';
export { LocalAuthGuard } from './local-auth.guard';
export { RoleGuard } from './role.guard';
完成角色授權驗證功能後,就要將 RoleGuard 套用至 UserController 與 TodoController 上,下方為 UserController 調整後的內容,主要就是多了 RoleGuard:
import {
  Body,
  ConflictException,
  Controller,
  Delete,
  ForbiddenException,
  Get,
  Param,
  Patch,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';
import { SearchPipe } from '../../core/pipes';
import { JwtAuthGuard, RoleGuard } 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, RoleGuard)
@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;
  }
}
在 TodoController 套用 RoleGuard:
import {
  Body,
  Controller,
  Delete,
  ForbiddenException,
  Get,
  Param,
  Patch,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, RoleGuard } 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, RoleGuard)
@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;
  }
}
完成這套系統之後,我們要來模擬使用情境,進而展示這套系統的成果,首先,我們透過指令將 Nest App 啟動:
$ npm run start:dev
啟動後,透過 [POST] /api/auth/signup 進行預設使用者註冊:
檢測一下是否有成功擋下第二次註冊,有正確擋下的話會收到 403 的錯誤:
透過 [POST] /api/auth/signin 進行登入:
透過登入的 API 取得 token 後,就可以將它帶入 Header 中進行其他 API 操作,我們先使用角色為 admin 的帳號來建立三個使用者,他們的角色分別為:manager、member 與 member,使用 [POST] /api/users 來建立:
username 為 manager1 的 manager:
username 為 member1 的 member:
username 為 member2 的 member:
使用 [PATCH] /api/users/:id 來修改 member2 的角色,將他角色變更為 manager:
透過 [DELETE] /api/users/:id 將 member2 移除:
使用 [GET] /api/users 確認 member2 是否已經從使用者中刪除:
最後,使用 manager1 來建立使用者,會收到 403 的錯誤,因為只有 admin 可以進行新增、刪除、修改使用者的操作:
我們使用 manager1 透過 [POST] /api/todos 來新增待辦事項:
使用 [PATCH] /api/todos/:id 將該筆待辦事項的 completed 改為 true:
使用 [GET] /api/todos 來取得待辦事項列表:
透過 [DELETE] /api/todos/:id 將該筆待辦事項刪除:
使用 member1 新增待辦事項,會收到 403 的錯誤,因為 member 不能對待辦事項進行新增與刪除的操作:
終於完成了這套系統了,這也表示這個系列文將劃下句點,相信各位在經歷這 31 天的奮鬥以後,對 Nest 有更進一步的認識。明天會再發一篇文章來做個總結,謝謝大家這一路以來的支持!
Inside app.module.ts, for the import array, I need to update to below to make it work
  AuthorizationModule.register({
      modelPath: join(__dirname, '../rbac/model.conf'),
      policyAdapter: join(__dirname, '../rbac/policy.csv'),
      global: true,
    }),
Yes, it's necessary step, thanks.