BackEnd/Nest.js

[Nest.js] JWT 인증/인가

Grace 2023. 5. 26. 14:27

가드

인증은 미들웨어로 구현하는 것이 좋은 사례입니다. 애플리케이션은 사용자의 권한을 확인하기 위해 인증과 인가를 수행해야 합니다. 인증은 요청자가 자신이 누구인지 증명하는 과정입니다. 최근에는 매 요청마다 헤더에 JWT 토큰을 실어 보내고 이 토큰을 통해 요청자가 라우터에 접근 가능한지 확인하는 방식을 많이 사용합니다.

이해 비해 인가는 인증을 통과한 유저가 요청한 기능을 사용할 권한이 있는지를 판별하는 것을 말합니다. 퍼미션, 롤, 접근 제어 목록 같은 개념을 사용하여 유저가 가지고 있는 속성으로 리소스 사용을 허용할지 판별합니다. 인가는 가드를 이용하여 구현할 수 있는 좋은 사례입니다.

보통 인증과 인가에 실패할 경우 응답에 대한 HTTP 상태 코드는 각각 401 Unauthorized와 403 Forbidden 입니다. 401의 이름이 Unauthorized로 되어 있으므로 주의 바랍니다.

미들웨어는 실행 콘텍스트에 접근하지 못합니다. 단순히 자신의 일만 수행하고 next()를 호출합니다. 즉, 다음에 어떤 핸들러가 실행될지 알 수 없습니다. 이에 반해 가드는 실행 컨텍스트 인스턴스에 접근할 수 있어 다음 실행될 작업을 정확히 할고 있기 때문에 인가는 인증처럼 미들웨어로 구현하기보다는 가드로 구현하는 것이 좋습니다.

가드를 이용한 인가

가드는 CanActivate 인터페이스를 구현해야 합니다.

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return this.validateRequest(request);
  }
  
  private validateRequest(request: any) {
    return true;
  }
}

실행 컨텍스트

canActivate 함수는 ExecutionContext 인스턴스를 인수로 받습니다. ExecutionContextArgumentsHost를 상속받는데, 요청과 응답에 대한 정보를 가지고 있습니다. 우리는 HTTP로 기능을 제공하고 있으므로 인터페이스에서 제공하는 함수 중 switchToHttp() 함수를 사용하여 필요한 정보를 가져올 수 있습니다.

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgsByIndex<T = any>(index: number): T;
  switchToTpc(); RpcArgumentsHost;
  switchToWs(): WsArgumentsHost;
  getType<TContext extends string = ContextType>(): TContext;
}

export interface HttpArgumentsHost {
  getRequest<T = any>(): T;
  getResponse<T = any>(): T;
  getNext<T = any>(): T;
}

이렇게 얻은 정보를 내부 규칙으로 평가하는 validateRequest 함수를통해 인가를 진행합니다. 편의상 validateRequest는 true를 리턴하고 있습니다. false로 바꾸면 403 Forbidden 에러가 발생합니다. 만약 다른 에러로 응답을 반환하고 싶다면 직접 다른 예외를 생성해서 던져야 합니다.

가드 적용

가드를 적용하는 방법은 예외 필터를 적용하는 것과 유사합니다. 컨트롤러 범위 또는 메서드 범위로 적용하고자 한다면 @UseGuards(AuthGuard)와 같이 사용하면 됩니다. AuthGuard 인스턴스의 생성은 Nest가 맡아서 합니다. 만약 여러 종류의 가드를 적용하고 싶다면 쉼표로 이어 선언하면 됩니다.

@UseGuards(AuthGuard)
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }
  
  @UseGuards(AuthGuard)
  @Get()
  getHello(): string{
    return this.appService.getHello();
  }
}

전역으로 가드를 적용하고 싶다면 부트스트랩 과정을 수정해야 합니다.

...
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new AuthGuard()) // useGlobalGuards 함수를 사용하여 전역 가드를 설정
  await app.listen(3000)
}
bootstrap()

가드에 종속성 주입을 사용해서 다른 프로바이더를 주입해서 사용하고 싶다면 커스텀 프로바이더로 선언해야 합니다.

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  provider: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    }
  ]
})
export class AppModule {}

인증

사용자의 리소스를 보호하기 위해서는 서버에 접속하는 클라이언트가 리소스의 주인인지 확인하는 인증 절차를 거쳐야 합니다. 사용자가 아이디와 비밀번호로 로그인하면 로그아웃할 때까지는 사용자가 가진 권한 내에서 서비스를 이용할 수 있습니다. 즉, 사용자가 가진 리소스를 조회하고 변경할 수 있게 됩니다.아이디와 패스워드를 한 번만 제출했는데 매번 리소스에 대해 접근이 가능하게 하는 방법은 여러 가지가 있지만 주로 세션이나 토큰을 이용한 방식을 사용합니다. 특히 토큰은 장점이 많아서 많은 서비스들이 채택하고 있고, 그중에서도 JWT를 이용하는 방식이 거의 표준이 되었습니다. 최근에 만들어진 서비스는 거의 모두 사용한다고 해도 무방합니다.

