JWT를 이용한 로그인(로그아웃)

·2022년 6월 8일
8

팀 프로젝트

목록 보기
28/34
post-thumbnail

코드를 볼 수 있는 곳으로 이동합니다! 클릭시 이동


오늘 쓸 내용은 유저가 생각하기에 제일 기본인 로그인이다.

나는 개발자로 배우기 전에는 로그인 / 로그아웃이 엄청 쉬울 것이라고 생각했다.
하지만....생각보다 말도 안되게 복잡하고 난해하다는 것을 알게 되었다.

인증과 인가

일단 두개의 용어를 알고 가야한다.

인증(Authentication)이란 유저가 어떤 사람인지 확인을 하는 것이고
인가(Authorization)이란 그 유저에게 행동을 할 수 있는 권한을 부여해주는 것이다.

결국은 로그인을 하는 것이 인증
로그인 상태를 유지시켜주는 것이 인가라고 정리할 수 있다.

로그인 기능을 구현하는 방식에는 2가지가 존재하는데
세션(session)을 사용하는 방식과 쿠키(cookie)를 사용하는 방식이 존재한다.

두 개는 서로 장점과 단점이 존재하는데
세션은 보안성이 좋지만, 서버에 부하를 줄 수 있고
쿠키는 서버의 부하가 적지만, 보안에 문제가 있을 수 있다.

하지만 시간이 흐르면서 쿠키의 단점이였던 보안을 개선할 수 있게 되면서
쿠키를 활용한 로그인 방식을 상당히 많이 쓰게 되었다.

그리고 JWT라는 것이 생겨남에 따라 쿠키를 사용한 방식이 많이 선호되고 있다.

JWT란?

JWT는 JSON Web Token의 약자로 암호화를 해서 쌍방향에서 쓸 수 있는 서명된 토큰을 만들기 수 있는 수단이다.

JWT는 크게 3개의 파트로 분리가 되어있는데 .을 기준으로 분리가 되어있다.

header에는 암호화를 시킨 알고리즘이 어떤 것인지, 토큰의 타입이 무엇인지 명시되어있다.

Payload

payload에는 암호화가 되어있는 정보와, 토큰의 발생 시간과 만료 시간이 명시되어있다.

여기서 주의해야할 점은 payload는 아주 쉽게 내용을 확인할 수 있다는 점이다.
그렇기에 정말 핵심적인 정보는 담으면 안되고,
유저를 판가름할 수 있는 유니크한 값을 넣어야한다.

Signature

signature은 서명인데, 여기가 실질적인 가치가 있는 곳이다.
위의 내용을 보면 그저 헤더와 페이로드는 base64로 인코딩이 되어있는 것 뿐인데.

실질적으로 위변조를 확인하는 부분은 your-256-bit-secret라고 적혀있는 부분이다.

이 비밀키는 서버에서 지정한 값이고,
내가 알고있는 것으로는 값은 고정이지만 토큰이 발생될때마다의 문자는 변한다.
(salting이 여기서 된다고 알고있다.)

그렇기에 헤더와 페이로드를 base64로 인코딩하고 다른 토큰에 붙이더라도 인증이 되지 않을 것이다.


AccessToken 발급하기 (로그인 구현)

이런저런 문제점이 나오는데, 그것은 시간 순서대로 읽어가면서 정리를 하려고 한다.
흔히 로그인을 할 때 AccessToken(액세스토큰)을 발급해서 그것을 사용한다고 보편적으로 이야기한다.

AuthResolver.ts

 @Mutation(() => Token)
  async login(
    @Args('userEmail') userEmail: string, //
    @Args('userPassword') userPassword: string,
    @Context() context: any,
  ) {
    const user = await this.authService.isUser({ userEmail, userPassword });
    await this.authService.setRefreshToken({ user, res: context.res });
    return await this.authService.getAccessToken({ user });
  }

