1. RBAC 란?
RBAC(Role-Based Access Control)은 사용자의 역할(Role)에 따라 시스템 내에서의 접근 권한을 제어하는 보안 모델이다.
예를 들어, 글을 쓰거나 수정하거나 삭제하거나 등의 작업에서 역할을 부여해서 해당 역할에 부합되는 경우에만 권한을 주는 것이다.
2. Roles Decorator 작업하기
@Column({
type: 'enum',
enum: RoleEnum,
default: RoleEnum.USER,
})
role: RoleEnum;
기존에 UsersModel에서 role 필드를 구현하지만 쓰진 않았었다.
import { SetMetadata } from "@nestjs/common";
import { RoleEnum } from "../entity/users.entity";
export const ROLES_KEY = 'user_roles';
// @Rolse(RolseEnum.ADMIN) -> 데코레이터 시 관리자가 아니면 사용할 수 없음
export const Roles = (role: RoleEnum) => SetMetadata(ROLES_KEY, role);
이러한 Roles Decorator를 만들면,
// 5) DELETE /posts:id
// id에 해당되는 post를 삭제한다.
@Delete(':id')
@UseGuards(AccessTokenGuard)
@Roles(RoleEnum.ADMIN)
deletePost(@Param('id', ParseIntPipe) id: number) {
return this.postsService.deletePost(id);
}
데코레이터를 적용할 수 있게 된다.
3. RolesGuard 생성하고 적용하기
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorator/roles.decorator";
@Injectable()
export class RolesGuard implements CanActivate{
constructor(
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
const requiredRole = this.reflector.getAllAndOverride(
ROLES_KEY,
[
context.getHandler(),
context.getClass(),
]
);
// Roles Annotaion이 등록되어있지 않음
if(!requiredRole) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if(!user) {
throw new UnauthorizedException(
`토큰을 제공해주세요.`
);
}
if(user.role !== requiredRole) {
throw new ForbiddenException(
`이 작업을 수행할 권한이 없습니다. ${requiredRole} 권한이 필요합니다.`
);
}
return true;
}
}
Reflector를 사용하여, 현재 적용된 클래스 등의 정보를 가져온다.
이 정보들을 이용해 Roles Annotation(Decorator)가 적용되어있는 메서드의 권한 여부를 체크한다.
문제가 없는 경우가 true이다.
providers: [AppService, {
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
}
app.module.ts 에 추가적인 provide로 적용하여 모든 곳에 RolesGuard가 적용되도록 한다.
로그인 후에도 Delete 메서드는 토큰을 제공해달라는 에러를 발생한다.
이것은 AccessTokenGuard가 적용되기 전에 글로벌 가드인 RolseGuard가 적용되기 때문이라는 것을 알 수 있다.
4. 모든 Route 기본 Private로 만들고 IsPublic Annotation 작업하기
기본적으로 모든 라우터들을 private하게 변경하여 강제적으로 AccessTokenGuard를 적용하도록 하고, AccessToken이 필요없는 경우에는 IsPublic Annotation으로 활성화시켜줄 수 있다.
{
provide: APP_GUARD,
useClass: AccessTokenGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
}
모든 라우터에 AccessTokenGuard를 적용하도록 한다.
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = 'is_public';
export const IsPublic = () => SetMetadata(IS_PUBLIC_KEY, true);
IsPublic 데코레이터를 만들어 준다. 해당 데코레이터가 붙어있으면 true를 반환한다.
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "../auth.service";
import { UsersService } from "src/users/users.service";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "src/common/decorator/is-public.decorator";
@Injectable()
export class BearerTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride(
IS_PUBLIC_KEY,
[
context.getHandler(),
context.getClass(),
]
);
const req = context.switchToHttp().getRequest();
if(isPublic) {
req.isRoutePublic = true;
return true;
}
const rawToken = req.headers['authorization'];
if(!rawToken) {
throw new UnauthorizedException('토큰이 없습니다!');
}
const token = this.authService.extractTokenFromHeader(rawToken, true);
const result = await this.authService.verifyToken(token);
/**
* request에 넣을 정보
* 1) 사용자 정보 - user
* 2) token - token
* 3) tokenType - access | refresh
*/
const user = await this.usersService.getUserByEmail(result.email);
req.user = user;
req.token = token;
req.tokenType = result.type;
return true;
}
}
@Injectable()
export class AccessTokenGuard extends BearerTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const req = context.switchToHttp().getRequest();
if(req.isRoutePublic) {
return true;
}
if(req.tokenType !== 'access') {
throw new UnauthorizedException('Access Token이 아닙니다.');
}
return true;
}
}
@Injectable()
export class RefreshTokenGuard extends BearerTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const req = context.switchToHttp().getRequest();
if(req.isRoutePublic) {
return true;
}
if(req.tokenType !== 'refresh') {
throw new UnauthorizedException('Refresh Token이 아닙니다.');
}
return true;
}
}
BearerTokenGuard에 Reflector를 이용하여 현재 실행되는 메서드에 IsPublic Annotation이 붙어있으면 req에 isRoutePublic을 붙여, AccessTokenGuard와 RefreshTokenGuard에서 true로 반환시킨다.
@Get()
@IsPublic()
// @UseInterceptors(LogInterceptor)
// @UseFilters(HttpExceptionFilter)
getPosts(
@Query() query: PaginatePostDto,
) {
return this.postsService.paginatePosts(query);
}
이제 AccessToken없이 접근할 라우터에 @IsPublic()을 붙이면 검증없이 실행되는 것을 볼 수 있다.
5. RefreshTokenGuard에서 RefreshToken 검증을 하기 위한 코드
4번과 같이 진행하면 일반적인 라우터는 AccessToken검증없이 잘 실행되지만, 문제는 AccessToken에 있다.
AccessToken 자체에 @IsPublic() 을 했기 때문에 무조건 true로 진행되게 되는데, 그러면 RefreshTokenGuard가 적용되지 않아 RefreshToken의 유효를 체크할 수 없게 된다.
5.1. IsPublic 를 선택할 수 있는 Enum 구현
export enum IsPublicEnum {
IS_PUBLIC = 'ISPUBLIC',
IS_REFRESH_TOKEN = 'ISREFRESHTOKEN',
}
5.2. IsPublic 데코레이터 수정
import { SetMetadata } from '@nestjs/common';
import { IsPublicEnum } from '../const/is-public.const';
export const ISPUBLIC_KEY = 'is_public';
export const IsPublic = (status: IsPublicEnum) => SetMetadata(ISPUBLIC_KEY, status);
Annotation의 매개변수에 IsPublicEnum에 따라 반환값을 다르게 줄 수 있도록 설정한다.
5.3. BearerTokenGuard 수정
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "../auth.service";
import { UsersService } from "src/users/users.service";
import { Reflector } from "@nestjs/core";
import { ISPUBLIC_KEY } from "src/common/decorator/is-public.decorator";
import { IsPublicEnum } from "src/common/const/is-public.const";
@Injectable()
export class BearerTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const requiredPublic = this.reflector.getAllAndOverride(ISPUBLIC_KEY,
[
context.getHandler(),
context.getClass(),
]);
if (requiredPublic) {
req.requiredPublic = requiredPublic;
}
if (requiredPublic === IsPublicEnum.IS_PUBLIC) {
return true;
}
const rawToken = req.headers['authorization'];
if(!rawToken) {
throw new UnauthorizedException('토큰이 없습니다!');
}
const token = this.authService.extractTokenFromHeader(rawToken, true);
const result = await this.authService.verifyToken(token);
/**
* request에 넣을 정보
* 1) 사용자 정보 - user
* 2) token - token
* 3) tokenType - access | refresh
*/
const user = await this.usersService.getUserByEmail(result.email);
req.user = user;
req.token = token;
req.tokenType = result.type;
return true;
}
}
@Injectable()
export class AccessTokenGuard extends BearerTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const req = context.switchToHttp().getRequest();
if (req.requiredPublic === IsPublicEnum.IS_PUBLIC || IsPublicEnum.IS_REFRESH_TOKEN) {
return true;
}
if(req.tokenType !== 'access') {
throw new UnauthorizedException('Access Token이 아닙니다.');
}
return true;
}
}
@Injectable()
export class RefreshTokenGuard extends BearerTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const req = context.switchToHttp().getRequest();
if(req.tokenType !== 'refresh') {
throw new UnauthorizedException('Refresh Token이 아닙니다.');
}
return true;
}
}
크게 어려울 것 없이 부모인 BearerTokenGuard에서는 체크하는 라우터의 IsPublicEnum값이 IS_PUBLIC인 경우에만 true를 반환한다.
AccessTokenGuard에서는 둘다 상관없이 true를 반환한다.
RefreshTokenGuard는 부모인 BearerTokenGuard에서 정상적으로 verifyToken이 되었다면 RefreshTokenGuard는 문제없이 진행되어야 하기 때문이다.
'NestJS' 카테고리의 다른 글
[NestJS] process.env 사용 및 분기처리 (0) | 2025.04.05 |
---|---|
[NestJS] Authorization Guard (0) | 2025.03.14 |
[NestJS] 모듈 네스팅 (0) | 2025.03.12 |
[NestJS] SocketIO 심화 (1) | 2025.03.12 |
[NestJS] SocketIO 일반 (0) | 2025.03.12 |