ข้ามไปยังเนื้อหา

Guards และ roles

ไฟล์เล็ก ๆ สี่ไฟล์ใน apps/api/src/auth/: gql-auth.guard.ts (GqlAuthGuard ตัว adapter บาง ๆ ทับ AuthGuard('jwt') ของ passport-jwt สำหรับ GraphQL), roles.decorator.ts (@Roles(...roles)), roles.guard.ts (RolesGuard ซึ่งอ่านสิ่งที่ @Roles ประกาศไว้) และ current-user.decorator.ts (@CurrentUser() param decorator) ตอนนี้ยังไม่มีตัวไหนถูกเรียกใช้จริง Auth resolver & GraphQL setup คือจุดที่ me และ admin-only mutation ในอนาคตจะหยิบไปใช้

Guard ของ NestJS คือคลาสที่มี method canActivate(context): boolean ซึ่งทำงานก่อนที่ route handler (หรือ resolver method) จะถูกเรียก คืน true แล้ว request ก็ดำเนินต่อ; คืน false (หรือ throw) แล้ว request ก็ถูกปฏิเสธก่อนที่ business logic ใด ๆ จะรัน — หลักการ “ปฏิเสธที่ edge” เดียวกับที่ ValidationPipe ของ Validation ใช้กับ body ที่ผิดรูปแบบ นำมาใช้ตรงนี้กับ request ที่ยังไม่ authenticated หรือสิทธิ์ไม่พอแทน

AuthGuard('jwt') จาก @nestjs/passport implement canActivate ให้ฝั่ง REST อยู่แล้ว โดยหา strategy 'jwt' ที่ JWT & Passport ลงทะเบียนไว้ รัน JwtStrategy.validate แล้วแนบผลลัพธ์เข้ากับ req.user สิ่งเดียวที่ทำไม่ได้คือหา req จากอาร์กิวเมนต์ของ resolver แบบ GraphQL เพราะ canActivate ของ resolver รับ object รูปร่าง GraphQLExecutionContext ไม่ใช่คู่ (req, res) ของ Express ตรง ๆ GqlAuthGuard จึง override แค่ method เดียวคือ getRequest เพื่ออุดช่องว่างนั้น: GqlExecutionContext.create(context) ห่อ context ดิบด้วย helper ที่รู้จัก GraphQL และ .getContext().req ดึง req object ตัวเดียวกับที่ context: ({ req }) => ({ req }) ใน GraphQLModule.forRoot ของ Auth resolver & GraphQL setup วางไว้ให้ทุก resolver call ออกมา ส่วนที่เหลือทั้งหมด — การรัน strategy, การตั้ง req.user — สืบทอดมาจาก AuthGuard('jwt') โดยไม่เปลี่ยนแปลง

RolesGuard คือ guard ที่สอง แยกจากกันสำหรับคำถามที่สองที่แยกออกจากกัน GqlAuthGuard ตอบคำถาม “นี่คือ user ที่ล็อกอินจริงหรือเปล่า”; RolesGuard ตอบคำถาม “user ที่ล็อกอินคนนี้มี role ที่ถูกต้องหรือเปล่า” และต้องรู้ในแต่ละ resolver method ว่า “role ที่ถูกต้อง” หมายถึงอะไร นี่คือหน้าที่ของ @Roles('admin'): SetMetadata(ROLES_KEY, roles) แนบ array ของ role ที่อนุญาตเป็น metadata บน method ที่ decorate ไว้ ซึ่งอะไรก็ตามที่ถือ Reflector อ่านกลับออกมาได้ภายหลัง RolesGuard.canActivate เรียก this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [context.getHandler(), context.getClass()]) โดยเช็คที่ method ก่อนแล้วค่อย fallback ไปที่ class เพื่ออ่าน metadata นั้นกลับมา ถ้าไม่มี (คือไม่ได้ใส่ @Roles กับ method นี้) จะคืน true เพราะการไม่ระบุ role ใด ๆ แปลว่าไม่มีข้อจำกัด นี่คือ pattern แบบ declarative เดียวกับ decorator ของ class-validator ที่ขับเคลื่อน ValidationPipe — metadata นิ่งเฉยจนกว่าจะมีอะไรมาอ่าน และในที่นี้ guard คือตัวที่อ่าน

