컨트롤러마다 권한 검사를 복붙하다 한 곳을 빠뜨린 문제
문제 발생
관리자 전용 엔드포인트를 만들면서 컨트롤러마다 이런 코드를 반복하고 있었습니다.
@Delete(":id")
async remove(@Req() req, @Param("id") id: string) {
if (req.user?.role !== "admin") {
throw new ForbiddenException();
}
return this.postService.remove(id);
}한 곳에서 이 검사를 빠뜨렸고, 그게 배포된 뒤에야 발견됐습니다.
원인 분석
이건 핸들러의 일이 아닙니다. NestJS 공식 문서가 가드의 단일 책임을 이렇게 정의합니다 — 가드는 런타임에 존재하는 조건(권한, 역할, ACL 등)에 따라 주어진 요청을 라우트 핸들러가 처리할지 말지 결정합니다.
실행 위치도 정해져 있습니다 — 가드는 모든 미들웨어 이후, 그리고 어떤 인터셉터나 파이프보다 먼저 실행됩니다. 그래서 인가 판정이 본문 검증이나 응답 변환보다 앞섭니다.
핸들러 안에 조건문으로 흩어두면 두 가지가 무너집니다. 빠뜨린 곳을 찾을 방법이 없고, "이 엔드포인트에 어떤 권한이 필요한가"가 코드 어디에도 선언되어 있지 않습니다.
해결 방안
CanActivate를 구현합니다.canActivate()는 현재 요청이 허용되는지를 boolean으로 반환하며,Promise나Observable로 비동기 반환도 가능합니다.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get(Roles, context.getHandler());
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}- 역할은 데코레이터로 선언합니다.
Reflector.createDecorator로 타입이 붙은 데코레이터를 만듭니다.
export const Roles = Reflector.createDecorator<string[]>();@Roles(["admin"])
@Delete(":id")
remove(@Param("id") id: string) {
return this.postService.remove(id);
}이제 필요한 권한이 엔드포인트 옆에 선언되어 있습니다. 검사를 빠뜨렸는지 눈으로 확인할 수 있습니다.
- 범위를 골라서 붙입니다. 세 가지입니다.
@UseGuards(RolesGuard) // 메서드 또는 컨트롤러
app.useGlobalGuards(new RolesGuard(reflector)); // 전역전역 가드에 의존성을 주입해야 하면 모듈 provider에 APP_GUARD 토큰으로 등록합니다.
providers: [{ provide: APP_GUARD, useClass: RolesGuard }]- 거부는 예외로 표현합니다. 가드가
false를 반환하면 Nest가 403 Forbidden을 자동으로 돌려줍니다. 이유를 구분해야 하면 직접 예외를 던집니다.
throw new ForbiddenException("이 작업에는 운영자 권한이 필요합니다");-
가드는 인가지 소유권 검사가 아닙니다. "관리자인가"는 가드가, "이 글의 작성자인가"는 데이터 접근 지점이 판단하는 편이 안전합니다. 리소스를 조회해봐야 알 수 있는 조건을 가드에 넣으면 같은 조회가 두 번 일어납니다.
-
인증과 인가를 섞지 않습니다. "누구인가"를 세우는 것과 "무엇을 할 수 있는가"를 판정하는 것은 다른 가드입니다. 하나로 합치면 공개 엔드포인트를 만들 때마다 예외 처리가 늘어납니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.