Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

JWT(JSON Web Token)에 대해서...

작년에 JSON Web Token(JWT)에 대한 글을 올렸다. 당시에는 JWT 처음 사용해 보면서 적은 글이라 그제야 약간 이해한 상황이었지만 시간이 지나면서 더 알게 된 부분도 있고 스펙상 달라진 부분도 꽤 생긴 관계로 유지보수 차원에서 추가로 글을 쓰게 됐다.

JSON Web Token에 대해서...

이전 글을 쓴 지 1년도 채 지나지 않았지만, 그 사이에 많은 내용이 달라졌을 정도로 당시의 JWT는 초기 상태였다. 작년에 사용할 때는 스펙의 버전이 25였지만 버전이 32까지 오른 뒤 현재는 RFC 7519가 되었다. (버전 32는 읽어보았지만, RFC 7519는 아직 못 읽어봤다.)

JWT는 토큰 기반의 인증이 많아지면서 HTTP Authorization 헤더나 URI 쿼리 파리미터 등 공백을 사용할 수 없는 곳에서 가볍게 사용할 수 있는 토큰으로 설계되었다.(발음은 "jot"이다.) 이름에서도 알 수 있듯이 JWT는 JSON 객체를 사용하고 여기에 서명하거나 암호화를 할 수 있다.

JWT의 구조

JWT는 API 인증 등에 사용하는 토큰이므로 스펙에 인증하는 방법에 대한 내용은 적혀있지 않고 토큰을 어떻게 만들고 검증하는지에 대해서 나와 있다. 뒤에서 인증에 대해서도 약간 얘기를 하겠지만 우선 JWT 자체에 대해서 알아보자. JWT에 대해서 알고자 한다면 Auth0에서 만든 JWT 사이트가 참고하기 제일 좋다. 이 사이트에서 JWT 토큰을 테스트해보거나 구조도 파악해 볼 수 있고 언어별로 추천 라이브러리와 지원상태를 한눈에 볼 수 있어서 믿고 쓸 수 있고 JWT에 대한 보안 취약점도 잘 보고되고 있다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJKb2huIERvZSIsImV4cCI6MTQzNDI5MDQwMDAwMCwidXNlcm5hbWUiOiJqb2huIiwiYWdlIjoyNSwiaWF0IjoxNDM0Mjg2ODQyNjU0fQ.jzvwdy5mQuzkEenNEFeRlSytvB7-X7NVAvtTDr1jP0Q

JWT 토큰은 위와 같은 모습을 하고 있다. 자세히 보면 마침표(.)를 구분자로 세 부분으로 나누어져 있는데 이 3가지 부분을 각각 JOSE 헤더(JSON Object Signing and Encryption), JWT Claim Set, Signature라고 부른다.

jwt.io에서 토큰을 디코딩한 화면

jwt.io 사이트의 디버거로 JWT 토큰을 디코딩한 모습니다. 이해하기 쉽게 각 부분의 색을 다르게 표시했는데 빨간색 부분이 JOSE 헤더이고 분홍색 부분이 JWT Claim Set이고 하늘색 부분이 Signature이다. 즉, 오른쪽의 값을 인코딩해서 왼쪽의 JWT를 만들고 왼쪽의 JWT를 디코딩하면 우측의 JSON이 나오게 된다. 각 값의 의미는 나중에 살펴보기로 하고 node.js로 직접 만드는 방법을 먼저 살펴보자.

JOSE 헤더

new Buffer('{"alg":"HS256","typ":"JWT"}').toString("base64")
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

JOSE 헤더는 JWT 토큰을 어떻게 해석해야 하는지를 명시한 부분이다. 이 헤더를 열어보고 이어서 설명할 JWT Claim Set이나 Signature를 어떻게 해석할지를 알 수 있다. JOSE 헤더의 값이 '{"typ":"JWT","alg":"HS256"}'라고 한다면 이를 base64로 인코딩해서 JOSE 헤더를 생성한다. 이 예제에서는 JWT 타입이고(옵션 파라미터로 MIME 미디어 타입을 의미하지만 JWT인 경우 JWT라고 적기를 추천하고 있다.) 토큰에 사용한 알고리즘은 HS256 즉, HMAC SHA-256이라는 의미이다. HS256 외에도 HS512, RS256(RSASSA SHA-256), ES256(ECDSA P-256 curve SHA-256) 등의 알고리즘을 사용할 수 있다.

JWT Claim Set

new Buffer('{"iss":"John Doe","exp":1434290400000,"username":"john","age":25,"iat":1434286842654}').toString("base64")
// eyJpc3MiOiJKb2huIERvZSIsImV4cCI6MTQzNDI5MDQwMDAwMCwidXNlcm5hbWUiOiJqb2huIiwiYWdlIjoyNSwiaWF0IjoxNDM0Mjg2ODQyNjU0fQ==

