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สะท้อน unionUser.roleจาก Schemas เป๊ะ ๆ —@Roles('admin')จะ type-check ผ่านก็ต่อเมื่อค่าที่ใส่เป็น role ที่เป็นไปได้จริงเท่านั้นgetAllAndOverride(แทนที่จะเป็นget) เช็ค metadata ระดับ method ก่อน แล้วค่อย fallback ไปที่ metadata ระดับ class ถ้า method เองไม่มีเลย — มีประโยชน์ในภายหลังถ้าทั้ง resolver class ถูกทำเครื่องหมายเป็น admin-only ไว้ในที่เดียว แทนที่จะ decorate ทุก methoduser?.role— optional chaining ตรงนี้สำคัญเพราะRolesGuardในหลักการแล้วรันได้โดยไม่มีGqlAuthGuardมาก่อน (resolver author ลืมใส่);userจะเป็นundefinedบนreqและundefined?.roleก็เป็นundefinedซึ่ง failrequiredRoles.includes(...)อย่างปลอดภัย แทนที่จะ throwTypeErrorที่จะโผล่เป็น500ที่มืดมนแทนที่จะเป็น403ที่ตั้งใจไว้
ไม่มีไฟล์ไหนในสี่ไฟล์นี้ถูกลงทะเบียนเป็น provider ใน AuthModule เลย — Nest instantiate คลาสที่ส่งตรงเข้า @UseGuards() ผ่าน DI container ตัวเดียวกัน และ Reflector เป็น core provider ที่มีให้ใช้ทั่วทั้งแอปอยู่แล้ว ดังนั้น constructor dependency ของ RolesGuard จึง resolve ได้โดยไม่ต้องเดินสายเพิ่มเลย
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”npm run start:dev[Nest] ... LOG [NestApplication] Nest application successfully startedAPI listening on http://localhost:4000Boot ที่สะอาดยืนยันว่าไฟล์ทั้งสี่ 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() จริง