JWT(JSON Web Token)로 로그인 / 로그아웃 구현하면서...
1. 개요
최근 Node.js로 백엔드 개발을 할 일이 있었다. Node.js 경험은 거의 없는 상태로 설계 단계에서 Node.js에 대해 공부하는 와중에 REST 뽕을 거하게 맞았다.(프론트엔드 경험이 없었기에 프론트엔드와 백엔드를 완벽하게 분리할 수 있다는 점이 좋았다...) 이로 인해 일반적인 세션 대신 비연결성의 토큰을 이용한 로그인 / 로그아웃에 관심이 생겼다. 구체적으로 구현하는 방법이나 코드에 대해서는 다른 글에 많이 있으니 JWT에 대한 이야기만 하려고 한다.
2. JSON Web Token, JWT
2.1 JWT?
RFC 7519에 정의된 JSON 형태의 요청을 주고 받는 형식이다. 간단하게 설명하자면 JSON 문자열을 BASE64 인코딩(암호화가 아니다!)하고 뒷부분에 문자열을 서버의 비밀 키로 Hashing해서 덧붙여 전송한다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |
위의 JWT 예시는 https://jwt.io/ 에서 가져왔다. 언뜻 보기에는 암호화된 것 같지만 실제로는 BASE64 인코딩된 것이므로 공개해도 되는 정보만 넣어야 한다. 토큰 내용을 암호화해서 숨기고 싶다면 JSON Web Encryption(RFC7516) 구현체를 알아봐야 할 것 같다.
JWT는 세 개의 문자열 사이에 점 두 개가 있는 형태다. 각 문자열 별로 역할이 다르다. 각 문자열이 어떤 값을 지니고 있는 지에 대해서 알아보자.
2.1.1 헤더
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 |
{ "alg": "HS256", "typ": "JWT" } |
alg는 어떤 방식으로 Hashing할 지를 정한다. 구현체에 따라 지원하는 알고리즘의 종류가 다르지만, RFC 상에는 HS256과 none(Hashing 없음)을 반드시 구현해야 한다고 적혀있다. Node.js의 구현체 jsonwebtoken 패키지의 경우 [HMAC using SHA-*]HS256, HS384, HS512, [RSA using SHA-*]RS256, RS384, RS512, [ECDSA using P-* curve and SHA-*]ES256, ES384, ES512 모두를 지원한다.
기본적으로는 HS256을 이용한다.
typ는 이 것이 JWT임을 나타낸다. 반드시 있어야 하는 필드는 아니지만 RFC 상 넣기를 권장하는 내용이다.
특별한 일이 없다면 헤더에는 alg, typ 필드 두 개에 대한 내용만 포함되어 BASE64 인코딩된다.
2.1.2 Payload
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ |
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 } |
위의 토큰 예제에서 분홍색 문자열은 Payload라고 부르며, 전달하고자 하는 JSON형태의 내용이 BASE64 인코딩되어 들어간다. 필드(JWT에서는 Claim이라고 부른다) 명과 데이터는 자기 마음이지만, 몇 가지 필드 이름은 정해진 역할을 한다. 다만 모두 RFC 상 선택적인 항목이라, 구현체에 따라 동작할 수도, 하지 않을 수도 있다.
jsonwebtoken의 경우 aud, exp 필드에 대한 검사는 구현되어 있지만, iss, sub, nbf, iat, jti 필드에 대해서는 구현되어 있지 않은 것으로 나타난다. 실제로 구현할 때는 exp 필드 정도만 유용하게 쓴 것 같다.
exp 필드에 Math.floor(Date.now() / 1000) 에 초 단위로 시간을 더해서 직접 입력해도 된다. Node.js의 jsonwebtoken 패키지는 sign 과정에서 expiresIn 옵션을 이용해서 조절할 수도 있다.
jwt.sign({ exp: Math.floor(Date.now() / 1000) + 3600, name: "John Doe" }, "private_key"); jwt.sign({ name: "John Doe" }, "private_key", {expiresIn: "1h"}); |
미리 정의된 Claim (참고: https://tools.ietf.org/html/rfc7519#section-4.1)
iss |
issuer. 토큰 발급자. 문자열이나 URI로 대소문자를 구별한다. |
sub |
subject. 토큰의 제목, 주제. issuer 단위로 유일하거나 토큰 전체에서 유일해야 한다. 문자열이나 URI로 대소문자를 구별한다. |
aud |
토큰 대상자. 일반적으로, aud 값은 대소문자를 구별하는 문자열/URI 값의 배열이다. 토큰 대상이 하나인 경우 aud 값은 대소문자를 구별하는 문자열/URI 값이다. 사용자 아이디같은 걸 넣어주면 될 것 같다. |
exp |
토큰 만료 시간(숫자). 만료시간을 넘긴 토큰은 반드시 거부되어야 한다. 반드시 현재 시간보다 미래의 시간이어야 한다. |
nbf |
토큰 유효화 시작 시간(숫자). 지정된 시간보다 이전인 토큰은 반드시 거부되어야 한다. 반드시 현재 시간과 같거나 이전의 시간이어야 한다. |
iat |
토큰 발급 시간(숫자). |
jti |
JWT ID. JWT에 대한 유일한 식별자. 대소문자를 구별하는 문자열이다. 중복 처리 방지를 위해 사용할 수 있다. 식별자는 충돌 날 가능성이 거의 없도록 할당되어야 한다. 어플리케이션이 여러 발급자가 발급한 토큰을 이용하는 경우 서로다른 발급자 사이의 jti가 절대로 충돌해서는 안 된다. |
2.1.3 서명
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c |
2.1.1의 BASE64 인코딩된 헤더와 점, BASE64 인코딩된 2.1.2의 Payload를 서버의 비밀키로 Hashing해서 점과 함께 뒤에 붙인다. 위 예제에서는 파란 색 문자열이 서명 부분. 클라이언트(프론트 엔드)에서는 서명이 유효한지 아닌지 확인할 수 없고, 비밀키를 가지고 있는 서버에서만 확인할 수 있다.
2.2 언어별 구현
주요 언어는 대부분 JWT 구현체가 존재하며, 구현체에 따라 지원하는 Hashing 알고리즘, 검사하는 필드에 차이가 있을 수 있다. 이러한 정보는 다음 링크에서 확인할 수 있다.
Node.js에서는 jsonwebtoken 1건이 검색된다. 다른 구현체가 아마도 있겠지만 일단은 jswonwebtoken 패키지만으로도 충분히 구현할 수 있었다.
jsonwebtoken 패키지의 경우 RFC에 정의된 Hashing 알고리즘 전부 및 aud(수신자), exp(토큰 유효시간) 체크는 구현되어 있으나, 나머지의 경우 구현되어 있지 않는 것으로 표시된다. 다만 jsonwebtoken 페이지의 레퍼런스를 확인해보면 iss, nbf, iat(max age)는 지원하는 것으로 보인다.
장점
간편함
Node.js로 개발하던 내 경우에는, jsonwebtoken을 가져다가 자체적인 검사 로직을 추가해 decode, verify, signing 을 구현한 후 따로 로컬 패키지로 만들고, 자체적으로 만든 미들웨어에서 이 패키지를 가져다 써서 로그인 및 권한 체크를 하는 식으로 사용했다. 미들웨어에서 권한 체크 후 문제가 있으면 에러 핸들러로 에러를 던지기 때문에 비즈니스 로직에 무언가 추가할 필요가 (거의) 없었다.
데이터베이스 접근 최소화
권한 유효성을 검사할 때 데이터베이스까지 접근하지 않아도 된다. 로그인한 사용자인지 확인할 때, DB에 쿼리를 날릴필요 없이 토큰이 유효한지만 확인하면 된다.
단점
프론트 엔드에서 토큰 관리
큰 단점은 아니라고 생각하고, 또한 전혀 신경쓰지 않아도 되긴 하다. 토큰이 만료되었는지 검사하지 않고 무조건 서버로 보내면 된다. 정상적으로 구현했다면 서버에서 유효하지 않은 토큰에 대해 요청을 거부하는 응답을 보낼 것이다.
다만 토큰을 주기적으로 확인해(주로 exp 필드) 토큰이 만료된 상태인 경우 토큰을 직접 삭제하고 '로그아웃되었습니다'와 같은 안내문구를 띄워주는 등의 처리를 하는 것이 서버로의 요청 수를 줄이고 더 나은 사용자 경험을 제공하는 데 도움이 될 것 같다. javascript로 된 jwt 구현체도 여럿 있어서 크게 어렵진 않을 것 같다.
보안 문제
JWT를 이용하면서 생길 수 있는 가장 큰 문제다.
https 연결을 이용하고 XSS같은 것도 다 막았지만 컴퓨터 해킹 등의 이유로 토큰이 탈취당했다고 생각해보자. 탈취한 토큰을 이용해 서버로 요청을 전송하는 경우 서버는 토큰을 무효화할 방법이 없다.
가장 간단하게는 토큰 발급 시 접속 IP를 같이 기록한 후 API 호출 시 요청 IP와 토큰에 기록된 IP를 비교하는 방법이 있는 것 같다. 토큰에 필드를 하나 추가하고 검사할 때 IP를 검사하는 로직만 추가하면 된다. 다만 완벽한 해결책은 아니다. 또한 모바일에서 LTE, 3G, 와이파이 전환이 자주 일어나며 IP가 자주 변경되는 경우 사용자가 자꾸 다시 로그인 해야 하는 불편함을 경험할 수 있다.
또는 만료시간이 긴 토큰 하나, 실제로 서버에 무언가 요청할 때 보내기 위해 쓰는 토큰을 나누어서 관리하는 방법이 있는 것 같다. 실제 요청 때 쓰는 토큰은 만료시간을 짧게 주고, 토큰이 만료되면 만료시간이 긴 토큰으로 서버에 요청하는 식으로 관리하는 것 같다. 이런 경우 토큰이 탈취된 상황에서도 다른 경우에 비해 상대적으로 짧은 시간 내에 토큰이 자동으로 무효화된다. 그리고 만료시간이 긴 토큰에 대해서만 잘 관리하면 성능과 보안 사이에서 균형있는 서비스가 가능할 것 같다.
완벽하게 토큰을 무효화하려면, 데이터베이스에서 탈취된 토큰 유효시간동안은 토큰 정보를 저장하고, 모든 요청을 받을 때 토큰 정보가 데이터베이스에 있는지 여부를 검사해야만 하는 듯 하다. Redis 등 메모리 DB를 이용할 순 있지만, 결국 DB 조회가 들어가면서 세션 기반 처리와 큰 차이가 없게 되는 점이 아쉬운 부분.
결론
Stateless하도록 하기 위해서라면 JWT는 괜찮은 선택이라고 생각한다. 실제로 JWT를 이용해 로그인/로그아웃 서비스를 만들어보니 생각보다 만족스러웠다. 다만 공개 서비스가 아니라 기술 검증 수준의 단계에서 개발하다보니 어느 정도 느슨하게(특히 보안과 관련된 부분) 고려해서 선택한 만큼, 공개 서비스를 개발한다면 여러 가지(특히 보안)를 충분히 고려해서 선택하는 것이 좋을 것 같다.