JWT 이해하기

불꽃남자·2021년 7월 16일
1

서론

이전 포스트에서는 회원가입과 로그인 API를 만들었다.

이제 유저가 로그인이 필요한 API를 요청할 때에, 해당 유저가 로그인이 되었는지 안 되었는지를 확인하는 기능이 필요하다.

인증과 인가

우선 다루고 있는 것이 인증인가 에 대한 부분이니만큼 둘의 차이에 대해 인지하고 있어야한다.

회원가입 / 로그인은 인증에 관한 기능을 한다. 유저는 회원가입 / 로그인을 통해 자신이 이 서버의 회원임을 인증할 수 있다.
하지만 유저가 권한이 필요한 기능에 접근하려고 할 때마다 로그인을 통해 인증을 해야한다면 UX는 저하된다. 생각해보라, 보통 로그인을 한 번 하고 나면 권한이 필요한 기능에 접근할 때에 다시 로그인을 하지 않아도 접근이 가능했지 않은가? 반면 매번 로그인을 해야한다면 상당히 불편할 것이다.

이것이 어떻게 가능할까? 그것은 클라이언트든 서버든 기능에 접근하려는 유저가 권한을 이미 가지고 있음을 알고 있기 때문이다. 즉, 이 유저가 인가 되었음을 알고 있기 때문이다.

그리고 JWT는 인가에 관한 기능을 한다.

왜 JWT인가?

HTTP 통신은 Connectionless이며 Stateless다. 무슨 말인고 하면 서버는 어떤 요청에 대해 응답하고 나면 그걸로 연결이 종료되며, 요청을 보내온 상대를 식별할 수 없다.

식별을 하기 위해서는 클라이언트가 인증을 하면 서버에서 해당 클라이언트를 식별할 수 있는 일종의 ID를 주고, 클라이언트에서는 인가가 필요한 API에 접근할 때에 해당 ID를 서버에 전송함으로써 인가된 클라이언트임을 서버에 알리는 것이 현재의 보편화된 인가 방식이다.

과거에는 세션이 대세였다

과거에는 인가를 위해서 세션을 보편적으로 사용했었다.
잠시 세션은 어떤 원리로 인가를 하는지 가볍게 살펴보자.

  1. 클라이언트가 인증에 성공하여 Session ID를 요청한다.
  2. 서버는 Session ID를 Cookie로 발급하고 해당 Session ID를 서버 메모리에 저장한다.
  3. 클라이언트가 인가가 필요한 API로 요청을 보낸다. Session ID와 함께.
  4. 서버는 Session ID를 검증하고 API를 응답한다.

이후에 인가를 취소하는 요청(예를 들면 로그아웃)이 들어오면 해당 Session ID를 지운다. Session ID가 저장된 메모리에는 현재 인가된 상태의 유저들의 Session ID가 저장되어 있는 것이다. 그래서 실시간으로 인가된 유저들을 관리하기에 용이하다.

허나 Session ID를 서버 메모리에 저장한다는 것이 치명적인 단점이다.

  1. 첫째로 접속 유저가 많아지면 서버에 점점 부담이 갈 것이고, 서버가 수용할 수 있는 메모리를 넘어서면 서버가 다운되거나 처리 속도가 느려져 제대로 서비스를 제공하지 못 하게 된다.
  2. 둘째는 로드밸런서에 관한 문제다.
    로드밸런서는 서버에 가해지는 부하를 분산하기 위해 요청을 각각 다른 서버로 분산시키는데, 요청이 Session ID를 발급하고 저장한 서버가 아니라 다른 서버로 분산되면 오류가 생긴다.
    이를 해결하기 위해 Session ID를 발급할 때 Cookie에 해당 유저가 요청할 때에 어느 서버로 로드밸런싱 될지 명시하는 방법을 사용하는데, 이 또한 문제가 될 수 있는 부분은 한 서버에 유저가 몰려서 서버에 부하가 일어날 수 있다. 로드밸런서의 의미가 퇴색되는 부분이다.
  3. 셋째는 쿠키와 웹뷰에 관한 문제다. 갑자기 왠 웹뷰 이야기가 나오는 것인가?
    세션은 쿠키를 통한 통신이 강제된다. 그런데 웹뷰에서는 쿠키를 사용할 때에 저장되었던 쿠키가 삭제되는 둥의 이슈가 간헐적으로 발생한다. 물론 이 이슈는 웹뷰와 쿠키를 동기화하는 방법으로 해결이 가능하긴 하다.

