본문 바로가기

프로젝트

[기록] JWT 로그인 & redis

Redis

https://redis.com/

Remote Dictionary Server의 약자로, key  - value 형식의 데이터를 저장하는 NoSQL DBMS의 일종이다. 메모리 기반으로 동작하기 때문에 속도가 매우 빠르다는 것이 장점이다.

 우리가 아는 많은 데이터베이스들은 파일시스템에 데이터를 저장한다. CPU는 메모리에 올라온 데이터만 읽을 수 있으므로 특정 데이터를 요구하려면 파일 시스템 →  메모리 → CPU의 과정을 거쳐야 하는데, redis는 파일 시스템 접근이 없으므로 속도가 매우 빠르다. 파일 시스템 상에 백업하는 기능도 지원한다.

 다양한 요구사항을 만족할 수 있도록 여러 가지 자료구조도 기본으로 제공한다.

https://redis.com/redis-enterprise/data-structures/

  • String: binary-safe(원시 바이트 그대로 다루는 방식)한 자료 구조. 문자열, 정수, 실수, 이미지 등 다양한 것을 담는다.
  • Set: 고유한 멤버를 포함하는 집합. 교집합 / 합집합 / 차집합 등도 표현 가능하다.
  • Sorted-set: 멤버와 점수가 함께 포함되는 집합. 점수 기반의 쿼리나 필터링, 정렬 등도 가능하다.
  • List: 많은 String 엘리먼트를 포함하는 자료구조.
  • Hash: 문자열 필드와 문자열 값을 매핑하는 자료구조
  • ... 등등 다양한 컬렉션 제공...

사용 사례

aws의 설명을 요약한다.

  • 캐싱: 인 메모리 캐시를 통해 DBMS 부하와 지연 시간을 줄인다.
  • 세션 관리: 세션에 대한 키를 저장할 수 있으며, TTL(Time To Live)을 지정하여 자동으로 제거되게 할 수 있다.
  • 실시간 순위표: sorted set 자료구조를 통해 정렬된 순위를 가져올 수 있다.
  • 속도 제한: INCR 등 명령으로 간단한 카운터를 만들 수 있고, 리소스 억제가 가능하다.
  • 대기열: List 자료구조를 이용하여 간단하게 큐를 구현할 수 있다.
  • 채팅 / 메시징: PUB/SUB을 통해 이벤트 트리거가 가능하다.

프로젝트와 Redis 도입

 나는 졸업 프로젝트에서 API 서버에 JWT refresh 토큰과 인기 키워드 표현을 위해 redis을 한번 도입해 보기로 했다.

로그인 기능

 졸업 프로젝트는 "키워드" 관리를 위해 관리자를 두고 있다. 이때 누구나 키워드를 추가하거나 수정해서는 안되므로 로그인 기능을 추가하여 관리자를 인증해야 한다.

 장기적으로 서버가 확장되고, 로드 밸런싱이 동작한다고 가정할 때 각 서버(EC2)의 메모리에 세션을 각각 관리하게 되면 실제 사용자와 세션이 매칭되지 않을 수 있게 된다.

로드밸런싱을 간단하게 표현한 그림

사용자(원통)가 1번 서버와 통신하여 로그인에 성공했다고 생각해 보자. 1번 서버는 자신의 메모리 영역에 세션을 생성하고, 세션 키를 사용자에게 쿠키 형태로 전달한다. 이후 사용자가 라운드 로빈에 따라 2번 서버에 로그인이 필요한 요청을 보낸다면, 해당 요청은 실패한다. 1번 서버와 2번 서버는 세션이 공유되지 않으므로, 2번 서버는 유저가 전달한 세션 키에 대응되는 세션에 대한 정보가 없기 때문이다.

로드밸런싱 환경에서의 세션을 표현한 모습

 이처럼 세션 정보를 각각의 서버에 저장하면 서버 간 세션 불일치 문제로 인해 유저가 큰 불편함을 느낄 수 있다. 요청을 전달한 실제 서버가 어딘지에 따라 기능이 동작할 수도, 동작하지 않을 수도 있기 때문이다. 이 문제를 해결하기 위해서는 세션 등의 정보를 각 서버가 아니라 하나의 저장 공간에 두고 참조하는 방식으로 변경해야 한다.

각 서버의 세션을 redis 저장소에 두는 모습


JWT 로그인 기능

 나는 Redis와 JWT를 이용하여 JWT 로그인을 구현하고자 했다. redis에 세션을 저장하는 것까지는 좋지만, 여러 서버로 분산 전달되는 요청이 redis에 집중되므로 부하가 올 수 있다. 반면 JWT를 이용하는 방식은 access token 검증 시 redis에 접근하지 않으므로 redis에 대한 부하를 줄일 수 있다.

 두 가지 방식 중 무엇이 더 좋은지에 대한 답은 없다고 생각한다. 현재 프로젝트에서는 redis에 대한 부하를 세션 방식에 비해 더 줄일 수 있고, 장기적으로 확장성이 높은 JWT를 선택했다. 

 

인증 로직에는 2개의 토큰이 사용된다.

토큰 종류 설명 저장소
access token 사용자 유효성을 검증하는 토큰 local storage 등
refresh token access token을 검증 http-only cookie

로그인 / 인가 로직은 다음과 같다.