การใช้ guard ทั้งสองตัวร่วมกัน @UseGuards(GqlAuthGuard, RolesGuard) มีลำดับที่สำคัญเป๊ะ ๆ: guard ทำงานจากซ้ายไปขวา และ RolesGuard อ่าน req.user ซึ่งจะมีก็ต่อเมื่อ GqlAuthGuard รัน JWT strategy แล้วแนบค่านั้นไว้ให้ก่อน ถ้าสลับลำดับ RolesGuard จะอ่านได้ undefined

@CurrentUser() ไม่เกี่ยวกับ authorization เลย เป็นแค่ createParamDecorator สะดวก ๆ ที่ค้น GqlExecutionContext.create(context).getContext().req.user แบบเดียวกับที่ RolesGuard ทำอยู่ข้างใน แต่ยื่นค่าออกมาเป็นพารามิเตอร์ของ resolver method แทนที่จะเป็นการเช็คฝั่ง guard เพื่อให้ resolver รู้ว่า ใคร เป็นคนเรียก โดยไม่ต้อง derive ขึ้นมาใหม่จาก context ดิบ

@Roles() แบบ declarative คู่กับ RolesGuard เทียบกับการเช็คแบบ inline ในทุก resolver if (user.role !== 'admin') { throw new ForbiddenException(); } ที่เขียนตรงใน resolver method ทำงานได้ ไม่มี indirection และอ่านง่ายเมื่อดูแยกเดี่ยว ๆ แต่ไม่ scale เพราะสามบรรทัดเดิมจะถูก copy-paste ไปทุก admin-only mutation แล้วพิมพ์ผิดจุดเดียว (!== เทียบกับ ===) ในตัวใดตัวหนึ่งก็เป็นบั๊ก access-control จริงที่พลาดง่ายตอน review และไม่มีที่เดียวให้ audit ว่า “operation ไหนต้องการ role อะไรบ้าง” — คำตอบนั้นกระจายอยู่ทั่วทุกไฟล์ resolver @Roles('admin') คู่กับ RolesGuard รวมการเช็คนี้ไว้ในคลาสเดียว เขียนครั้งเดียว และเปลี่ยน “operation นี้ต้องการอะไร” ให้เป็นสิ่งที่มองเห็นได้ตรงด้านบนของ method ข้าง ๆ @Mutation()/@Query() แทนที่จะฝังอยู่ในเนื้อ method

Guard สองตัวแยกกัน (GqlAuthGuard, RolesGuard) เทียบกับ guard รวมตัวเดียว Guard ตัวเดียวที่ทั้ง verify JWT และเช็ค role ประหยัดไปได้หนึ่งรายการในทุกการเรียก @UseGuards() แต่ก็รวมสองคำถามที่อิสระจากกัน (“session นี้ valid ไหม” กับ “session นี้มีสิทธิ์เข้าตรงนี้ไหม”) เข้าเป็นคลาสเดียว ทำให้หยิบเฉพาะครึ่ง authentication มาใช้ซ้ำไม่ได้ สำหรับ route ที่ต้องการแค่ user ที่ล็อกอินแล้ว คนไหนก็ได้ โดยไม่สน role (me ด้านล่างนี้คือกรณีนั้นเป๊ะ ๆ: @UseGuards(GqlAuthGuard) เดี่ยว ๆ ไม่มี RolesGuard ไม่มี @Roles()) การแยกกันไว้มีค่าใช้จ่ายเป็น guard เพิ่มอีกหนึ่งตัวใน array สำหรับ operation ที่จำกัด role แลกกับที่ authentication และ authorization ยังคง compose แยกจากกันได้อย่างอิสระ

สร้าง apps/api/src/auth/gql-auth.guard.ts:

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}

สร้าง apps/api/src/auth/roles.decorator.ts:

import { SetMetadata } from '@nestjs/common';
export type Role = 'author' | 'admin';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

