grep

Engineering

JWT를 소개합니다.

NHN

2020년 5월 20일

원문에서 보기 ↗

머리말

토스트 클라우드 메시징 플랫폼 서비스 중 하나인 푸시에 추가될 APNs(Apple Push Notification service) JWT 인증 기능을 개발하면서 진행한 기술 조사 내용을 공유합니다. 이 글에서는 크게 'JWT 소개'와 'JWT 더 알아보기' 2개 부분으로 구성되어 있습니다. 'JWT 소개'는 JWT 구조, 생성과 검증 방법에 대해 설명하고, 'JWT 더 알아보기'는 개인적으로 궁금했던 점, 특징, 몇 가지 사용 사례에 대해 다룹니다. 이 글을 통해 JWT를 이용한 기능을 개발하거나 JWT를 사용하는 분들에게 도움이 되면 좋겠습니다.

혹시 궁금하거나 사실과 다른 부분이 있다면 아래 링크드인이나 깃헙을 통해 연락 부탁드립니다.

아래부터 평어체를 사용합니다.

JWT(JSON Web Token) 소개

'소개'에서는 JWT를 처음 접하는 개발자를 위한 부분이다. JWT에 대해 어느정도 이해가 있다면 지나쳐도 괜찮다.

JWT는 일반적으로 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가(Authorization)를 위해 사용하는 토큰이다. URL에대해 안전한 문자열로 구성되어 있기 때문에 HTTP 어디든(URL, Header, ...) 위치할 수 있다. 이게 JWT에 대한 정확한 정의는 아니지만 초반 이해를 돕기위해 이 정도에서 넘어가자. 더 자세한 내용은 'JWT 더 알아보기'에서 다시 다룬다.

구조와 생성

HEADER.PAYLOAD.SIGNATURE

헤더(Header), 페이로드(Payload), 서명(Signature) 세 부분을 점(.)으로 구분하는 구조다.

Header

JWT를 검증하는데 필요한 정보를 가진 JSON 객체는 Base64 URL-Safe 인코딩된 문자열이다. 헤더(Header)는 JWT를 어떻게 검증(Verify)하는가에 대한 내용을 담고 있다. 참고로 alg는 서명 시 사용하는 알고리즘이고, kid는 서명 시 사용하는 키(Public/Private Key)를 식별하는 값이다.

{
    "alg": "ES256",
    "kid": "Key ID"
}

위와 같은 JSON 객체를 문자열로 직렬화하고 UTF-8과 Base64 URL-Safe로 인코딩하면 다음과 같이 헤더를 생성할 수 있다.

Base64URLSafe(UTF-8('{"alg": "ES256","kid": "Key ID"}')) -> eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9

Payload

JWT의 내용이다. 페이로드(Payload)에 있는 속성들을 클레임 셋(Claim Set)이라 부른다. 클레임 셋은 JWT에 대한 내용(토큰 생성자(클라이언트)의 정보, 생성 일시 등)이나 클라이언트와 서버 간 주고 받기로 한 값들로 구성된다.

{
    "iss": "jinho.shin",
    "iat": "1586364327"
}

위와 같은 JSON 객체를 문자열로 직렬화하고 Base64 URL-Safe로 인코딩하면 다음과 같이 페이로드를 생성할 수 있다.

Base64URLSafe('{"iss": "jinho.shin","iat": "1586364327"}') -> eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLnNoaW4ifQ

Signature

점(.)을 구분자로 해서 헤더와 페이로드를 합친 문자열을 서명한 값이다. 서명은 헤더의 alg에 정의된 알고리즘과 비밀 키를 이용해 성성하고 Base64 URL-Safe로 인코딩한다.

Base64URLSafe(Sign('ES256', '${PRIVATE_KEY}',
'eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLnNoaW4ifQ'))) ->
MEQCIBSOVBBsCeZ_8vHulOvspJVFU3GADhyCHyzMiBFVyS3qAiB7Tm_MEXi2kLusOBpanIrcs2NVq24uuVDgH71M_fIQGg