이런 단점들을 해결하기 위해 나온 것이, 바로 JWT다.

JWT는 어떻게 작동하는가?

큰 흐름은 세션과 같다.

  1. 클라이언트가 인증에 성공하여 Access Token을 요청한다.
  2. 서버는 Access Token을 발급한다. JWT는 굳이 Cookie가 아니더라도 어디에나 저장 가능하다. JWT 공식문서에는 Bearer schema의 Authorization header
  3. 클라이언트가 인가가 필요한 API를 요청한다. Access Token과 함께.
  4. 서버는 해당 Access Token을 검증 후 적절한 응답을 한다.

세션 방식과 다른 점은 서버에 뭔가 저장하지 않는다는 것이다. 이것이 어떻게 가능한지 알아보자.

JWT의 구성요소

JWT는 Header, Paylaod, Signature로 구성되어있고, 각 요소는 . 으로 구분된다.


실제 JWT의 모습이다.

Signature를 제외한 각 요소는 JSON 객체이다. 이렇게 문자열의 형태를 띄고 있는 것은 base64 방식으로 인코딩되었기 때문이다.
이 문자열의 인코딩 되기 이전의 모습은 이렇다.

이제 각 구성요소가 갖는 의미에 대해 알아보자.

JWT의 Header에는 해싱 알고리즘 방식을 명시하는 alg와 토큰 타입을 명시하는 typ가 들어있어야 한다.

Payload

Payload는 data를 담는 곳이다. 그리고 이 부분이 JWT가 서버에 정보를 따로 저장하지 않고 사용자를 식별하고 HTTP 통신 환경을 개인화 할 수 있는 핵심적인 부분이다.

Payload에 담기는 Key-Value 한 쌍을 Claim이라고 부른다.
그리고 Claim에는 세 종류가 있다.

  • Registered claim
    토큰에 대한 정보를 담기위한 claim으로, 이미 이름이 정해져 있다.
    Registered claim은 RFC 7519 4.1에 명세되어 있다. 총 7개이다.
  • Public claim
    JWT를 사용하는 유저들에 의해 임의로 지정될 수 있으나, 이름의 충돌을 방지하기 위해 IANA JWT 레지스트리에 이름을 정의하거나, UUID, OID, URL 형식의 이름으로 지정해야한다.
    라고 소개되어 있는데, 왜 그렇게 해야하는지, Private와 정확히 무엇이 다른 것인지 어디에 쓰이는 것인지를 모르겠다.
  • Private claim
    Registered claim도 아니고, Public claim도 아닌 claim을 Private claim이라고 한다.
    Private claim은 서버와 클라이언트가 Key-Value를 정해놓고 자유롭게 사용 가능하다.

Signature

Signature는 Header와 Payload를 각각 base64 방식으로 인코딩 하고 하나의 문자열로 합친 다음 임의의 Sceret key를 사용해 HMAC SHA-256 방식으로 암호화한 값이다.
그래서 Payload의 내용이 한 끗만 바뀌어도 Signature값은 송두리째 바뀐다.

JWT는 Token을 검증할 때에 Header와 Payload를 해당 Token을 발급할 때 사용했던 Secret key를 이용해 Signature 값을 만들고, 이 값과 해당 Token의 Signature 값이 일치하지 않는다면 검증은 실패한다.(나는 처음에 이 과정을 잘 이해 못 했었는데, 어떻게 잘 설명이 되었길 바란다.)

그러니 악의적인 유저가 Token의 Payload를 decode해서 임의로 값을 바꾼 다음 encode하는 둥의 조작을 가해도 Secret key를 모른다면 Signature 값을 일치시킬 수 없어 검증을 통과할 수 없다.

JWT는 Token 자체가 데이터다

이렇듯 JWT는 Token 자체에 데이터가 모두 담겨있기 때문에, 서버에 따로 정보를 저장해두지 않아도 요청을 보낸 사용자에 대해 어느정도 식별이 가능하다. 이것이 세션 방식과의 차이점이다.

JWT Token을 어디에 저장해야 할까?

클라이언트에서 서버로 Token을 전달 할 때에 사용할 수 있는 경로가 3개 정도 있다.

  1. request.body로 Token 전달 (권장되는 방법은 아니다. 왜냐하면 request.body를 사용할 수 없는 HTTP METHOD가 몇 있기 때문이다.)
  2. 요청의 query parameter (그 길다란 문자열이 parameter에 그대로 노출되는 건 별로 보기 좋은 모습은 아니라고 생각한다.)
  3. Cookie로 Token 전달 (생각보다 많이 쓰이는 방법이다.)
  4. HTTP Authorization Header로 전달 (RFC 7519 명세에 따르면 이 방법이 권장되고 있다. 그러니까 이 방법이 표준이라는 이야기이다.)