JWT Claim Set은 실제 토큰의 바디로 토큰에 포함할 내용을 넣는다. JWT는 토큰을 디코딩해서 바로 값을 확인할 수 있는 구조로 되어 있어서 데이터베이스 조회할 필요없이 사용자 아이디 등을 여기에 담아서 바로 사용할 수 있다. 여기서 JWT Claim Set의 내용이 '{"iss":"John Doe","exp":1434290400000,"username":"john","age":25,"iat":1434286842654}'라고 한다면 JOSE 헤더와 같게 base64 인코딩을 한다. 마지막에 붙은 ==padding인데 여기서는 패딩을 제거하고 ...yNjU0fQ까지만 토큰으로 사용한다.

Signature

앞에서 보았듯이 JOSE 헤더와 JWT Claim Set은 암호화를 한 것이 아니라 단순히 JSON문자열을 base64로 인코딩한 것뿐이다. 그래서 누구나 이 값을 다시 디코딩하면 JSON에 어떤 내용이 들어있는지 확인할 수 있다. 토큰을 사용하는 경우 이 토큰을 다른 사람이 위변조할 수 없어야 하므로 JOSE 헤더와 JWT Claim Set가 위변조되었는지를 검증하기 위한 부분이 Signature 부분이다. JOSE 헤더와 JWT Claim Set는 JOSE 헤더와 JWT Claim Set를 base64로 인코딩해서 만든 두 값을 마침표(.)로 이어 붙이고 JOSE 헤더에서 alg로 지정한 알고리즘 HS256 즉, HMAC SHA-256으로 인코딩하면 JWT 토큰의 세 번째 부분인 Signature를 만든다.

var crypto = require('crypto');

var secretKey = 'secret';
var headerAndClaimSet = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJKb2huIERvZSIsImV4cCI6MTQzNDI5MDQwMDAwMCwidXNlcm5hbWUiOiJqb2huIiwiYWdlIjoyNSwiaWF0IjoxNDM0Mjg2ODQyNjU0fQ';

crypto.createHmac('sha256', secretKey).update(headerAndClaimSet).digest('base64')
// jzvwdy5mQuzkEenNEFeRlSytvB7+X7NVAvtTDr1jP0Q=

headerAndClaimSet 값이 앞에서 만든 JOSE 헤더와 Claim Set을 마침표로 이어 붙인 것이고 이를 HMAC SHA-256으로 암호화해서 base64로 표현하자 앞에서 본 Signature와 같은 문자열이 나왔다.(여기서도 padding은 제거한다.) 공격자가 서명을 위변조할 수 없도록 비밀키를 사용해서 서명하고 당연히 이 비밀키는 외부에 노출되면 안 된다.(여기서는 비밀키로 secret이라는 값을 사용했다.)

이렇게 만든 3부분을 마침표(.)로 이어 붙이면 JWT 토큰이 완성된다.

JWT Claim Set

JWT의 구조를 설명하기 위해서 앞에서는 직접 토큰을 만들었지만 실제로 사용하게 되면 언어별로 있는 JWT 라이브러리를 사용해서 토큰을 만들 것이므로 이 과정을 직접 수행하는 경우는 별로 없다. JWT 사이트에 언어별 추천 라이브러리가 있고 현재 지원상황까지 표시되어 있으므로 비교해서 사용하면 된다. 이런 라이브러리는 취약점에 대한 대처가 중요하므로 세부 내용을 자세히 아는 것이 아니라면 그냥 여기 나와 있는 라이브러리를 사용하기를 추천한다. 이전에 작성한 글에서는 Node.js JWT 라이브러리로 jwt-simple을 사용했지만, 개발이 거의 진행되고 있지 않아서 지금은 jsonwebtoken으로 갈아탔다.

JWT에서 토큰의 정보를 클레임이라고 부르기 때문에 이 정보를 모두 가지고 있는 바디 부분을 Claim Set이라고 부르고 Claim Set은 키 부분인 Claim Name과 값 부분인 Claim Value의 여러 쌍으로 이루어져 있다. Claim Name으로 사용 가능한 값에는 3가지 분류가 있는데 등록된 클레임 이름(Registered), 공개 클레임 이름(Public), 비밀 클레임 이름(Private)이다. 등록된 클레임 이름은 IANA JSON Web Token Claims에 등록된 이름이고 필수값은 아니지만 공통으로 사용하기 위한 기본값이 정해져 있다. 아래 목록이 등록된 클레임 이름인데 모두 선택사항이다.

  • iss: 토큰을 발급한 발급자(Issuer)
  • sub: Claim의 주제(Subject)로 토큰이 갖는 문맥을 의미한다.
  • aud: 이 토큰을 사용할 수신자(Audience)
  • exp: 만료시간(Expiration Time)은 만료시간이 지난 토큰은 거절해야 한다.
  • nbf: Not Before의 의미로 이 시간 이전에는 토큰을 처리하지 않아야 함을 의미한다.
  • iat: 토큰이 발급된 시간(Issued At)
  • jti: JWT ID로 토큰에 대한 식별자이다.