JWT

점을 구분자로 해서 헤더, 페이로드, 서명을 합치면 JWT가 완성된다.

eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLn
NoaW4ifQ.eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRC9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6Imp
pbmhvLnNoaW4ifQ.MEQCIBSOVBBsCeZ_8vHulOvspJVFU3GADhyCHyzMiBFVyS3qAiB7Tm_ME
Xi2kLusOBpanIrcs2NVq24uuVDgH71M_fIQGg

이렇게 완성된 JWT는 헤더의 alg, kid 속성과 공개 키를 이용해 검증할 수 있다. 서명 검증이 성공하면 JWT의 모든 내용을 신뢰할 수 있게되고, 페이로드의 값으로 접근 제어나 원하는 처리를 할 수 있게된다.

구현해보기

1. Public/Private Key 생성

JWT 생성과 검증에 필요한 공개 키와 비밀키를 생성한다. 여기에서는 키 생성 알고리즘으로 ECDSA(Elliptic Curve Digital Signature Algorithm, 타원곡선 디지털 서명 알고리즘) 중 하나인 ES256(P-256 + SHA256)을 사용한다. 블록체인에서 사용하는 알고리즘인데, JWT에서도 많이 사용하는 알고리즘인 것 같다.

    /**
     * Java API를 이용해 ES256 키 생성
     *
     * @throws NoSuchAlgorithmException
     * @throws InvalidAlgorithmParameterException
     */
    @Test
    public void test_pure_java_generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException {
        // Given
        final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
        keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); // == P256

        // When
        final KeyPair keyPair = keyPairGenerator.generateKeyPair();

        // Then
        // Nothing Happen
        log.info("ecKey.publicKey: {}", Base64.encodeBase64String(keyPair.getPublic().getEncoded()));
        log.info("ecKey.privateKey: {}", Base64.encodeBase64String(keyPair.getPrivate().getEncoded()));
    }
공개 키: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKY/2QKid9XCTRWCusDHUddgjWUTskYpY2wj
WcgZ6vVfBlYRL0UhyLGbgBpucjGGjRAYoWRvn83f+GhAfiqmydw==
비밀 키: MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBfWNacqAsGHMnGbWiZXR81
mRvB4w/Icva0jGFPduwBxQ==

2. JWT 생성

위에서 생성한 키를 Java의 공개 키(ECPublicKey)와 비밀 키(ECPrivateKey)로 로드한다. 그리고 헤더와 페이로드를 인코딩하고, 둘을 합친 문자열을 비밀 키로 서명한다.

    private static ECPublicKey EC_PUBLIC_KEY;
    private static ECPrivateKey EC_PRIVATE_KEY;

    /**
     * PEM 형식의 키를 Java의 ECPublicKey, ECPrivateKey로 변환
     *
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    @BeforeAll
    public static void beforeAll() throws NoSuchAlgorithmException, InvalidKeySpecException {
        final KeyFactory keyPairGenerator = KeyFactory.getInstance("EC"); // EC is ECDSA in Java

        EC_PUBLIC_KEY = (ECPublicKey) keyPairGenerator.generatePublic(new X509EncodedKeySpec(Base64.decodeBase64("위에서 생성한 공개 키")));
        EC_PRIVATE_KEY = (ECPrivateKey) keyPairGenerator.generatePrivate(new PKCS8EncodedKeySpec(Base64.decodeBase64("위에서 생성한 비밀 키")));
    }

    /**
     * Java API를 이용해 JWT 생성
     *
     * @throws NoSuchAlgorithmException
     * @throws IOException
     * @throws InvalidKeyException
     * @throws SignatureException
     */
    @Test
    public void test_java_JWT() throws NoSuchAlgorithmException, IOException, InvalidKeyException, SignatureException {
        // Given
        final ObjectMapper objectMapper = new ObjectMapper();
        final Map<String, Object> header = Maps.newLinkedHashMap();
        header.put("kid", "키 아이디");
        header.put("typ", "타입, 일반적으로 'JWT'로 설정");
        header.put("alg", "알고리즘, 일반적으로 ES256 사용");
        final String headerStr =  Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(header));

        final Map<String, Object> payload = Maps.newLinkedHashMap();
        payload.put("iss", "JWT를 생성한 곳");
        payload.put("iat", 0); // JWT 생성 시간
        final String payloadStr = Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(payload));

        // When
        // Java 9부터 가능(Java 8에서 오류 발생 'java.security.NoSuchAlgorithmException: SHA256withECDSAinP1363Format Signature not available')
        // SHA256withECDSA와 서명 형식이 다름, 일부 라이브러리에서 검증이 실패하는 경우가 있었음
        final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format");
        signature.initSign(EC_PRIVATE_KEY);
        signature.update((headerStr + "." + payloadStr).getBytes());

        byte[] signatureBytes = signature.sign();

        final String signatureStr = Base64.encodeBase64URLSafeString(signatureBytes);

        final String jwt = headerStr + "." + payloadStr + "." + signatureStr;

        logJWT("java", jwt);

        // Then
        verifyJWTByJava(jwt, EC_PUBLIC_KEY);
    }
eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODczNDk1MjcsImlzcyI6ImppbmhvLnNoaW4
ifQ.MEUCIGncUpdRpxO9glZi7aKrzXa06DFrWIfxPtEL7kLxcHtWAiEAqenTrf-nD8EucxhJBrBpZw5IuTDFxK1rtv20nF5SYZk

3. JWT 검증(Verify)

공개 키로 JWT의 서명을 검증한다.

    public void verifyJWTByJava(String jwt, ECPublicKey publicKey) throws NoSuchAlgorithmException,
    InvalidKeyException, SignatureException {
        final String[] splitJwt = jwt.split("\\.");
        final String headerStr = splitJwt[0];
        final String payloadStr = splitJwt[1];
        final String signatureStr = splitJwt[2];

        final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format");
        signature.initVerify(publicKey);
        signature.update((headerStr + "." + payloadStr).getBytes());

        assert signature.verify(Base64.decodeBase64(signatureStr));
    }

여기에서는 JWT 생성과 검증 과정 이해를 돕기 위해 Java API를 이용했다. 하지만, 다양한 JWT 라이브러리가 있기 때문에 개발 시 라이브러리를 사용하는게 편리하다. 아래 Gist에는 nimubs, auth0, jjwt 등을 이용한 JWT 생성, 검증과 관련된 코드가 있다. 개발 시 참고하길 바란다.

JWT 더 알아보기

여기에서는 개인적으로 궁금했던 점과 새롭게 알게 된 점, 그리고 JWT 사용 사례에 대해서 짧게 다룬다.

JWT, JWS, JWE, JWK, JWA...?

JWT는 URL, Cookie, Header와 같이 사용할 수 있는 문자가 제한된 환경에서 정보를 주고받을 수 있게 하는 데이터 표현 형식(Format)이다. 그런데 실제 우리가 JWT를 이용한 서명(Sign)이나 암호화(Encryption)에 대한 명세는 JWT 하위 JWS(JSON Web Signature)와 JWE(JSON Web Encryption)에 되어있다. 이해하기 쉽게 설명하자면 JWT는 추상화 클래스라(Abstract Class) 할 수 있고, JWS와 JWE는 추상화 클래스를 마저 구현한 콘크리트 클래스(Concrete Class)라고 할 수 있다. 그밖에 JWK(JSON Web Key)는 JSON 형식으로 암호화 키를 표현한 것이고, JWA(JSON Web Algorithm)은 JWS, JWE, JWK에 사용하는 알고리즘에 대한 명세다.

다음은 JWT RFC-7519의 일부분이다. 1.png

JWS & Compact Serialization