세션 기반 인증

세션은 로그인에 성공한 유저가 서비스를 사용하는 동안 저장하고 있는 유저 정보입니다. 서버는 세션을 생성하고 나서 세션을 데이터베이스에 저장하고, 이후 사용자의 요청에 포함된 세션 정보가 세션 DB에 저장되어 있는지 확인합니다. 브라우저에는 데이터를 저장할 수 있는 공간이 있습니다. 현재 열려있는 브라우저를 닫거나 새로운 탭 또는 창을 열면 데이터가 삭제되는 세션 저장소와 창을 닫아도 데이터가 남아 있는 로컬 저장소, 그리고 간단한 데이터를 저장할 수 있는 쿠키가 있습니다.

세션 방식의 단점은 악의적인 공격자가 브라우저에 저장된 데이터를 탈취할 수 있다는 것입니다. 비록 세션을 알 수 없는 문자열로 만들었다고 해도, HTTP는 보안에 취약하기 때문에 중간에 전달되는 데이터 역시 가로챌 수 있습니다. 이렇게 탈취된 세션을 이용하면 마치 해당 사용자인 것처럼 서버에 접근할 수 있게 됩니다. 이를 방지하기 위해 HTTPS로 암호화된 통신을 하고 세션에 유효기간을 정해둡니다. 유효기간이 만료된 세션인 경우 다시 로그인을 유도하게 합니다. 유효기간은 서비스 사용자가 사용에 불편함이 없는 적당한 시간으로 정해야 합니다.

세션은 서버의 저장소에 저장되고 빠른 응답을 위해 메모리에 상주시키는 경우가 많습니다. 이로 인해 서비스에 사용자가 몰렸을 경우 요청마다 세션을 확인해야 하므로 DB에 많은 부하를 일으키게 되고 메모리 부족으로 서비스 장애가 발생할 수도 있습니다. 클라우드를 이용하면 서버와 DB를 유연하게 증설할 수 있다고 하지만 그 시간에 서비스 장애를 겪을 수도 있습니다. Redis와 같은 인프라를 이용하여 메모리에 상주하는 세션을 좀 더 빠르게 처리하도록 하는 방법을 사용하기도 합니다.

또한 서비스가 여러 도메인으로 나누어져 있는 경우 CORS 문제로 인해 도메인 간 세션을 공유하도록 하기 위한 처리가 번거롭습니다.

토큰 기반 인증

세션이 사용자 인증을 서버에 저장하는 방식인 반면, 토큰은 사용자가 로그인했을 때 서버에서 토큰을 생성해서 전달하고 따로 저장소에 저장하지 않는 방식입니다. 로그인 이후 요청에 대해 클라이언트가 전달한 토큰 검증만 수행합니다. 이렇게 하기 위해서는 당연히 특정한 검증 방식이 필요한데, JWT를 많이 사용합니다.

토큰 기반 인증을 이용하면 세션과 같이 상태를 관리할 필요가 없어 어느 도메인의 서비스로 보내더라도 같은 인증을 수행할 수 있게 됩니다. 이를 확장하면 메타, 구글 계정으로 다른 서비스에 로그인을 할 수 있는 Oauth를 구현할 수 있습니다. 또한 토큰 기반 인증 방식은 세션 저장소가 서버에 필요하지 않기 떄문에 세션 기반 방식에서 발생하는 문제가 줄어듭니다.

JWT

JWT는 두 당사자 사이에 이전될 수 있는 클레임을 나타내는 간결하고 URL에서 안전한 방법입니다. JWT에 포함된 클레임은 JSON으로 인코딩되어 JSON 웹 서명의 페이로드 또는 JSON 웹 암호화의 일반 텍스트로 사용됩니다. 클레임을 디지털 방식으로 서명하거나 메시지 인증 코드로 암호화해서 무결성을 보호합니다.

JWT는 헤더, 페이로드, 시그니처, 3가지 요소를 가지며 점(.)으로 구분됩니다. 헤더와 페이로드는 각각 base64로 인코딩되어 있습니다. base64로 인코딩을 하면 사람이 읽을 수 없고 디코딩이 필요하지만 JWT를 HTTP 헤더나 요청 매개변수 또는 폼 매개변수로 사용할 수 있습니다. 또 JSON 문자열을 데이터베이스나 프로그래밍 언어에서 지원하지 않는 경우가 있기 때문에 이 경우를 위해서도 base64 인코딩이 필요합니다.

헤더

