상황을 가정한다.
프로젝트 하는 중에 계정 인증 부분을 구현하려고 한다.
먼저, 알아야할 것은
웹브라우저와 서버의 연결은 Http로 주고 받는데
Http의 2가지 특징인 비연결지향와 상태없음으로 인해 서버는 클라이언트의 상태를 알 수 없다.
비연결지향(Connectionless)은 request 에 대한 response 를 해주면 서버와 클라이언트가 연결이 끊어진다. connection을 낭비하지 않기 때문에 리소스의 낭비를 줄일 수 있기 때문에 비연결 지향이다.
상태없음 (Stateless)은 커넥션을 끊는 순간 이전의 상태 정보를 유지하지 않는다.
그렇기 때문에 로그인 한 사용자가 계속적으로 커넥션이 끊길 때마다 로그아웃이 되고 그럴때마다 귀찮을 정도로 서버에 로그인 요청을 하는 경우가 발생한다.
그래서 그걸 해결하기 위해 어떤 은밀한 저장소에 또는 임시저장소 따위에 흔적을 남겨두면 귀찮은 일이 발생하지 않을까? 싶어 나온게 쿠키(cookie), 세션(session), JWT이다.
쿠키는 사용자의 클라이언트 로컬pc에 저장되는 key-value 형태의 데이터이다. 클라이언트의 상태 정보를 로컬에 저장했다가 request 할 때마다 확인하고 로그인을 유지한다. 참고로, 데이터안에 담고 있는 정보는 이름, 값, 유효시간, 도메인, 경로등이 있다. 하지만, 단점은 로컬에 저장되어서 보관하기 때문에 보안에 취약하다.
그러므로 대부분 쿠키는 쇼핑몰의 장바구니 기능으로 사용하거나 (다른 pc에 로그인하면 담아놓은 장바구니가 없다.) 또는 팝업창에서 '오늘 더 이상 이 창을 보지 않음' 이런 보안에는 크게 문제되지 않는 중요한 정보가 없는 임시 유지 정보를 저장한다.
이러한 명확하게 단점이 많아 쿠키 인증기반으론 부적절한거 같다.
그럼...사용자 로컬 pc에 계정 정보를 저장하지말고 서버 측에 보관하면 어떨까? 라는 생각을 했을 것이다.
세션은 쿠키를 기반하고 있지만, 사용자 정보 파일을 브라우저에 저장하는 쿠키와 달리 세션은 서버 측에서 관리한다. 쿠키에 아이디와 비밀번호 같은 중요 정보들을 담는게아니라, 중요정보가 아닌 인증을 위한 별개의 정보를 세션 저장소에 저장하고, 로컬에는 이 정보를 쿠키에 대신 담아서 요청하고 서버는 세션 저장소에 있는 정보랑 일치하는지 확인하는 방식이다.
여기까지보면 너무 완벽하지 않는가?
하지만 아쉽게도 세션과 쿠키로의 인증 유지방법도 단점이 있다.
그것은 바로 http의 가장 큰 특성중 하나인 상태없음(stateless)를 위배한다는 것이다. 상태없음(stateless)이라면 서버는 클라이언트의 상태를 저장하지 않아야 하지만 서버측의 세션 저장소라는 곳에서 클라이언트의 상태를 저장하게 되므로 stateful하게 된다. 상태를 유지하면 안되는 것일까?
http의 특징 중 하나가 상태없음을 하는 이유는 서비스가 커짐에 따라 대용량 트래픽을 분산 처리하기 위해 서버를 scale-out(증설)하게 되는데 수 많은 서버(노드) 중에 항상 같은 서버에만 1대1로 요청을 하는게 아니기 때문에 클라이언트와 서버가 1대1로 stateful하게 된다면 다른 서버로 요청을 할 땐 세션 정보가 없기에 불편하게 다시 로그인해야한다.
이 문제를 해결하기위해 session clustering과 Sticky session 방식이 있지만 서비스 규모가 아주 커서 서비스를 쪼개어 운영하는 아키텍처인 MSA와 같은 구조에선 클러스터링하기엔 오히려 배 보다 배꼽이 커지게 되고 sticky sesstion 방식은 짧게 설명하자면 위에 잠깐 언급한대로 그냥 클라이언트가 요청하는 서버는 1대1로 하나의 서버가 처리하는건데 이것 또한 사실 완벽하지 않다. 요청을 처리할 서버를 적절하게 나누어주는 로드밸런서가 처음에는 균등하게 1/n 빵(?)으로 분배할지라도 사용자가 사용하는 시간은 제각각 다르기 때문에 오히려 한 곳으로 부하가 발생할 수 있다.
이러한 단점과 불편한 점을 해결하기 위해 나온 것이 JWT이다.
Json Web Token의 약자이다. 이름에서도 느껴지듯이 Json의 형식이고 토큰 기반이다.
먼저, JWT 계정 인증 동작 과정을 설명하자면
크게 이러한 동작 방식으로 인증을 유지한다.
JWT는 크게 3가지(헤더, 페이로드, 시그니처)로 이루어 져있다.
{
"type" : "JMT" // type은 토큰 유형을 말함
"alg" : "HS256" // 해시 알고리즘 중 하나인 HS256 사용
}
type은 어떤 토큰 유형인지 값을 가지고 있고
alg는 알고리즘인데 어떤 hash algorithm을 사용할 것인지를 말한다.
{
"userId" = "park123"
"username" = "JeongBinPark"
"email" = "jbin3031@gachon.ac.kr"
// standard fields
"iss" : "zKoder, author of bezkoder.com"
"iat" : 1570238918,
"exp" : 1570238992
}
userId, username, email 같은건 사용자의 개인 정보를 저장하고 있고
iss는 JWT 발행자, iat는 JWT의 발행된 시간, exp는 JWT의 만료시간이다. 더 많은 필드를 넣을 수 도 있다.
const data = Base64UrlEncode(header) + '+' + Base64UrlEncode(payload);
const hashedData = Hash(data, secret);
const signature = Base64Encode(hasedData)
먼저, 헤더와 페이로드를 인코딩하고 점(.)으로 결합한다.
그리고 해시 알고리즘을 적용시켜 데이터를 암호화한다.
마지막으로 해싱 결과를 인코딩하여 시그니처에 저장하여 담는다.
여기까지 3가지의 정보가 다 모여있는것이 JWT이다.
JWT의 표준 구조는 header.payload.signature이다.
const encodedHeader = base64urlEncode(header);
/* Result */
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"
const encodedPayload = base64urlEncode(payload);
/* Result */
"eyJ1c2VySWQiOiJhYmNkMTIzNDVnaGlqayIsInVzZXJuYW1lIjoiYmV6a29kZXIiLCJlbWFpbCI6ImNvbnRhY3RAYmV6a29kZXIuY29tIn0"
const data = encodedHeader + "." + encodedPayload;
const hashedData = Hash(data, secret);
const signature = base64urlEncode(hashedData);
/* Result */
"crrCKWNGay10ZYbzNG3e0hfLKbL7ktolT7GqjUMwi3k"
// header.payload.signature
const JWT = encodedHeader + "." + encodedPayload + "." + signature;
/* Result */
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJhYmNkMTIzNDVnaGlqayIsInVzZXJuYW1lIjoiYmV6a29kZXIiLCJlbWFpbCI6ImNvbnRhY3RAYmV6a29kZXIuY29tIn0.5IN4qmZTS3LEaXCisfJQhrSyhSPXEgM1ux-qXsGKacQ"