우리가 일반적으로 사용하는 대부분의 JWT는 JWS다. 아마 넓은 의미로 JWT라고 하는 것 같다. 그럼 JWE는 언제 사용하는지 궁금해할 수도 있다. 사실 거의 사용하지 않는 것으로 보인다. (개인적인 생각으로는... ) 사용할 필요가 없다. 왜냐하면 JWE는 이름에서 알 수 있듯이 데이터를 암호화하는 것인데, 우리는 일반적으로 통신 시 구간 암호화가 필요하면 TLS(Transport Layer Security)를 사용하고 있기 때문이다. JWE를 사용해 데이터를 암호화할 필요가 없다. (혹시 JWE 사용 사례를 알고 계시는 분이 있으시면 말씀 부탁드립니다.) 다시 JWS로 돌아와서, 앞서 JWT의 구조라고 설명한 Header.Payload.Signature 구조는 JWS의 직렬화 방법 중 하나인 Compact Serialization 형식으로 직렬화한 것이다. 정리하자면 우리가 일반적으로 사용하는 JWT는 JWS를 사용하고 JWS Compact Serialization으로 직렬화한 문자열이다.

다음은 JWS RFC-7515의 일부분이다. 2.png

Base64 URL-Safe != Base64

(기본적인 거긴 하지만) Base64 URL-Safe 인코딩은 기본 Base64 인코딩에서 '+'(plus)는 '-'(minus)로, '/'(slash)는 '_'(underscore)로 대체된 인코딩 방법이다. 이로 인해서 JWT는 설계 의도대로 URL, Cookie, Header 등 어디에서도 사용될 수 있는 넓은 범용성을 가지게 되었다.

Header & Payload

JWT의 헤더는 Base64 인코딩 전 항상 UTF-8로 인코딩된 문자열이어야 한다. 이유는 헤더가 꼭 JSON이어야 하고, JSON의 기본 인코딩은 UTF-8이기 때문이다. 정식 명칭은 JOSE(JSON Object Signing and Encryption) Header다. 그렇다면 페이로드는 JSON이 아니어도 괜찮은가라는 의문을 가지게 되는데 페이로드는 일반적으로 JSON을 사용하는 것뿐이지 꼭 JSON이어야 될 이유는 없다. 따라서 페이로드는 헤더와 다르게 Base64 URL-Safe 인코딩만 한다.

다음은 JWS RFC-7515의 일부분이다. 3.png

자체 포함(Self-Contained) & 무상태(Stateless)

JWT는 JWT 자체에 필요한 모든 정보를 담을 수 있다. 헤더는 토큰에 대한 해석 방법을, 페이로드는 토큰의 내용, 전달할 내용(사용자 정보, 권한, 서비스에 필요한 데이터)을 자유롭게 담을 수 있으며, 서명으로 헤더와 페이로드가 위 변조 되지 않았다는 것을 검증할 수 있다. 서버는 JWT 생성 시 JWT에 검증이나 권한 인가 시 필요한 값을 넣으면 되기 때문에 JWT에 대한 상태를 따로 관리하고 있지 않아도 된다. 예를 들어 토스트 밋업에 대한 JWT 페이로드를 다음과 같이 정의할 수 있다. 토스트 밋업 서버는 JWT 서명 검증 후 권한 확인을 위한 추가적인 통신 없이 roles 속성으로 권한 인가를 진행 할 수 있다.

{
    "iss": "meetup.toast.com", <- 발행인
    "iat": 1586364327, <- 발행 시간
    "exp": 1586874996, <- 만료 시간
    "email": "email@email.com", <- 사용자 이메일
    "roles": ["read"] <- 읽기만 가능
}

공개 키 암호 방식에서 서명(Signature)과 암호화(Encryption)