공개된 클레임이름은 토큰에서 사용하기 위해서 정의했지만, 충돌을 방지하기 위해서 공개된 이름이고 비밀 클레임이름은 서버와 클라이언트가 협의로 사용하는 이름을 의미한다.

JWT의 사용

JWT가 다른 토큰하고 가장 다른 부분은 토큰 자체가 데이터를 가지고 있다는 점이다. API 서버를 직접 구현한 적이 많지는 않지만, 일반적으로 토큰 기반의 인증을 구현한다면 API 요청 시 헤더나 파라미터에 엑세스토큰을 가져오도록 하고 이 토큰을 보고 인증한다.(서비스에 따라서 앱 ID나 비밀키를 같이 사용하기도 하지만 여기서는 JWT 범주가 아니므로 여기서는 엑세스토큰만 얘기한다.)

일반적인 토큰의 흐름을 생각한다면 API 요청 시에 들어온 토큰을 보고 이 토큰이 유효한지 확인하게 된다. 보통은 데이터베이스에 토큰을 저장해 놓고 만료시간이나 토큰의 사용자 등을 저장해 놓고 유효한 토큰인지 등을 검사하고 유효한 경우 해당 사용자라고 인식하고 이 사용자의 권한으로 사용할 수 있는 정보를 조회하게 된다. 요청마다 데이터베이스를 조회하는 것은 비용이 꽤 크므로 캐시서버 등을 두어 성능을 높이기도 한다.

JWT의 경우는 토큰을 받아서 서명으로 유효한 토큰인지 검증을 한 뒤에 유효하다고 판단하면 클레임셋을 디코딩해서 토큰에 담긴 데이터를 열어본다. 앞에서 보았듯이 클레임셋의 JSON에 만료시간 등이 담겨있으므로 토큰이 사용가능한지 검사를 하고 이상이 없으면 바로 사용한다. 토큰의 사용자 아이디 등이 클레임셋에 담겨있다면 데이터베이스나 캐시를 조회할 필요없이 바로 애플레이케션이서 사용자를 확인하고 정보를 조회할 수 있다. 대신 다른 토큰보다 길이가 좀 길다는 문제는 있다.

이 차이는 서버 개발 쪽에서는 상당히 큰 차이를 만들어내는데 토큰만 가지고 유효성 검사나 사용자 식별까지 할 수 있으므로 서버를 무상태로 유지할 수 있다는 점이다. 데이터베이스나 캐시 없이 로직만으로 인증이 가능하므로 애플리케이션 서버를 필요에 따라 늘릴 수 있다.

JWT를 사용하다 보니 몇 가지 주의할 점이 있다.

  • 앞에서 보았듯이 클레임셋은 암호화하지 않는다. 그래서 서명 없이도 누구나 열어볼 수 있기 때문에 여기에는 보안이 중요한 데이터는 넣으면 안 된다. base64로 인코딩해서 사용하다 보면 이 부분을 간과하기 쉬운데 필요한 최소한의 정보만 클레임셋에 담아야 한다.
  • 인코딩 특성상 클레임셋의 내용이 많아지면 토큰의 길이도 같이 길어진다. 그래서 편하다고 너무 많은 정보를 담으면 안 된다. 앞에서 정의된 클레임네임이 전부 약자를 사용하는 이유도 JWT를 최대한 짧게 만들기 위함이다.
  • 토큰을 강제로 만료시킬 방법이 없다. 서버가 토큰의 상태를 가지고 있지 않고 토큰 발급 시 해당 토큰이 유효한 조건이 결정되므로 클라이언트가 로그아웃하더라도 해당 토큰을 클라이언트가 제거하는 것뿐이지 토큰 자체가 만료되는 것은 아니다. 만약 이때 누군가 토큰을 탈취한다면 해당 토큰을 만료시간까지는 유효하게 된다. 많은 고민을 해보았지만 무상태를 유지하면서 이 부분을 같이 해결할 방법은 존재하지 않으므로 서비스에서 이 부분이 문제가 된다면 선택을 해야 한다고 본다.
2015/07/30 04:00 2015/07/30 04:00