สร้าง apps/api/src/auth/roles.guard.ts:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ROLES_KEY, Role } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const ctx = GqlExecutionContext.create(context);
const { user } = ctx.getContext().req;
return requiredRoles.includes(user?.role);
}
}

สร้าง apps/api/src/auth/current-user.decorator.ts:

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);
  • Role = 'author' | 'admin' ใน roles.decorator.ts สะท้อน union User.role จาก Schemas เป๊ะ ๆ — @Roles('admin') จะ type-check ผ่านก็ต่อเมื่อค่าที่ใส่เป็น role ที่เป็นไปได้จริงเท่านั้น
  • getAllAndOverride (แทนที่จะเป็น get) เช็ค metadata ระดับ method ก่อน แล้วค่อย fallback ไปที่ metadata ระดับ class ถ้า method เองไม่มีเลย — มีประโยชน์ในภายหลังถ้าทั้ง resolver class ถูกทำเครื่องหมายเป็น admin-only ไว้ในที่เดียว แทนที่จะ decorate ทุก method
  • user?.role — optional chaining ตรงนี้สำคัญเพราะ RolesGuard ในหลักการแล้วรันได้โดยไม่มี GqlAuthGuard มาก่อน (resolver author ลืมใส่); user จะเป็น undefined บน req และ undefined?.role ก็เป็น undefined ซึ่ง fail requiredRoles.includes(...) อย่างปลอดภัย แทนที่จะ throw TypeError ที่จะโผล่เป็น 500 ที่มืดมนแทนที่จะเป็น 403 ที่ตั้งใจไว้

ไม่มีไฟล์ไหนในสี่ไฟล์นี้ถูกลงทะเบียนเป็น provider ใน AuthModule เลย — Nest instantiate คลาสที่ส่งตรงเข้า @UseGuards() ผ่าน DI container ตัวเดียวกัน และ Reflector เป็น core provider ที่มีให้ใช้ทั่วทั้งแอปอยู่แล้ว ดังนั้น constructor dependency ของ RolesGuard จึง resolve ได้โดยไม่ต้องเดินสายเพิ่มเลย

Terminal window
npm run start:dev
[Nest] ... LOG [NestApplication] Nest application successfully started
API listening on http://localhost:4000

Boot ที่สะอาดยืนยันว่าไฟล์ทั้งสี่ compile ผ่าน และ import ทุกตัว (AuthGuard จาก @nestjs/passport, GqlExecutionContext จาก @nestjs/graphql, Reflector จาก @nestjs/core) resolve ได้ถูกต้อง ตอนนี้ยังไม่มีไฟล์ไหนถูกเรียกใช้จริง จึงไม่มีอะไรให้ curl Auth resolver & GraphQL setup decorate me ด้วย @UseGuards(GqlAuthGuard) และอ่าน @CurrentUser() ที่เป็นจุดที่ request จริงจะทดสอบ guard ของบทเรียนนี้แบบครบวงจรจริง ๆ

GqlAuthGuard ปรับ AuthGuard('jwt') ของ passport-jwt ที่เดิมทำมาสำหรับ REST ให้ใช้กับ GraphQL ได้ โดย override แค่ method เดียวคือ getRequest เพื่อดึง req object ที่แชร์กันออกมาจาก GqlExecutionContext แทนคู่ request/response ของ Express ส่วน @Roles(...roles) แนบ metadata ด้วย SetMetadata แล้ว RolesGuard อ่านกลับมาด้วย Reflector.getAllAndOverride เทียบกับ req.user.role และคืน true (ไม่มีข้อจำกัด) เมื่อ method ไม่มี @Roles() @CurrentUser() เป็น param decorator ธรรมดาที่ค้นหา context แบบเดียวกัน เปิดให้ resolver method อ่านได้ว่าใครเป็นคนเรียก ทั้งสี่ตัวนิ่งเฉยจนกว่า Auth resolver & GraphQL setup จะนำไปใช้กับ @Query()/@Mutation() จริง

ถัดไป: Auth resolver & GraphQL setup →