JWT를 활용한 인증 및 인가(로그인, 로그아웃, 회원탈퇴) 기능 구현 방법에 대해 작성하기 전, 기본 개념부터 정리해두려고 한다. 사실 완벽하게 이해하고 구현했다기보단 최소한의 필요한 게 뭔지 정도만 알고 구현했던 거라... 다른 프로젝트에서 기능(ADMIN, USER 구분 등)을 추가해야 된다거나 할 때 자신 있게 수정할 수 있을 때까지 이해하는 게 목표다. 🔥
HTTP 기본 개념
서버와 클라이언트 사이의 모든 Request와 Response는 HTTP를 기반으로 동작한다.
1. 무상태 프로토콜 - Stateless
서버가 클라이언트의 이전 상태를 보존하지 않는다.
- 따라서 클라이언트는 요청 시 서버에 필요한 모든 정보를 제공해야만 응답을 받을 수 있다.
- 서버는 상태를 보존할 필요가 없기 때문에 클라이언트 요청이 증가하는 대규모 트래픽 상황이나 서버에 장애가 생기더라도 수많은 응답 서버를 투입할 수 있다는 장점이 있다.
물론 모든 것을 무상태로 설계할 수는 없다. 예를 들면 로그인의 경우, 로그인 상태를 서버에 유지해야 한다. 일반적으론 브라우저 쿠키와 서버 세션 등을 사용해 상태를 유지한다.
이 부분이 핵심이다. 클라이언트는 사용자가 로그인했음을 인증하기 위해 쿠키 또는 세션을 사용해야 한다. 쿼리 스트링에 사용자 정보를 담는 방식도 있지만, URL에 민감한 정보가 보이면 보안에 문제가 생길 수 있다.
2. 비 연결성 - Connectionless, Non-Persistent
서버는 연결을 유지하지 않고, 요청에 응답한 뒤 연결을 종료한다. 따라서 최소한의 자원만 유지할 수 있다.
- 보통 초 단위 이하의 빠른 속도로 응답하기 때문에 1시간 동안 수 천명이 서비스를 사용하더라도 실제 서버에서 동시에 처리하는 요청은 수 십 개 이하로 매우 적다.
3. 헤더와 메시지 바디
HTTP 헤더
- HTTP 전송에 필요한 모든 부가 정보가 담긴다. 필요시 임의의 헤더를 만들어 추가할 수 있다.
HTTP 메시지 바디
- 실제 전송할 데이터로, HTML과 문서, 이미지, 영상, JSON 등 byte로 표현할 수 있는 모든 데이터가 전송될 수 있다.
HTTP에 대한 추가 기본 개념은 아래 글에서 참고하자.
쿠키(Cookie)
1. 개념 & 인증 방식
클라이언트가 로그인에 성공하면, 서버에서는 헤더에 Set-Cookie로 사용자의 정보를 담아 응답한다. 클라이언트는 응답으로 받은 Cookie를 웹 브라우저에 있는 쿠키 저장소에 저장한다. (ex. user="kim")
로그인 이후 welcome 페이지에 접근 시, 쿠키 저장소에서 자동으로 사용자의 모든 요청 헤더에 Cookie를 담아서 전송한다. 쿠키 정보는 로그인 상태를 유지해야 하는 모든 요청에 대해 서버에 전송돼야 하기 때문에 네트워크 트래픽을 유발하게 된다. 따라서 세션 ID나 인증 토큰 등 최소한의 정보만 사용해야 한다.
서버는 쿠키에 담긴 정보를 바탕으로 사용자 인증을 진행한다.
2. 단점
헤더에 Cookie 정보를 그대로 담아 보내기 때문에 요청 메시지만 열어봐도 누구나 사용자의 민감한 정보를 확인할 수 있다는 단점이 있다.
- 물론 보안을 위해 HTTPS인 경우에만 전송할 수 있게 하거나, 경로와 도메인에 제한을 둔다거나, XSS 공격과 XSRF 공격을 방지할 수 있도록 돼있지만 탈취되면 데이터가 바로 보인다는 게 가장 큰 문제다.
세션(Session)
1. 개념 & 인증 방식
민감한 정보를 그대로 전달해야 하는 쿠키의 보안적 단점을 해결할 수 있도록, 사용자의 정보를 서버 단에 저장하고 관리하는 방식이다.
사용자가 로그인을 요청하면 서버 단에서 사용자를 확인하고, 세션 저장소에 회원 정보 세션을 만든다. 이후 Set-cookie로 사용자의 정보 대신 sessionId를 응답에 담아 전송하고, 클라이언트는 그 정보를 쿠키 저장소에 저장한다.
로그인 이후 클라이언트는 모든 요청 시 쿠키에 sessionId를 담아 전송하고, 서버 단에선 요청과 함께 들어온 sessionId를 세션 저장소에서 확인한 뒤 사용자 인증을 진행한다.
2. 단점
sessionId 자체엔 사용자의 정보가 들어있지 않기 때문에 탈취돼더라도 민감한 정보가 바로 노출되는 문제는 해결했다. 그러나 세션 저장소를 유지해야 하므로 서버 단에 부하가 발생할 수 있다는 문제가 생긴다.
토큰(Token) & JWT(JSON Web Token)
1. 개념
쿠키는 민감한 데이터의 노출 문제, 세션은 서버 부하 문제가 있었다. 두 문제를 해결하기 위해 나온 게 JWT(JSON Web Token)이다. 공식 문서를 보면서 더 자세히 알아보자.
JWT를 사용하면 민감한 정보를 JSON 객체로 안전하게 전송할 수 있다. 민감한 정보를 암호화하여 전송한다는 게 가장 큰 특징이다. HMAC 알고리즘을 사용한 secret 키를 이용하거나, RSA나 ECDSA를 사용한 공개키&개인키 쌍을 이용해 민감한 정보에 대해 디지털 서명을 진행한다.
JWT는 .(점)으로 구분되는 3개의 부분으로 나눠져 있다. 'Header.Payload.Signature'로 보면 된다.
Header
헤더에는 토큰의 타입(JWT)과 정보를 서명하기 위해 사용한 알고리즘(HMAC SHA256 || RSA)에 대한 두 가지 정보가 담겨 있다. Base64Url로 인코딩 되어 첫 부분에 들어간다.
{
"alg": "HS256",
"typ": "JWT"
}
Payload
서버에 보낼 데이터인 claim을 담고 있다. 여기서 claim이란 엔티티(ex. 회원)에 대한 상태와 추가적인 정보를 말한다. claim은 3가지 유형이 존재한다. (registered, public, private) Base64Url로 인코딩 되어 중간 부분에 들어간다.
서명된 토큰에 대해, JWT가 탈취되면 이 데이터는 누구나 읽을 수 있다. 따라서 JWT가 암호화되지 않았다면, 민감한 정보를 Header나 Payload에 저장해선 안 된다.
참고: 최소한의 정보만 담기 위해 모든 claim의 이름은 3글자로 되어 있다.
- Registered claims
- 필수적이진 않지만, 유용하고 상호 운용 가능한 claim을 제공하는 미리 정의된 claim이다.
- ex. iss(issuer), exp(expiration time), sub(subject), aud(audience)
- Public claims
- JWT를 사용하는 누구나 정의할 수 있는 claim이다.
- 충돌을 피하기 위해, IANA JWT Registry로 정의되거나, 충돌 저항 namespace를 포함한 URI로 정의돼야 한다.
- Private claims
- JWT를 주고받는 클라이언트와 서버가 정보를 공유하기 위해 서로 약속해서 만든 claim이다.
- 위의 두 claim과는 달라야 한다.
{
"sub": "12345",
"name": "Kim",
"admin": true,
...
}
Signature
메시지가 도중에 변경되지 않았는지 확인하는 데 사용된다. 개인키로 서명된 토큰의 경우, JWT를 보낸 사람이 JWT의 발신자인지 확인할 수 있다.
이 부분을 만들기 위해, Header와 Payload, secret 키, Header에 명시된 알고리즘이 모두 필요하다. 예를 들어 HMAC SHA256 알고리즘을 쓰고 싶다면, Signature은 아래와 같이 생성되어 마지막 부분에 들어간다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
2. Decoding
아래 링크에서 JWT와 사용한 알고리즘을 입력하면 Header와 Payload, Signature 값을 알아낼 수 있다.
아래처럼 Header와 Payload가 바로 보이기 때문에 민감한 정보(ex. 비밀번호)는 저장하면 안 된다.
2. 인증 방식
클라이언트가 로그인을 요청하면 서버는 사용자의 정보(이름, 아이디 등)를 Payload에 담고 JWT를 발급한다. 클라이언트는 발급받은 토큰을 따로 저장해 두고, 로그인 이후의 모든 요청에서 Authorization 헤더에 해당 토큰을 넣어 전송한다.
서버는 요청에서 Authorization 헤더를 추출해 토큰을 확인하고, 유효성과 만료 기간 등을 판단하여 사용자 인증을 진행한다. 이때, 유효성 검증은 토큰의 Payload에 담긴 사용자 정보가 SecurityContextHolder(≒세션 저장소)에 저장돼있는지 확인하는 방식으로 이루어진다.
참고: 쿠키를 사용하지 않기 때문에 CORS 문제는 신경 쓰지 않아도 된다는 장점이 있다.
Authorization: Bearer <JWT>
3. 단점 & Refresh Token
JWT엔 쿠키와 세션에 담기는 값보다 훨씬 긴 값이 들어가기 때문에 인증 요청이 많을수록 네트워크 부하가 심해지게 된다. 또, 한번 JWT를 생성하면 저장하고 확인하는 것 외엔 수정이나 삭제가 불가능하다는 단점도 있다. 따라서 토큰을 탈취당하게 되면 유효 기간이 만료되지 않는 이상 마음대로 사용할 수 있기 때문에 보안상 문제가 발생할 수 있다.
- 위의 문제를 해결하기 위해 JWT를 Access Token과 Refresh Token으로 나눠 사용한다. 클라이언트는 로그인 이후 두 토큰을 헤더에 담아 전송해야 한다.
Access Token
Payload에 사용자 정보가 담긴 JWT다. 탈취되면 설정해 둔 유효 기간 동안 탈취자가 마음대로 사용할 수 있기 때문에 보통 유효 기간을 서비스를 이용하기에 충분한 2시간 정도로 설정해 발급한다. 서버에선 Access Token의 유효성과 만료 여부 등을 검증해 사용자 인증을 진행한다.
Refresh Token
Access Token을 재발급하기 위해 사용하는 JWT다. Payload에 민감한 정보를 넣지 않아 탈취되더라도 보안상으로 크게 위험하지 않다. Refresh Token의 유효 기간은 보통 2주 정도고, 길면 한 달까지 설정하기도 한다.
요청과 함께 들어온 Access Token이 만료된 경우, 서버에서 Access Token이 만료됐다는 응답을 보낸다. 응답을 받은 클라이언트에선 Refresh Token을 통해 Access Token 재발급 요청을 해야 한다. 이후 서버에선 해당 Refresh Token을 블랙리스트화하고, 새로운 Access Token과 Refresh Token을 발급해 응답한다.
참고자료