보통 Cookie 혹은 Authorization header에 Token을 담아 요청을 보낸다.(항상 그렇지만 내가 모르는 방법이 있을 수도 있다. Authorization header의 존재도 이 포스트를 쓰다가 알았다.)

JWT를 사용할 때에 Access token과 Refresh token을 발급하고 Access Token의 만료기간을 1시간 혹은 그보다 더 짧게 설정하고 Refresh token의 만료기간은 1주 혹은 2주 정도로 설정한다.
그리고 서버에서는 Access token이 만료되었으나 Refresh token이 유효하다면 Access token을 다시 발급해주는 로직을 짜놓는다.
이렇게 하면 Access token이 탈취당해도 만료기간이 짧으므로 해킹으로 인한 피해를 줄일 수 있다는 이야기이다. 물론 Refresh token이 탈취당하면 그게 그거 아닌가 싶긴 하지만..

아무튼 갑자기 Access token과 Refresh token을 설명하는 이유는 두 Token의 저장 위치를 달리하는 방법을 설명하고 싶어서다. Access token은 Authorization header에, Refresh token은 Cookie에 저장하는 방법이다. 자세한 시나리오를 보자.

서버에서 인증된 유저에게 응답할 때에 Access token은 request.body로, Refresh token은 Cookie로 발급한다. 클라이언트는 Access token를 받으면 인가가 필요한 API 요청의 Authorization header에 즉시 삽입한다. Refresh token은 Cookie에 저장되어 있으니 자동으로 요청에 포함된다. 그러므로 Access token이 만료되거나 사용자가 브라우저를 껐다 키거나 새로고침 해서 Access token이 사라진다고 해도 Refresh token에 의해 Access token이 재발급 된다.

처음에는 "로컬 변수에 Token을 저장하면 JS코드로 접근 가능한 거 아닌가?" 싶었는데, 이런 보안에 민감한 부분을 다루는 웹을 순수 JS로 만들 리는 없고, 순수 JS를 사용한다 하더라도 클로저로 변수를 보호하면 되고, React나 Vue같은 프레임워크를 사용한다면 JS코드로 변수에 접근할 수 없으니 그 부분은 안전하다고 생각한다.

보안에 관한 부분은 여기서 다루자면 끝이 없을 것 같다. JWT 사용하기 편에서 어떤 보안책을 적용했고 왜 그랬는지 이야기를 해 볼 생각이다.

🌟

솔직히 이렇게 길어질 줄 몰랐다! 코드는 보안에 관련되기만 하면 배워야 할 게 너무 많아진다.

이 포스트를 쓰기전에는

  1. 유저 인증하고
  2. 인증 된 유저에게 JWT를 발급하고
  3. API 요청시 JWT 검증

여기까지 구현하고 끝인줄 알았다. 근데 포스팅하려고 알아보기 시작하니 너무 빙산의 일각만을 알고 있었다.

다음 포스트에선 실제로 서버에 JWT를 적용시키는 부분을 다룰 예정이다.


이틀 전에 면접을 봤다. 처음 보는 면접이라 긴장을 했는지 잘못된 대답을 두 어개 정도 했다... 면접 내용을 복기하고 있으려니 기술적인 질문도 그렇고 인적인 질문에도 내가 이상하게 대답을 했었다는 걸 깨달아서 내가 긴장을 하긴 했구나 싶었고, 다음에 면접을 볼 때는 정신을 더욱 집중해야겠다 느꼈다.

결과는 어땠냐고? 떨어졌다! 자가진단을 하기로는 아무래도 포트폴리오에 문제가 있다고 생각한다. 포트폴리오 하나하나의 개발기간이 되게 긴데, 왜 길어졌는지 정확히 설명을 하지 못 하기도 했고 나 자신을 모두 포트폴리오에 녹이지 못 했다고 생각한다. 질문에 이상하게 답을 한 것도 원인이라고 생각한다.

아쉽지만 어쩌겠는가! 그럼에도 앞으로 나아가야한다. 하지만 구직기간이 너무 길어져서 단기 아르바이트라도 병행해야겠다 싶다.

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글