점(.)으로 구분된 가장 첫 번째 문자열은 헤더입니다. 헤더는 일반적으로 JWT의 유형('typ”)과 어떤 알고리즘(“alg”)에 의해 인코딩되었는지를 포함합니다.

{
  "typ": "JWT",
  "alg": "HS256"
}
  • “typ” 매개변수는 JWS와 JWE에 정의된 미디어 타입입니다. 이는 JWT를 처리하는 애플리케이션에게 페이로드가 무엇인지를 알려주는 역할을 합니다. 즉, 이 토큰은 JWT라는 것을 뜻하므로 “JWT”라는 값으로 정의하라고 권고하고 있습니다.
  • “alg” 매개변수는 토큰을 암호화하는 알고리즘입니다. 암호화하지 않을 경우는 “none”으로 정의하고, 암호화를 할 경우 해당 알고리즘을 기술합니다. 위의 예에서는 HS256으로 토큰을 암호화헀다는 뜻입니다.

페이로드

페이로드는 클레임이라 부르는 정보를 포함합니다.

등록된 클레임

IANA JWT 클레임 레지스트리에 등록된 클레임입니다. 필수는 아니지만 JWT가 상호 호환성을 가지려면 작성해야 합니다.

  • “iss”(issuer, 발급자): 누가 토큰을 발급(생성)했는지를 나타냅니다. 애플리케이션에서 임의로 정의한 문자열 또는 URI 형식을 가집니다.
  • “sub”(subject, 주제): 일반적으로 주제에 대한 설명을 나타냅니다. 토큰 주제는 발급자가 정의하는 문맥상 또는 전역으로 유일한 값을 가져야 합니다. 문자열 또는 URI 형식을 가집니다.
  • “aud”(audience, 수신자): 누구에게 토큰이 전달되는가를 나타냅니다. 주로 보호된 리소스의 URL을 값으로 설정합니다.
  • “exp”(expiration, 만료 시간): 언제 토큰이 만료되는지를 나타냅니다. 만료 시간이 지난 토큰은 수락되어서는 안 됩니다. 일반적으로 UNIX Epoch 시간을 사용합니다.
  • “nbf”(not before, 정의된 시간 이후): 정의된 시간 이후에 토큰이 활성화됩니다. 토큰이 유효해지는 시간 이전에 미리 발급되는 경우 사용합니다. 일반적으로 UNIX Epoch 시간을 사용합니다.
  • “iat”(issued at, 토큰 발급 시간): 언제 토큰이 발급되었는지를 나타냅니다. 일반적으로 UNIX Epoch 시간을 사용합니다.
  • “jti"(JWT ID, 토큰 식별자): 토큰의 고유 식별자로서, 같은 값을 가질 확률이 없는 암호학적 방법으로 생성되어야 합니다. 공격자가 JWT를 재사용하는 것을 방지하기 위해 사용합니다.

공개 클레임

JWT 발급자는 표준 클레임에 덧분여 공개되어도 무방한 페이로드를 공개 클레임으로 정의합니다. 하지만 이름 충돌을 방지하기 위해 IANA JWT 클레임 레지스트리에 클레임 이름을 등록하거나 합리적인 예방 조치를 해야 합니다. 보통 URI 형식으로 정의합니다.

{
  "http://example.com/is_root: true"
}

비공개 클레임

JWT 발급자와 사용자 간에 사용하기로 약속한 클레임입니다. 서비스 돔네인 내에서 필요한 이름과 값ㅇ르 비공개 클레임으로 정의합니다. 이름 충돌이 발생하지 않도록 주의해야 합니다.

시그니처

헤더와 페이로드는 단순히 base64로 인코딩하기 때문에 공격자가 원하는 값을 넣고 토큰을 생성할 수 있습니다. 따라서 생성된 토큰이 유효한지 검증하는 장치가 필요합니다. 헤더에서 “alg”: “HS256”이라고 선언한다면 이 토큰은 HMAC-SHA256 알고리즘으로 암호화해야 합니다. 당연히 암호화할 때 사용하는 secret은 토큰을 생성하고 검증하는 서버에서만 안전한 방법으로 저장해야 합니다.

HS256 방식의 암호화 헤더는 페이로드를 base64로 인코딩한 문자열과 secret을 이용하여 HMACSHA256 알고리즘에 넣어주면 됩니다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  'secret'
)
JWT 토큰을 생성할 때는 직접 base64 인코딩과 알고리즘을 사용하지 않고 JWT 생성 라이브러리를 사용합니다.

'BackEnd > Nest.js' 카테고리의 다른 글

[Nest.js] 예외 필터  (0) 2023.05.30
[Nest.js] 로깅  (0) 2023.05.30
[Nest.js] 파이프와 유효성 검사  (0) 2023.05.16
[Nest.js] Config 패키지  (0) 2023.05.16
[Nest.js] 프로바이더  (0) 2023.05.16