[NestJS] JWT 인증과 Role 적용하기
JWT인증과 Role인증을 동시에 적용하는 로직을 만드는 연습을 해 보았다. JWT인증에 대해서 정리한 글은 아래의 링크에 나와 있다.
https://tre2man.tistory.com/321
JWT인증을 하기 전에 공식 문서를 잘 읽어 보자. NestJS는 공식문서가 잘 되어 있어서 꼼꼼하게 읽어 보는것이 좋다.
https://docs.nestjs.com/security/authentication
먼저 JWT인증을 할 때의 플로우를 간단히 정리해 보았다.
- 회원가입 및 로그인을 할 때 JWT 토큰을 반환한다.
- 가드가 적용된 컨트롤러에 요청을 보낼 때, 유효한 JWT토큰 확인과 Role가드가 있다면 Role까지 확인한다.
- 유효한 토큰과 권한이 아니면 401에러, 맞으면 정상적으로 로직을 실행한다.
인증 관련한 라이브러리는 passport를 사용하고, jwt를 적용할려고 하면 추가 라이브러리가 필요하다. 여기에다가 몇개의 패키지를 더 설치해야 하는데, 이에 대한 내용은 공식문서에 자세히 설명이 되어 있으므로 따로 적지는 않겠다.
먼저 유저를 인증할 validate 로직을 제작한다. 주로 Auth와 관련된 서비스에 추가하면 가독성이 좋고, 입력된 데이터를 통해서 해당 유저가 존재하는지 확인하면 된다. 아래는 그냥 예시이다.
async validate(input: JwtPayload): Promise<User | null> {
const user = await this.userService.findOneUser(input);
if (!user) return null;
return user;
}
여기서 주의할 것은 실제 사용할 때는 암호는 단방향 해시로 암호화가 되어 있어야 하는 것이다. 이러한 점을 염두해서 로직을 작성해야 한다. 다음으로는 JwtStrategy 객체를 새로 지정한다. 이것으로 토큰이 유효한지 판별할 수 있다.
// jwt-strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_TOKEN,
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validate(payload);
if (!user) throw new HttpException('Unauthorized', 401);
return user;
}
}
다음으로는 Jwt 가드를 정의할 것이다. AuthGuard('jwt')를 상속받는 객체를 생성한다.
// auth-guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
여담이지만, GraphQL을 사용할 때는 request를 중간에서 한번 정제할 필요가 있다. 이를 위해서 NestJS에서 예시를 제공해 주고 있다. 만약 GraphQL을 사용한다면 ExecutionContext말고 GqlExcutionContext를 사용해야 한다.
// graphql-guard
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
jwt 인증 로직이 완성되었으면은 role을 지정한다. enum으로 제작하며, role-guard를 사용 가능하게 만드는 SetMetadata를 사용하여 데코레이터까지 생성해 준다.
// role-list.ts
export const ROLE_KEY = 'roles';
export enum Role {
admin = 'admin',
user = 'user',
}
export const Roles = (...roles: (keyof typeof Role)[]) =>
SetMetadata(ROLE_KEY, roles);
위에 있는 Roles 데코레이터를 사용하게 되면, 해당 데코레이터의 입력 변수에 들어가는 role만 요청 가능하게 하는 가드를 새로 만들어 주어야 한다. 여기서는 reflector로 지정한 메타데이터를 불러올 수 있다. 그래서 해당 로직에서는 canActivate 메서드 안의 reflector는 앞에서 지정한 ROLE_KEY에 해당하는 메타데이터(@Role 사용할 때 입력했던 역할들)를 불러오게 되고, 이를 user의 role과 비교하여 권한 제공 여부를 결정하게 된다.
// role-guard.ts
@Injectable()
export class RoleGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext) {
const roles = this.reflector.get<(keyof typeof Role)[]>(
ROLE_KEY,
context.getHandler(),
);
const user: User = context.switchToHttp().getRequest().user;
if (!roles.includes(user.role))
throw new HttpException('Unauthorized', 401);
return true;
}
}
위와 같이 로직을 다 작성했으면, 모듈에 입력 후 정상 작동을 확인해보자. 모듈에 입력할 때 async 모듈로 삽입한 이유는, configService를 사용할 때 dotenv파일에서 설정값을 불러오는 과정이 async하기 때문에 아래와 같이 작성했다. 처음부터 전역으로 적용하고 싶으면 dotenv-cli를 사용하면 된다.
// auth.module.ts
@Module({
imports: [
UserModule,
PassportModule.register({
defaultStrategy: 'jwt',
}),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_TOKEN'),
signOptions: { expiresIn: '10m' },
}),
}),
],
controllers: [AuthController],
providers: [userProvider, UserService, AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
컨트롤러에 적용할 때는 다음과 같이 한다. 예시로 든 아래의 로직은 요청하는 토큰의 유저의 권한이 admin일 경우에만 작동을 한다는 것이다.
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@UseGuards(JwtAuthGuard, RoleGuard)
@Roles('admin')
async findOneUser(@Query() input: FindOneUserDto) {
return await this.userService.findOneUser(input);
}
}
아래의 사진은 Postman으로 요청을 보냈을 때 나오는 응답값이다. 해당 요청들을 보내기 위해서 위의 코드는 Query String을 던지지만, 잠시 없애 두었다. 여튼 user권한이 있는 jwt 토큰을 던졌을 때는 허가되지 않은 요청을 반환하게 된다.
아래의 사진은 admin 권한이 있는 토큰을 던졌을 때 나오는 응답값이다. 권한 문제 없이 요청값이 잘 들어오는 것을 볼 수 있다.