JWT에서는 기본적으로 공개 키 암호 방식(PKC, Public Key Cryptography)을 사용한다. 비대칭 암호 방식을 이용해 공개 키와 비밀 키를 생성하고 이 키를 상황에 따라 나누어 가지며 통신 시 사용한다. 서명은 데이터의 해싱 값을 비밀 키로 서명하고 다시 공개 키로 서명을 검증(Verify)하는데, 서명은 비밀 키를 가진 곳에서만 할 수 있고 공개 키를 가진 어느 곳에서나 이 데이터의 서명을 검증할 수 있다. 반대로 암호화는 공개 키로 데이터를 암호화(Encrypt)하고 비밀 키로 데이터를 복호화(Decrypt) 한다. 공개 키를 가진 누구나 데이터를 암호화해서 데이터를 보낼 수 있지만 비밀 키를 가진 곳에서만 데이터를 복호화 해 내용을 확인할 수 있다. 여기서 확인할 수 있는 점은 공개 키 암호 방식은 비밀 키로 암호화한 데이터를 공개 키로 복호화 할 수 있고, 반대로 공개키로 암호화 한 데이터는 비밀 키로 복호화할 수 있다는 점이다. 당연히 비밀 키로 암호화한 것을 비밀 키로 풀거나 공개 키로 암호화한 것을 공개 키로 풀 수 없다.

서명: 비밀 키를 가진 극소수(주로 한명)만 데이터에 서명할 수 있다. 공개 키를 가진 아무나 데이터의 서명을 검증할 수 있다.
암호화: 공개 키를 가진 아무나 데이터를 암호화할 수 있다. 비밀 키를 가진 극소수만 데이터를 복호화 해 확인할 수 있다.

사용 사례

JWT as API Key

애플의 푸시 메시지 발송 API인 APNs Provider API는 2016년부터 인증을 위해 JWT를 지원하기 시작했다. 기존에는 1년 동안 사용 가능한 인증서를 애플 개발자 콘솔에서 발급받아 mTLS(Mutual TLS)를 통해 API 인증을 했었다. JWT를 API Key로 사용하면서 앞서 말한 성질들 덕분에 APNs는 mTLS 방식에 비해 빠르게 API를 인증할 수 있게되었다. JWT를 생성하고 APNs Provider API를 호출하는 과정은 다음과 같다.

  1. 애플 개발자 콘솔에서 JWT 생성에 필요한 키 아이디(Key ID, kid), 발행인(Issuer, iss), 비밀 키를 발급받는다.
  2. API 호출 전 발급받은 값을 이용해 JWT를 생성한다.
  3. API 호출 시 Authorization 헤더에 JWT를 추가한다.
  4. APNs는 Authorization 헤더에 있는 JWT를 인증한다.

4.png

curl -X POST -H 'Authorization: bearer HEADER.PAYLOAD.SIGNATURE' -d '{"aps":{"alert":"Hello, JWT"}}'
https://api.push.apple.com/3/device/jinho-token

JWT in MSA

1. Access Token in MSA

일반적으로 권한에 따른 접근 제어가 필요한 웹 서비스는 먼저 로그인을 통해 사용자 인증(Authentication)을 진행한다. 권한 서비스(Authorizatioin Service)는 인증을 통과한 클라이언트에게 액세스 토큰(Access Token)을 발급한다. 보통 액세스 토큰은 권한을 가리키는 임의의 문자열로 구성되어 있는데, 권한을 참조한다는 의미에서 참조 토큰(By Reference Token)이라 부른다. 모놀리스(Monolith) 아키텍처에서는 참조 토큰을 액세스 토큰으로 사용해도 큰 문제가 없다. 하지만 수많은 서비스 간 API 호출이 발생하는 MSA(Micro Service Architecture)나 클라우드 환경에서는 액세스 토큰이 가리키는 권한을 확인하기 위해 모든 서비스들이 권한 서비스와 통신을 해야 한다. 서비스가 늘어날수록 권한 서버가 받는 부하는 기하급수적으로 늘어날 수 있다. 이는 MSA의 확장성(Scalability)에 부담을 줄 수 있다. 5.png

2. JWT as Access Token in MSA