위의 로직은 이렇게 진행된다.

  1. 유저는 이메일과 비밀번호를 작성한 후 로그인 버튼을 누른다.
  2. 첫번째 코드에서 이 유저가 해당하는 유저가 맞는지 검증하는 로직이 돌아간다.
    2-1 해당하는 유저가 아닐 경우 에러를 뱉으면서 멈춘다.
  3. 유저에게 리프레시 토큰을 붙여준다.
  4. 유저에게 액세스 토큰을 붙여준다.

사실상 3,4번은 한번에 이루어진다고 생각하면 된다.

3번은 나중에 설명하고, 4번부터 확인을 해보겠다.

AuthService.ts

import { JwtService } from '@nestjs/jwt';
  async getAccessToken({ user }) {
    const Access = this.jwtService.sign(
      { userEmail: user.userEmail },
      {
        secret: this.config.get('ACCESS'),
        expiresIn: '30m',
      },
    );
    const obj = {};
    obj['accessToken'] = Access; 
    // 그냥 리턴해도 되는데 객체로 만들어서 보내달라길래 이렇게 되어있다.
    return obj;
  }

위의 코드를 확인해보면 jwtService.sign을 통해서 액세스 토큰이 생성되는 로직이 존재한다.

여기서 중요한 것은
첫번째 중괄호 속에는 payload에 들어갈 데이터의 값 을 명시한다는 것.
두번째 중괄호 속에는 signature에 secret파트에 들어가는 서버단의 시크릿 키
그리고 토큰의 지속시간이 명시되어있는 것을 알 수 있다.

시크릿키는 알려질 경우 문제가 있기에 환경변수 속에 감춰져있다.


토큰이 지속되는 시간이 즉 로그인이 유지되는 시간이라고 생각을 하면 된다. (인가 시간)
그런데 코드를 유심히 보면 지속시간이 30m(30분)으로 상당히 짧은 것을 볼 수 있다.
왜냐하면 액세스토큰은 클라이언트 단에 저장을 하고 있기에 탈취를 당하기 너무 쉬운데

이미 발급된 토큰 자체를 무효화할 수 없다.

즉 아무리 짧은 시간이더라도, 토큰이 탈취당할 경우 해당 사용자인척 할 수 있다는 문제가 발생하는데
이 문제를 해결하기 위하여 도입된 것이 RefreshToken 이라는 것이다.

RefreshToken?

RefreshToken(이하 리프레시토큰)은 클라이언트에 저장을 하는 것이 아니라
쿠키 혹은 로컬 스토리지 에 저장을 함으로써

액세스토큰이 만료되었을 경우 리프레시토큰을 기반으로
유저를 인증하여 인가상태를 유지할 수 있도록 만들어주는 역할을 한다.

그럼 리프레시토큰이 발급되는 코드를 확인해보자.

이후 설명되는 코드상에서는 쿠키에 리프레시토큰을 저장한다!

AuthService.ts

  async setRefreshToken({ user, res }) {
    const refreshToken = this.jwtService.sign(
      { userEmail: user.userEmail },
      { secret: this.config.get('REFRESH'), expiresIn: '2w' },
    );

    await res.setHeader(
      'Set-Cookie',
      `refreshToken=${refreshToken}; path=/; Secure; httpOnly; SameSite=None;`,
    );
}

확인을 해보면 jwtService.sign의 파트가 대부분 같지만, 한가지만 다르다.

바로 유효시간이 2w(2주)로 확연하게 길다는 것을 확인 할 수 있다.

보안이 그만큼 보장이 되고, 한번 로그인을 한 유저가 편하게 사용을 하기 위해서 유효기간을 길게 해놓은 것이다.

물론 보안이 걱정된다면 유효기간을 짧게 줄이는 것도 방법이라고 생각한다.
유저 입장에서는 어 로그인 풀렸네 다시 로그인해야지 라고 생각을 하지 않을까.

그리고 아래로 내려가면 res.setHeader라는 것이 존재하는데, 이것이 바로 쿠키를 적용하는 코드다.

헤더에 값을 적용할 때, 접두어에 Set-Cookie라고 적을 경우에는 그것이 쿠키에 담기게된다.

