프로젝트에서 JWT로 인증작업을 진행하고 있는데 인증이 진행되는 방식을 명확히 이해하면 좋겠다는 생각으로 작성하는 문서입니다.
인증(Authentication)과 인가(Authorization)에 대해 먼저 정리하겠습니다.
인증은 자신이 누구라고 주장하는 사람을 확인하는 절차이다
권한부여는 가고싶은 곳으로 가도록 혹은 원하는 정보를 얻도록 허용하는 과정이다.
인증이 흔히 표현되는 로그인입니다. 로그인을 통해 얻어지는 인증수단을 통해 접근권한이 있는지 확인받는 과정이 Authorization입니다. 페이지 내에서 Status Code 401을 보게되는 경우는 Authorization에 실패했을 때입니다.
아래 예시는 아이템 리스트를 가져오려면 인증된 JWT를 헤더에 넣어야하는데 이 과정이 이루어지지 않았다는 의미입니다.
토큰 기반 인증 방법 중 하나인 JWT에 대해 잘 이해하기 위해서는 세션 기반 인증과 토큰 기반 인증에 대한 이해가 선행되어야합니다.
공통적으로 세션기반 인증 혹은 토큰 기반 인증이 필요한 이유에 대해 먼저 알아보겠습니다. 이는 HTTP 통신의 특성에서 기인합니다.
HTTP는 웹에서 이루어지는 모든 데이터 교환의 기초입니다. 클라이언트와 서버는 개별적인 메시지교환에 의해 통신합니다. OSI 7계층상 애플리케이션 계층의 프로토콜입니다. 대표적으로 HTTP는 비연결성과 무상태성이라는 특징을 가지고 있습니다. 이 특징에 따라 HTTP는 상태를 저장하지 않습니다. 연속된 요청을 클라이언트에서 서버로 보내더라도 요청 간의 연결고리는 존재하지 않습니다.
이런 특징은 서버와 클라이언트가 연결되어있을 때의 자원낭비를 예방하기 위한 것입니다. HTTP는 필요한 요청을 다 처리하고 나면 연결을 끊습니다.
연속된 요청에 대해 재연결을 하게되면 연결에 필요한 비용 및 시간에 대한 의문이 드실 수도 있을 것 같습니다. 설정에 따라 일정 기간 커넥션을 유지하기도 하는 것 같습니다. '지속 커넥션'을 키워드로 찾아보시면 HTTP 요청이 완료된 후에도 TCP 커넥션을 일정시간 유지하는 동작에 대해 알아보실 수 있습니다.
그렇다면 HTTP 요청을 통해 로그인을 수행한다면 로그인 상태를 어떻게 유지할 수 있을까요? 비연결성에 따른다면 서버는 같은 클라이언트로부터 다시 요청이 오더라도 클라이언트를 식별할 수 없습니다. 일반적인 어플리케이션에서의 동작에서 기대되는 것과 다릅니다.
MDN에서 HTTP에 대해 찾아보면 다음과 같은 문장이 있습니다.
HTTP은 상태가 없지만, 세션은 있습니다
HTTP의 핵심은 상태가 없는 것이지만 HTTP 쿠키는 상태가 있는 세션을 만들도록 해줍니다.
헤더 확장성을 사용하여, 동일한 컨텍스트 또는 동일한 상태를 공유하기 위해 각각의 요청들에 세션을 만들도록 HTTP 쿠키가 추가됩니다.
HTTP 자체로 로그인 상태를 유지 시키지는 않고 쿠키(스토리지도 가능)을 활용하여 세션을 만들어줄 수 있습니다.
토큰 기반 인증 이전에 세션 기반 인증을 먼저 사용한 것으로 소개됩니다. 세션 기반 인증에서의 플로우는 다음과 같습니다.
이 과정에서 세션이 어떻게 저장되는지에 대해 알아야합니다. 보통 서버는 이 세션의 정보를 메모리에 담아둡니다. 서버는 세션을 보관할 곳으로 메모리, 데이터베이스, 메모리형 디비서버(레디스) 등을 선택할 수 있습니다. 메모리가 주로 선택되는 이유는 DB 등에 비해 더 빠르게 접근할 수 있기 때문입니다. 메모리에 세션이 담길 때의 단점이 있습니다.
서버가 여러 대로 운영될 경우 클라이언트는 로그인이 되어있더라도 세션 인증을 받지못할 가능성이 생깁니다. 한 서버의 메모리에 세션정보가 담겨있더라도, 이후 요청에서 다른 서버가 요청을 처리하게 된다면 기존 서버에 담긴 메모리의 세션정보에 접근하기 어렵습니다.
이에 대한 대안으로 디비, 레디스를 사용할 수도 있습니다. 디비를 사용할 경우엔 속도에 대한 이슈가 있다고합니다.
결과적으로 이러한 방식은 authentication을 Stateful하게 만듭니다. 따라서 서버에서 Session Management를 정교하게 해주지 못한다면 인증 시스템에 결함이 생길 수 있습니다.
방금까지 알아본 세션 기반 인증은 Stateful함이 단점으로 작용했었습니다. 따라서 토큰 기반 인증은 Stateless한 방식이라고 생각하시면 됩니다. 유저 정보가 서버의 메모리에 담기지 않고, 토큰 자체에 필요한 정보를 담고 있습니다. 대표적으로 쓰이는 JWT를 예시로 설명하겠습니다.
JWT는 보통 Header, Payload, Signature의 세 부분으로 나눌 수 있습니다.
xxxxxxx.yyyyy.zzzzzz
위의 양식과 같이 점( . ) 을 기준으로 각 파트가 구분됩니다.
{
"alg": "HS256",
"typ": "JWT"
}
페이로드: Payload에 필요한 정보들이 담겨있습니다. 각 속성들을 클레임 셋이라고 부릅니다. 클레임 셋은 JWT에 대한 내용(토큰 생성자(클라이언트)의 정보, 생성 일시 등)이나 클라이언트와 서버 간 주고 받기로 한 값들로 구성됩니다. 클레임의 종류엔 3가지가 있습니다.(Registered, public, private)
→ Registered의 종류: iss (issuer), exp (expiration time), sub (subject), aud (audience), and others.
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
위의 3가지 Part는 Base64-URL-safe string으로 인코딩되어 토큰이 됩니다. 서버는 서버의 PrivateKey를 가지고 Signature를 생성하여 JWT를 만들고 클라이언트로 보내줍니다.
위의 내용을 바탕으로 예시서버의 토큰을 Decode 해보겠습니다.
jwt 공홈에 가시면 같은 작업을 해보실 수 있습니다.
디코딩 된 결과로 토큰의 정보를 확인할 수 있습니다. Payload에 유저 아이디와 만료기간, 이메일 등이 담긴 것을 볼 수 있습니다. Invalid Signature라고 뜬걸 볼 수 있는데, 서버에서 가진 Key값을 넣어준다면 시그니쳐를 인증할 수 있을 것입니다.
JWT를 Response로 받은 후에는 어떻게 처리해줘야 할까요? JWT는 토큰 자체에 정보를 담고 있기 때문에 서버는 토큰에 대한 정보를 가지고 있을 필요가 없습니다. 이를 통해 서버와 클라이언트간의 상태를 Stateless로 유지할 수 있는 것입니다. 서버는 클라이언트가 요청을 보낼 때 JWT를 같이 보내주면 그 토큰을 Validate 해줌으로써 세션의 유효성을 확인하고 Authorization을 진행합니다.
토큰은 별다른 해싱없이 Base64로 인코딩만 되어있기 때문에, 토큰이 Encrypt 되어있지 않다면 토큰에 민감한 정보를 넣어서는 안된다는 사실을 기억해주셔야합니다.
토큰이 안전하게 전달되기 위해서 HTTPS 프로토콜을 사용하여 토큰을 Encrypt 해주는 것이 권장됩니다.
클라이언트는 받은 토큰을 HTTP 헤더에 등록하는 방식으로 사용할 수 있습니다. 토큰을 헤더에 바로 넣지않고 Storage 또는 쿠키에 저장할 수도 있습니다. 그렇게 되면 보안상 이슈가 동반되는데 이 부분에 대해서는 이후 정리하겠습니다.(Refresh Token과 AccessToken에서 설명할 내용입니다.)
Authorization: Bearer <token>
다음과 같이 헤더에 인증스킴을 넣어주고 서버는 Authorization이 필요할 때 클라이언트의 요청에서 이 인증스킴을 확인합니다. 위의 양식은 RFC에서 제시하는 표준방식입니다. 이 헤더에 대한 내용이 더 궁금하시다면 MDN의 HTTP 인증 파트를 찾아봐주세요.
JWT에 대해 살펴보면서 토큰 기반 인증을 사용할 경우 서버와 클라이언트의 관계를 Stateless하게 유지할 수 있다는 것은 확인하셨을겁니다. 다음으로 넘어가기 전에 JWT의 장점과 단점을 짚어보겠습니다.
장점
단점
위에서 드러나는 JWT의 단점은 명확합니다. 이미 발급된 토큰에 대해서 대처가 어렵다는 점입니다.
그렇다면 JWT의 유효기간을 짧게 설정하는 것은 필수적인 일입니다.
이미 발급된 토큰에 대해서는 대처가 어렵기 때문에 토큰의 유효기간을 짧게 두는 것입니다.
이것이 Access Token의 개념입니다.
Access Token을 통해서 세션을 유지하는데 이 토큰의 유효기간이 짧다면 유저는 주기적으로 로그인을 다시 해줘야할까요? 그런 귀찮은 작업을 반복하는 것은 매우 불편할 것입니다.
이에 대응하는 전략이 Refresh Token입니다.
클라이언트가 로그인 시에 서버는 Acccess Token과 Refresh Token을 발급해줍니다. 서버는 Refresh Token을 DB에 보관합니다. 클라이언트는 Access Token을 기존처럼 헤더에 등록하고 Refresh Token은 안전한 장소에 보관합니다.
Access Token이 만료될 경우 클라이언트는 서버에 Refresh Token을 보내서 새로운 Access Token을 발급받습니다. 필요할 경우 Refresh Token으로 새로운 Refresh Token을 발급받아 세션을 유지시키는 작업도 가능합니다.
이렇게 Token을 두가지로 두었을 때의 장점은 두가지입니다.
물론 읽으시면서도 완벽한 절차는 아니다라는 생각이 드셨을 것 같습니다.
→ Access Token으로만 통신할 때보다는 Refresh Token이 노출되는 빈도가 적기 때문에, 그래도 보안상 낫다고 보는 느낌입니다.
→ 보안 처리를 위한 Trade-off 라고 보는 것 같습니다.
→ XSS, CSRF 등 다양한 보안 이슈로 연결되는 주제입니다. 앞으로 기회가 생긴다면 이 부분에 대한 문서도 작성해보겠습니다. 지금 당장은 httplonly, secure 옵션이 설정된 쿠키로 보관된다고 생각해주시면 됩니다.
따라서 JWT는 보안을 1순위로 생각하는 작업에서 사용하기는 적합하지 않습니다. 다만 일반적인 페이지의 경우는 그정도까진 아니어서 JWT를 사용하기에 충분히 적합하다고 생각해요.
위의 그림은 JWT를 활용한 인증과정입니다.
그렇다면 이제 서비스에 적용해 볼 차례입니다.
먼저 로그인 과정에 필요한 액티비티를 정리해보았습니다.
먼저, 클라이언트에서는 서비스에 접속하면서 Refresh Token이 있는지 검사합니다.
플로우는 대략 이렇게 잡아줄 수 있을 것 같습니다.
이에 따라 어떤 엔드포인트가 필요할지 정리해보면서 마무리하겠습니다.
기본적으로 로그인 시에는 액세스 토큰과 리프레시 토큰의 반환이 필요합니다.
Refresh Token을 이용한 Silent login도 구현해둡니다.
/api/login
request:
login: string
password: string
response
Access Token,
Refresh Token,
/api/silent-login
request:
refreshToken
response
Access Token,
Refresh Token,
Refresh Token과 Access Token의 Validity를 확인할 수 있는 엔드포인트가 필요합니다.
/api/validation/accesstoken
request:
accessToken
response
boolean
/api/validation/refreshtoken
request:
refreshToken
response
boolean
RefreshToken을 통해 새로운 토큰을 발급할 수 있어야합니다!
/api/token/accesstoken
request:
refreshToken
response
accessToken
/api/token/refreshtoken
request:
refreshToken
response
refreshToken // 새로운 refreshToken을 발급해야합니다!
주어진 엔드포인트를 가지고 클라이언트는 토큰의 유효성을 확인하고 새로 토큰을 발급받습니다.
서비스 접속시에 토큰을 갱신하는 수도코드를 생각해보았습니다.
function silentLogin() {
const [accessToken, refreshToken] = postSilentLogin() // refreshToken으로 silent Login을 진행
registerAccessToken(accessToken) // 액세스토큰을 헤더에 등록
renewRefresh(refreshToken) // 리프레시 토큰을 갱신
setTiemout(renewAccessToken, beforeAccessTokenExpiration) // 액세스 토큰이 만료되기 전에 토큰을 갱신
}
function renewAccessToken( token ){
const accessToken = fetch('/api/token/accesstoken', refreshToken) // 리프레시 토큰으로 토큰 갱신
setTiemout(renewAccessToken, beforeAccessTokenExpiration) // 재귀적으로 토큰 갱신 함수를 호출하여 액세스 토큰이 접속중에 만료되지 않고 갱신
}
함수를 대략적인 수도코드로 짜봤습니다. Silent Login을 하면서 토큰을 등록해주고, 토큰갱신에 필요한 함수를 호출해줍니다. 이를 통해 접속중에 토큰이 만료되지 않도록 하는 로직입니다.
지금까지 JWT의 플로우에 대해 연구해보았는데요.
궁금하신 점이나 개선 가능한 플로우에 대해 피드백 주시면 감사합니다!
Ref)
인증 방식 : Cookie & Session vs JWT
🥨 HTTP Connection의 관리 (TCP 지연 방지)
5 Steps to Add Modern Authentication to Legacy Apps Using JWTs
JWT를 소개합니다. : NHN Cloud Meetup
JWT.IO - JSON Web Tokens Introduction