참조 토큰 대신 JWT를 액세스 토큰으로 사용할 수 있다. JWT는 자체적으로 필요한 정보를 모두 담을 수 있기 때문에 값 토큰(By Value Token)이라 한다. JWT 액세스 토큰은 MSA(Micro Service Architecture) 환경의 인증과 접근 제어에 적합하다. 서비스는 JWT에 포함된 값을 기준으로 권한을 확인할 수 있다. 서비스와 권한 서비스의 통신은 JWT 서명을 인증하기 위한 공개 키를 조회하는 게 전부다. 하지만 JWT를 액세서 토큰으로 사용하면 장점만 있는 것은 아니다. 단점으로는 사용자에 대한 권한이나 정보가 변경되는 경우 JWT를 새로 발급해야 하며, 경우에 따라 JWT의 크기가 커질 수 있다. 그리고 JWT의 헤더나 페이로드는 디코딩(Decoding)하면 바로 내용을 확인할 수 있기 때문에 JWT의 모든 값들은 클라이언트에게 공개된다. 외부에 노출되어서는 안되거나 민감한 값이 노출될 수 있어 보안 문제로 이어질 수 있는 담점이 있다. 6.png

3. API Gateway between Access Token and JWT in MSA

클라이언트, 권한 서비스, 서비스 사이에 API Gateway를 위치시키면 JWT를 클라이언트에게 숨기면서 서비스간 통신 시 사용할 수 있다. API Gateway는 클라이언트에게 받은 액세스 토큰을 권한 서비스를 통해 JWT로 받아 액세스 토큰 대신 서비스로 넘겨준다. 7.png

결론

JWT의 넓은 범용성, 무결성 보장, 필요한 값을 자체 포함할 수 있는 성질 때문에 많은 곳에서 JWT를 사용하고 있고, 앞으로 더 많은 곳에서 사용할 수 있을 것이다. 특히 MSA에서 서비스 간 통신 시 권한 서비스와의 의존성을 줄일 수 있어 서버와 서버 간 통신에 매우 유용하다. MSA 환경에서 권한 인가의 한 가지 방법으로 JWT를 사용한다면 더 MSA에 어울리고 더 클라우드 네이티브(Cloud Native)한 서비스를 만들 수 있을 것이라고 생각한다.

참고

JWT RFC: https://tools.ietf.org/html/rfc7519 JWS RFC: https://tools.ietf.org/html/rfc7515 JWE RFC: https://tools.ietf.org/html/rfc7516 https://docs.oracle.com/javase/tutorial/security/apisign/gensig.html https://docs.oracle.com/javase/tutorial/security/apisign/versig.html https://ldapwiki.com/wiki/ES256 https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns https://www.ibm.com/blogs/security-identity-access/oauth-jwt-access-token/ https://www.ibm.com/support/knowledgecenter/ko/SSEQTP_liberty/com.ibm.websphere.wlp.doc/ae/cwlp_jwttoken.html https://curity.io/resources/tutorials/howtos/advanced/jwt-assertion/ https://www.oauth.com/oauth2-servers/access-tokens/self-encoded-access-tokens/ https://auth0.com/blog/using-json-web-tokens-as-api-keys/ https://yos.io/2017/09/03/serverless-authentication-with-jwt/ https://techdocs.broadcom.com/content/broadcom/techdocs/us/en/ca-enterprise-software/layer7-api-management/api-management-oauth-toolkit/4-3/installation-workflow/configure-authentication/token-configuration/configure-jwt-access-tokens.html https://www.binance.vision/ko/blockchain/what-is-a-digital-signature https://www.binance.vision/ko/security/what-is-public-key-cryptography https://blog.outsider.ne.kr/1160 https://velopert.com/2389 https://medium.com/@rahulgolwalkar/pros-and-cons-in-using-jwt-json-web-tokens-196ac6d41fb4 https://www.scottbrady91.com/OAuth/OAuth-is-Not-Authentication https://www.oauth.com/oauth2-servers/openid-connect/authorization-vs-authentication/