여기서 잠깐!

쿠키를 활용할 때에는 외부의 공격이 올 수 있는데 그것을 방지하기 위해서는 특정한 옵션이 필요하다.

1. XSS 공격

Cross Site Scripting (이하 XSS) 공격은 웹사이트에 악성 스크립트를 실행하는 것을 이야기한다.

그래서 이것을 막는 옵션은 위의 코드 중 Secure; httpOnly; 이 2가지다.

httpOnly는 http 통신을 외에는 쿠키의 접근을 불가능하게 할 수 있다.
즉 스크립트에 의한 공격은 원천차단이 가능하다.

하지만 스크립트가 아닌 http 공격의 경우에는 막을 수 없는데, 그래서 사용하는 것이 Secure 옵션이다.

Secure는 https 통신이 아닐 경우 쿠키가 전송되지 않는 옵션이다.
기본적으로 https 통신에서는 직접적으로 값을 볼 수 없고, 암호화가 되어있는 값으로 통신이 되기 때문에 최종적으로 XSS 공격을 방지할 수 있다.

2. CSRF 공격

Cross Site Request Forgery (이하 CSRF) 공격은 웹사이트 상에서의 요청을 위조하는 것을 이야기한다.

이것을 막는 옵션은 SameSite라는 옵션인데. 이것은 단순하게 선언하는 것이 아니라 추가적으로 더 적어줘야만 사용할 수 있다.

총 3가지가 존재하는데

  1. None : 도메인 검증 X 어디서든 사용 가능하나 secure 옵션 필수
  2. Lax : 외부 링크도 접근 허용 하지만 get 요청만 OK
  3. Strict : 같은 도메인에서만 쿠키 전송 가능

순서대로 강경한 옵션이 들어간다고 생각하면 된다.

어지간하면 Lax 이상의 옵션을 쓰기를 추천하는 듯 하고
나는 일단 localhost단에서 사용하는 일이 많기 때문에 None + secure 옵션을 걸어놨다.


다시 리프레시토큰으로 돌아와서, 결국 리프레시 토큰을 발급하는 경우에는 아래와 같이 사용하면 된다.

 await res.setHeader(
      'Set-Cookie',
      `refreshToken=${refreshToken}; path=/; Secure; httpOnly; SameSite=None;`,
    );

도메인은 적지 않으면 발급되는 곳이 디폴트값으로 들어간다.


그러면 이제 jwt를 통해서 인증과 인가에 대한 부분이 정리가 됐다.

그럼 이제 로그아웃을 한번 알아보도록 하자.

Redis를 이용한 로그아웃

위에서 잠깐 언급이 됐는데, 한번 발급된 토큰의 경우에는 무효화를 시킬 수 없다고 했다.
그렇다면 로그아웃은 어떻게 구현을 해야할까?

그것은 바로 블랙리스트라는 이름을 사용하는데, 말 그대로 이제부터는 불가능이라고 생각하면 된다.

쉽게 생각하면 발급되어있는 액세스 토큰과 리프레시 토큰을 무효화하면 되는데
이것을 시간을 정해서 데이터를 날릴 수 있는 램기반의 DB, Redis를 사용해서 만들 수 있다.

코드부터 보자!

AuthResolver

  @Mutation(() => String)
  async logout(
    //
    @Context() context: any,
  ) {
    return await this.authService.blackList({ context });
  }

Context는 http통신을 할 때 사용하는 상자라고 생각하면 된다.
사실상 모든 정보들이 다 들어있는데, 이것을 활용해서 로그아웃을 할 수 있다.

콘솔을 찍어보면 이러한 데이터들이 들어있는 것을 확인해볼 수 있다.

헤더 속에 Bearer로 시작하는 액세스 토큰과
cookie라는 이름으로 리프레시토큰이 들어가있는 것을 확인할 수 있다.

AuthService