로그인

  1. 유저 로그인: access token은 만료 시간과 함께 json으로, refresh token은 쿠키로 전달한다.
  2. 유저 요청: Authentication: Bearer ~헤더에 유효한 토큰을 담아 사용자가 요청을 보낸다.
  3. 토큰 검증: AuthGuard에서 토큰의 유효성을 검증한다.
    1. 토큰이 유효함: 사용자 정보를 request 객체에 저장하고, 요청을 수행한다.
    2. 토큰이 유효하지 않음: 401 상태코드와 함께 요청을 거절한다.
  4. 토큰 갱신: GET /auth/refresh 요청을 보내면 refresh token이 서버로 전달된다.
    refresh token의 유효성을 검증하고, 유저 정보를 access token으로 만든 후 반환한다.

검증

refresh token을 검증하면 유저 정보와 refresh_key를 얻을 수 있다.

  1. 로그인 시 유저 id: refresh_key를 redis에 저장한다. refresh_key는 로그인할 때마다 갱신된다.
  2. refresh_token이 유효할 때 redis와 토큰 상의 refresh_key의 동일성을 검사한다.
  3. 두 값이 동일하다면 현재 refresh token은 최신 로그인에 의해 발행되었으므로 유효하다.
  4. 두 값이 다르다면 현재 refresh token은 유효하지 않습니다. 토큰을 제거한다.

 

  •  refresh token의 경우 refresh token 자체를 redis에 저장하는 것이 아니라, 내부 페이로드 중 refresh_key만 저장한다. refresh_key는 현재 토큰이 유효함을 나타내는 랜덤 문자열로, 로그인할 때마다 갱신된다.
  • 유저가 새로운 환경에서 로그인을 하면 기존 refresh token에 담긴 refresh_key는 redis 상의 refresh_key와 다른 값을 가지므로 유효하지 않은 토큰으로 판단된다. 해당 토큰을 쿠키에서 제거한다. 
export interface IOutAdminUser extends Pick<AdminUser, 'id' | 'name'> {
  id: number;
  name: string;
}

export interface RefreshTokenObj {
  data: IOutAdminUser;
  refresh_key: string;
}

refresh_key

refresh key 동작 방식

 refresh token은 사용자의 access token이 유효하지 않을 때 갱신하기 위한 목적을 가지고 있다. refresh token이 탈취당하게 되면 유효 기간이 전부 지나기 전까지 access token을 계속 생성할 수 있으므로 보안적 측면에서 큰 문제가 생긴다.

 나는 로그인 시마다 각 refresh token을 식별하기 위한 랜덤 문자열이 있다면 refresh token 자체가 탈취되더라도 재 로그인을 통해 문제를 해결할 수 있을 것이라고 생각했다. 이에 대해 랜덤 문자열인 refresh_key를 두고 토큰 / redis 상에 저장된 값을 비교, 같은 경우에만 올바른 토큰으로 판단하는 로직을 작성했다. 

 랜덤 문자열 refresh_key는 유저가 로그인 시 redis 저장소에 유저의 id 값을 기반으로 저장된다. 사용자가 access token을 갱신하는 경우 다음 순서에 따라 JWT를 검증한다.

  1. 비밀 키를 통해 refresh token을 검증하여 페이로드를 얻는다.
  2. 페이로드의 유저 id를 기반으로 redis 상의 refresh key를 가져온 후 두 값을 비교한다.
    1. 두 값이 같다면 refresh token은 유효하다. access token을 payload 기반으로 만들어 반환한다.
    2. 두 값이 다르면 refresh token은 유효하지 않다. refresh token을 쿠키에서 제거한다.

 만약 가지고 있는 refresh token을 생성한 이후 다른 환경에서 다시 로그인을 진행했다면 redis 상의 refresh_key는 변경되었으므로, 내가 가진 refresh token은 유효하지 않아 access token을 생성할 수 없다.

 

  async refreshAccessToken(refresh_token: string): Promise<AccessTokenInfo> {
    const exception_message = 'refresh token is not valid';
    let payload: RefreshTokenObj;

    try {
      // refresh token 인증
      payload = await this.jwtService.verifyAsync(refresh_token, {
        secret: this.config.get('JWT_REFRESH_SECRET'),
      });
    } catch {
      throw new UnauthorizedException(exception_message);
    }
    // refresh token 내부 값 검증 -> RefreshTokenType
    const isValid = this.tokenValidator.validateRefreshToken(payload);
    if (!isValid) {
      throw new UnauthorizedException(exception_message);
    }

    // refresh key 비교 -> 다르면 이미 만료된 토큰
    const refresh_key = await this.tokeninfoService.getTokenInfo(
      payload.data.id,
    );
    if (!refresh_key || refresh_key != payload.refresh_key) {
      throw new UnauthorizedException(exception_message);
    }

    // access token 만들어서 반환
    const access_token = await this.signAccessTokenWithExp(payload.data);
    return access_token;
  }

간단한 방법으로 refresh token이 탈취되더라도 무효화할 수 있도록 만들었다. refresh_key는 페이로드에 노출되어 있지만 서버 상에서 비밀로 관리하는 JWT_REFRESH_KEY를 모르는 이상 서버의 검증을 통과할 수 있는 토큰을 만들 수 없으므로 보안 측면에는 큰 문제가 없을 것이라고 생각했다.