NestJS - Guard, JWT
이 포스트는 NestJS 의 Guard
를 이용한 인가에 대해 알아본다.
- 1.
Guard
- 2.
Guard
를 이용한 인가 - 2.1. 실행 콘텍스트
- 2.2.
Guard
적용 - 3. 유저 서비스
- 3.1. JWT 발급: 이메일 인증
- 3.2. JWT 발급: 로그인
- 3.3. JWT 인증: 회원 정보 조회
- 3.4.
Guard
로 인가 처리 - 참고 사이트 & 함께 보면 좋은 사이트
소스는 example, user-service 에 있습니다.
인증, JWT, 세션, 토큰에 대한 상세한 내용은 인증, JWT, Sliding Session, Refresh Token 를 참고하세요.
1. Guard
NestJS - Middleware 의 1. Middleware 에서 인증(Authentication) 을 Middleware 로 구현하는 것이 좋은 예시라고 하였다. 최근에는 매 요청마다 헤더에 JWT 토큰을 실어보내서 해당 토큰을 통해 인증하는 방식을 많이 사용한다.
인가(Authorization) 는 인증을 통과한 유저가 요청 기능을 사용할 권한이 있는지 판별하는 것으로 Middleware 가 아닌 Guard
를 이용하여 구현하는 것이 좋다.
인가는 permission, role, access control list 개념을 사용하여 유저가 갖고 있는 속성으로 리소스 사용 허용 여부를 판단한다.
인증(Authentication) 실패 시 - 401 Unauthorized
인가(Authorization) 실패 시 - 403 Forbidden
인가를 인증처럼 Middleware 로 구현할 수 없는 이유는 아래와 같다.
- Middleware 는 실행 콘텍스트(ExecutionContext) 에 접근 불가
- 자신의 일만 수행하고 next() 호출하기 때문에 다음에 어떤 핸들러가 실행될 지 알 수 없음
- 반면,
Guard
는 실행 콘텍스트 인스턴스에 접근이 가능하기 때문에 다음에 실행될 작업을 알고 있음
2. Guard
를 이용한 인가
$ nest new ch10
Guard
는 CanActivate
인터페이스를 구현해야 한다.
/src/auth.guard.ts
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return this.validateRequest(request);
}
// 얻은 정보(request) 를 내부 규칙으로 평가 진행
private validateRequest(request: any) {
// 편의상 true 리턴
// false 로 리턴 시 403 Forbidden 에러 발생함
// 다른 에러 응답을 원하면 직접 다른 예외 생성해서 던지면 됨
return true;
}
}
2.1. 실행 콘텍스트
CanActivate
메서드, ExecuteContext
인터페이스, ArgumentsHost
인터페이스, HttpArgumentsHost
인터페이스의 각 시그니처
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>;
export interface ExecutionContext extends ArgumentsHost {
getClass<T = any>(): Type<T>;
getHandler(): Function;
}
export interface ArgumentsHost {
getArgs<T extends Array<any> = any[]>(): T;
getArgByIndex<T = any>(index: number): T;
switchToRpc(): RpcArgumentsHost;
switchToHttp(): HttpArgumentsHost;
switchToWs(): WsArgumentsHost;
getType<TContext extends string = ContextType>(): TContext;
}
export interface HttpArgumentsHost {
getRequest<T = any>(): T;
getResponse<T = any>(): T;
getNext<T = any>(): T;
}
canActivate
함수는 ExecutionContext
인스턴스를 인수로 받고, ExecutionContext
는 ArgumentsHost
를 상속받으며 ArgumentsHost
는 요청과 응답에 대한 정보를 갖고 있다.
이 포스트에선 HTTP 로 기능을 제공하므로 HttpArgumentsHost
인터페이스에서 제공하는 switchToHttp()
함수로 정보를 가져올 수 있다.
2.2. Guard
적용
2.2.1. 컨트롤러 혹은 메서드 범위로 Guard
적용: @UseGuards
여러 개의 Guard
적용 시엔 쉼표로 이어 선언한다.
app.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuard } from './auth.guard';
@UseGuards(AuthGuard) // 클래스에 가드 적용
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@UseGuards(AuthGuard) // 메서드에 가드 적용
@Get()
getHello(): string {
return this.appService.getHello();
}
}
2.2.2. 전역으로 Guard
적용
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthGuard } from './auth.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard()); // 전역 가드 적용
await app.listen(3000);
}
bootstrap();
2.2.3. Guard
에 종속성 주입을 사용해서 다른 Provider 를 주입해서 사용
Custom Provider 로 선언하여 사용한다.
Custom Provider 는 NestJS - Provider 의 4. Custom Provider 를 참고하세요.
app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AppModule {}
인증, JWT 와 함께 보시면 도움이 됩니다.
3. 유저 서비스
위에서 한 내용과 NestJS - 동적 모듈로 환경변수 구성 참고하여 AuthService 를 AuthModule 에서 제공하도록 하고, AuthService 에서 사용할 JWT secret 을 환경변수로 등록한다.
$ nest g mo Auth -- auth.module.ts 생성
$ nest g s Auth -- auth.service.ts 생성
동적으로 ConfigModule 등록
/src/config/authConfig.ts
import { registerAs } from '@nestjs/config';
export default registerAs('auth', () => ({
jwtSecret: process.env.JWT_SECRET,
}));
/src/config/env/.local.env
...
JWT_SECRET=test
AppModule 에 ConfigModule 을 동적 모듈로 등록
app.module.ts
@Module({
imports: [
UsersModule,
ConfigModule.forRoot({
envFilePath: [`${__dirname}/config/env/.${process.env.NODE_ENV}.env`],
load: [emailConfig, authConfig], // ConfigFactory 지정
isGlobal: true, // 전역으로 등록해서 어느 모듈에서나 사용 가능
validationSchema, // 환경 변수 값에 대해 유효성 검사 수행
}),
...
/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
@Module({
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
/src/users/users.module.ts
@Module({
imports: [EmailModule, TypeOrmModule.forFeature([UserEntity]), AuthModule], // UsersModule 에 forFeature() 로 유저 모듈 내에서 사용할 저장소 등록
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
3.1. JWT 발급: 이메일 인증
회원 가입 요청 시 발송된 이메일 인증을 통해 회원 가입 완료 후 응답으로 토큰을 발급하여 로그인 상태가 되도록 해본다.
가입 시 아래와 같은 인증 링크가 포함되는데 “가입 확인” 버튼 클릭 시 요청에 대한 응답으로 JWT 문자열을 돌려준다.
const url = `${baseUrl}/users/email-verify?signupVerifyToken=${signupVerifyToken}`;
/src/users/users.service.ts (기존)
// 이메일 인증
async verifyEmail(signupVerifyToken: string): Promise<string> {
// DB 에 signupVerifyToken 으로 회원 가입 처리중인 유저가 있는지 조회 후 없다면 에러 처리
const user = await this.userRepository.findOne({
where: { signupVerifyToken },
});
if (!user) {
throw new NotFoundException('존재하지 않는 유저');
}
// TODO: 바로 로그인 상태가 되도록 JWT 발급
throw new Error('아직 미구현된 로직');
}
로그인 상태가 되도록 하는 로직은 AuthService 가 처리하도록 하기 한다.
JWT 토큰 발급 & 검증을 위해 jsonwebtoken 패키지를 사용한다.
$ npm i jsonwebtoken
$ npm i -D @types/jsonwebtoken
이제 AuthService 에서 로그인 처리를 한다.
/src/auth/auth.service.ts
import * as jwt from 'jsonwebtoken';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import authConfig from '../config/authConfig';
interface User {
id: string;
name: string;
email: string;
}
@Injectable()
export class AuthService {
constructor(
// 주입받을 때 @Inject 데커레이터의 토큰을 앞에서 만든 ConfigFactory 의 KEY 인 `auth` 문자열로 넣어준다.
@Inject(authConfig.KEY) private config: ConfigType<typeof authConfig>,
) {}
// 로그인 처리
login(user: User) {
// Private claim
const payload = { ...user };
// Registered claim
return jwt.sign(payload, this.config.jwtSecret, {
expiresIn: '1d',
audience: 'test.com',
issuer: 'test.com',
});
}
}
/src/users/users.service.ts
// 이메일 인증
async verifyEmail(signupVerifyToken: string): Promise<string> {
// DB 에 signupVerifyToken 으로 회원 가입 처리중인 유저가 있는지 조회 후 없다면 에러 처리
const user = await this.userRepository.findOne({
where: { signupVerifyToken },
});
if (!user) {
throw new NotFoundException('존재하지 않는 유저');
}
// 바로 로그인 상태가 되도록 JWT 발급
return this.authService.login({
id: user.id,
name: user.name,
email: user.email,
});
}
이제 회원 가입 시 받은 이메일 버튼을 눌러서 응답 JWT 를 jwt.io/ 에 넣어 확인해본다.
JWT 발급에 사용한 Private claim (id, name, email) 과 함께 Registered claim (exp, aud, iss) 이 포함되어 있는 것을 확인할 수 있다. (iat 는 자동 생성)
3.2. JWT 발급: 로그인
전달받은 이메일과 패스워드로 유저 조회 후 유효한 유저이면 JWT 를 발급한다.
/src/users/users.service.ts
// 로그인
async login(email: string, password: string): Promise<string> {
// DB 에 email, password 가진 유저 존재 여부 조회 후 없다면 에러 처리
const user = await this.userRepository.findOne({
where: { email, password },
});
if (!user) {
throw new NotFoundException('존재하지 않는 유저');
}
// JWT 발급
return this.authService.login({
id: user.id,
name: user.name,
email: user.email,
});
}
이제 로그인 요청을 하면 JWT 토큰이 전달되는 것을 알 수 있다.
$ curl --location 'http://localhost:3000/users/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "test@test.com",
"password": "test1234"
}'
3.3. JWT 인증: 회원 정보 조회
이제 매 요청마다 로그인 후 받은 토큰으로 인증을 처리하도록 한다.
API Spec
GET /users/:id
Authorization: Bearer <token>
Header 로 전달하는 JWT 의 Private claim 에 유저의 ID 가 포함되어 있지만 REST 형식의 API 명세를 맞추기 위해 id 를 경로 매개변수로 다시 전달받는다.
/src/auth/auth.service.ts
// jwt 검증
verify(jwtString: string) {
try {
// 외부에 노출되지 않는 secret 을 사용하기 때문에 이 토큰이 유효한지 검증 가능
const payload = jwt.verify(jwtString, this.config.jwtSecret) as (jwt.JwtPayload | string) & User;
const { id, email } = payload;
return {
userId: id,
email,
};
} catch (e) {
throw new UnauthorizedException();
}
}
/src/users/users.controller.ts
import { Headers } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
@Controller('users')
export class UsersController {
// UsersService 를 컨트롤러에 주입
constructor(
private readonly usersService: UsersService,
private readonly authService: AuthService,
) {}
// 유저 정보 조회
@Get(':id')
async getUserInfo(
@Headers() headers: any,
@Param('id') userId: string,
): Promise<UserInfo> {
// jwt 파싱
const jwtString = headers.authorization.split('Bearer ')[1];
this.authService.verify(jwtString);
return await this.usersService.getUserInfo(userId);
}
}
/src/users/users.service.ts
// 유저 정보 조회
async getUserInfo(userId: string): Promise<UserInfo> {
// DB 에 userId 가진 유저 존재 여부 조회 후 없다면 에러 처리
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('존재하지 않는 유저');
}
// 조회 데이터를 userInfo 타입으로 리턴
return {
id: userId,
name: user.name,
email: user.email,
};
}
이제 JWT 를 전달하여 유저 정보를 조회해본다.
$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer eyJhbGciOiJ6IkpXVCJ9.eyJpZCI6IjAxR1hOMzlLV1ZQS1Y3V1pSNVhGRDBBNUZIIiwibmFtZSI6ImFzc3UiLCJlbWFpbCI6ImpoLmxlZUB3ZXZlcnNlY29tcGFueS5jb20iLCJpYXQiOjE2ODExMTc0NzksImVLmNvbSJ9._-ysauarpOjSz_LFcYhjZha9Mw' | jq
{
"id": "01GXN39KWVPKV7WZR5XFD0A5FH",
"name": "assu",
"email": "test@test.com"
}
3.4. Guard
로 인가 처리
위의 유저 정보 조회 API 를 보면 Header 에 포함된 JWT 토큰 유효성 검사 로직을 모든 엔드포인트에 중복 구현하도록 되어 있다.
이를 NestJS 에서 제공하는 Guard
를 이용하여 handler 에서 분리해본다.
/src/uses/users.controller.ts (기존 방식)
// 유저 정보 조회
@Get(':id')
async getUserInfo(
@Headers() headers: any,
@Param('id') userId: string,
): Promise<UserInfo> {
// jwt 파싱
const jwtString = headers.authorization.split('Bearer ')[1];
this.authService.verify(jwtString);
return await this.usersService.getUserInfo(userId);
}
앞에서 본 것처럼 AuthGuard 를 적용해본다.
/src/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { AuthService } from './auth/auth.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return this.validateRequest(request);
}
// 얻은 정보(request) 를 내부 규칙으로 평가 진행
private validateRequest(request: any) {
const jwtString = request.headers.authorization.split('Bearer ')[1];
this.authService.verify(jwtString);
// false 로 리턴 시 403 Forbidden 에러 발생함
// 다른 에러 응답을 원하면 직접 다른 예외 생성해서 던지면 됨
return true;
}
}
AuthGuard 를 전역으로 적용하면 가입, 로그인 등 액세스 토큰없이 요청하는 기능이 사용 불가하므로 회원 조회 엔드포인트에만 적용한다.
컨트롤러를 분리하여 분리된 컨트롤러에 적용해도 무방하다.
/src/uses/users.controller.ts (Guard 적용 후)
// 유저 정보 조회
@UseGuards(AuthGuard)
@Get(':id')
async getUserInfo(
@Headers() headers: any,
@Param('id') userId: string,
): Promise<UserInfo> {
// jwt 파싱
//const jwtString = headers.authorization.split('Bearer ')[1];
//this.authService.verify(jwtString);
return await this.usersService.getUserInfo(userId);
}
이제 다시 유저 정보 조회를 해보면 정상적으로 동작하는 것을 확인할 수 있다.
$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer eyJhbGciOiJIUzpXVCJ9.eyJpZCI6IjAxR1hOMzlLV1ZQS1CJlbWFpbCI6ImpoLmxlZUB3ZXZlcnNlY29tcGFueS5jb20iLCJpYXQiOjE2ODExMTc0NzksImV4cCI6MTY4MTIwMzg3OSwiYXVkIjoidGVzdC5jb20iLCJpc3MiOiJ0ZXN0LmNvbSJ9._-ysauarpOjYhjZha9Mw' | jq
{
"id": "01GXN39KWVPKV7WZR5XFD0A5FH",
"name": "assu",
"email": "test@test.com"
}
$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer wrongToken' | jq
{
"statusCode": 401,
"message": "Unauthorized"
}
좀 더 나은 AOP 가 되었다.
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.
- NestJS로 배우는 백엔드 프로그래밍
- NestJS로 배우는 백엔드 프로그래밍 - Github
- NestJS 공식문서
- NestJS docs
- Nest.js Github
- NestJS 공식 예제 Starter 프로젝트 Github
- 인증, JWT
- 관점 지향 프로그래밍 (AOP, Aspect-Oriented Programming)