import * as jwt from 'jsonwebtoken';

 async blackList({ context }) {
    const now = new Date();
    
    const access = context.req.headers.authorization.replace('Bearer ', '');
    const access_decoded = this.jwtService.decode(access);
    const access_time = new Date(access_decoded['exp'] * 1000);
    const access_end = Math.floor(
      (access_time.getTime() - now.getTime()) / 1000,
    );
    
    
    const refresh = context.req.headers.cookie.replace('refreshToken=', '');
    const refresh_decoded = this.jwtService.decode(refresh);
    const refresh_time = new Date(refresh_decoded['exp'] * 1000);
    const refresh_end = Math.floor(
      (refresh_time.getTime() - now.getTime()) / 1000,
    );
    
    try {
      jwt.verify(access, this.config.get('ACCESS'));
      jwt.verify(refresh, this.config.get('REFRESH'));
      await this.cacheManager.set(access, 'accessToken', { ttl: access_end });
      await this.cacheManager.set(refresh, 'refreshToken', {
        ttl: refresh_end,
      });
      return '로그아웃에 성공했습니다';
    } catch {
      throw new UnauthorizedException();
    }
  }

좀 복잡하다(....) 시간을 추적해서 사용하려면, 더 간단하게 하려면 어떻게 해야하는지 아직은 잘 모르겠다.

코드를 순서대로 해석해보면 이렇게 볼 수 있다.

  1. 현재 날짜(시간)을 now 상수에 저장한다.
  2. 헤더 속에 있는 액세스토큰을 짤라서 access 상수에 저장한다.
  3. access_decoded에 액세스토큰을 디코딩한 정보를 저장한다.
  4. access_time 액세스 토큰이 만료되는 날짜를 저장한다.
  5. access_end 액세스 토큰이 만료되는 날짜를 기준으로 현재 날짜만큼을 뺀 값을 저장한다.
  6. 리프레시토큰 2-5까지 반복
  7. jwt 라이브러리를 사용하여 액세스토큰 검증
  8. jwt 라이브러리를 사용하여 리프레시토큰 검증
    8-1. 위의 검증단에서 오류가 발생할 경우 UnauthorizedException 에러 발생
  9. 레디스에 액세스토큰의 정보를 Key 문자열 accessToken을 value, 액세스토큰의 남은 시간을 유지되는 시간으로 저장
  10. 리프레시토큰도 9번과 동일하게 저장
  11. 로그아웃 성공 메세지 리턴

으로 구현이 되어있다.

왜 시간을 저렇게 복잡하게 쓰냐면 시간이 유닉스 시간으로 구현이 되어있기 때문이다.
유닉스 시간 * 1000 + new Date를 사용하면 유닉스 시간을 일반 시간으로 바꿀 수 있어서 저런 과정이 포함되어있다.


보통 로그인이 된 사람만 사용할 수 있는 무언가를 하려면 Nestjs에서는
UseGuards라는 것을 지나쳐야하는데, 로그아웃이 된 상태에서는 유저가드단에서 차단이 되어버린다.

다음 포스팅에 가드에 대한 것이 작성될 예정이긴 한데, 여기서 끊기에는 애매해서 일부 코드만 올려보자면

req에는 context 똑같이 토큰에 대한 정보가 존재하는데

위의 로그아웃 과정을 거칠 경우에는 레디스에 해당하는 액세스토큰이 저장되어있기 때문에 UnauthorizedException가 발생한다.

이런식으로 JWT에서의 로그아웃은 레디스를 통하여 구현할 수 있다.

레디스에 관한 설명까지 하면 완전 글이 산으로 가기 때문에 여기까지만 작성하고, 부가적으로 유저가드에 대한 부분은 다음 포스팅에 올라갈 예정이다.


보충하면 좋을 것 같은 점이라거나 수정할 요소가 있다면 언제든 댓글로 달아주세요!

끝!

profile
물류 서비스 Backend Software Developer

1개의 댓글

comment-user-thumbnail
2022년 10월 7일

좋은 글 남겨주셔서 감사합니다. 참고글로서 퍼가겠습니니다.

답글 달기