관리자만 Post를 삭제할 수 있도록 RBAC을 사용하겠습니다. 먼저 관리자인지 아닌지를 구분하는 데코레이터를 만들겠습니다.
import { SetMetadata } from "@nestjs/common";
import { RolesEnum } from "../const/roles.const";
export const ROLES_KEY = 'user_roles';
// @Roles(RolesEnum.ADMIN) -> admin 사용자만 사용 가능
export const Roles = (role: RolesEnum) => SetMetadata(ROLES_KEY, role) // 키값과 키값에 해당하는 데이터 넣기
@Delete(':id')
@UseGuards(AccessTokenGuard)
@Roles(RolesEnum.ADMIN) // admin만 접근 가능
deletePost(@Param('id', ParseIntPipe) id: number) {
return this.postsService.deletePost(id);
}
메타데이터를 적용했으니까, 관리자가 아니면 아래 deletePost 기능을 못하게 만들겠습니다. 즉, 특정 코드를 막는 기능이기 때문에 Guard를 사용하겠습니다. user에 관한 guard이기 때문에 user에서 생성하겠습니다.
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,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
/**
* Roles annotation에 대한 metadata를 가져와야한다.
*
* Reflector: IoC에서 자동으로 주입을 받을 수 있다.
* getAllAndOverride():
* ROLES_KEY에 해당되는 annotation에 대한 정보를 전부 가져옵니다.
* 그중에서 가장 가까운 값을 가져와서 override(덮어씌운다)한다.
* EX) 컨트롤러에 붙여도, 메소드에 적용된 어노테이션을 가져온다.
*/
const requireRole = this.reflector.getAllAndOverride(
ROLES_KEY,
[
// 어떤 context에서 가져올거야?
context.getHandler(),
context.getClass()
]
);
// Roles Annotation 등록 X
if (!requireRole) return true;
// RolesGuard를 실행하기전에 AccessToken이 통과되기 때문에
const {user} = context.switchToHttp().getRequest();
if (!user) throw new UnauthorizedException(`토큰을 제공해주세요! `);
if (user.role !== requireRole) throw new ForbiddenException(`이 작업을 수행할 권한이 없습니다. ${requireRole} 권한이 필요합니다.`);
return true;
}
}
이 Guard는 전역적으로 적용할 것입니다. 왜냐하면 아래의 아래의 코드 때문입니다. 해당 코드는 권한을 필요로 하는 모든 경우 전부 적용시키도록 하겠습니다.
providers: [AppService,
{
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor, // 다른 모듈에서도 ClassSerializerInterceptor를 적용받는다.
},
{
provide: APP_GUARD,
useClass: RolesGuard
}
],
포스트맨으로 테스트를 하겠습니다.
{
"message": "토큰을 제공해주세요! ",
"error": "Unauthorized",
"statusCode": 401
}
다음과 같이 에러가 나는 이유는 우리가 적용한 Guard가 전역적으로 먼저 발동하기 때문에 AccessToken 보다 먼저 실행되어서 user가 존재하지 않기
때문입니다.
이번에는 AccessToken
이 @Roles
보다 먼저 실행되도록 글로벌하게 만들겠습니다. 보안을 위해서는 전역적으로 AccessTokenGuard가 작용하게 만들고, 필요하지 않는 것만 @IsPublic
을 만들어서 적용하겠습니다.
providers: [AppService,
{
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor, // 다른 모듈에서도 ClassSerializerInterceptor를 적용받는다.
},
{
// 보안을 위해서는 기본값을 이렇게 만든다
provide: APP_GUARD,
useClass: AccessTokenGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard
}
],
common쪽에 @IsPublic
을 만들어 주겠습니다.
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = 'is_public';
export const IsPublic = () => SetMetadata(IS_PUBLIC_KEY, true);
이제 IsPublic
을 감지해야할 기능이 필요합니다. 따라서 감지하는 기능은 token쪽
에 작성을 하겠습니다.
@Injectable()
export class BearerTokenGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly userService: UsersService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 토큰을 검증하기 전에 reflect metadata 기능을 사용해서
// public route를 달았는지 안 달았는지를 확인하는 검증을 하고
// 달려있으면 바로 true를 반환하는 기능
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);
.
.
}
@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;
}
}
@IsPublic
을 사용하는 곳에서는 return true가 되기 때문에 토큰이 필요없게 됩니다.
post 컨트롤러 1곳에만 적용을 해보겠습니다.
@Get()
@IsPublic()
getPosts(
@Query() query: PaginatePostDto,
) {
return this.postsService.paginatePosts(query);
}
포스트맨으로 요청하면 응답이 에러없이 잘 나오는 것을 알 수 있습니다. 또한 삭제도 잘 되는 것을 알 수 있습니다.
@Post('token/access')
@IsPublic()
@UseGuards(RefreshTokenGuard)
postTokenAccess
@Post('token/refresh')
@IsPublic()
@UseGuards(RefreshTokenGuard)
postTokenRefresh(
@Post('login/email')
@IsPublic()
@UseGuards(BasicTokenGuard)
postLoginEmail(
@Post('register/email')
@IsPublic()
postRegisterEmail(
@Post('image')
@UseInterceptors(FileInterceptor('image'))
postImage
@Get()
@IsPublic()
getPosts
@Post('random')
async postPostsRandom
@Get(':id')
@IsPublic()
getPost
@Post()
@UseInterceptors(TransactionInterceptor)
async postPosts
@Patch(':id')
patchPost
@Delete(':id')
@Roles(RolesEnum.ADMIN)
deletePost
@Get()
@Roles(RolesEnum.ADMIN)
getUsers