JWT 자바 가이드

이 글은 OAuth와 JSON Web Token과 같은 토큰 기반의 인증을 처음 접하는 자바 개발자를 위한 가이드입니다.

우선 JSON Web Token (줄여서 JWT라고 하고 “jot”이라고 읽습니다.)을 살펴 보겠습니다. JWT는 권한claim라고 하는 정보를 디지털 서명을 하고 나중에 비밀 서명 키로 검증하는 도구 입니다.

토큰 인증이란?

애플리케이션이 사용자가 누구인지 확인하는 과정을 인증authentication이라고 합니다. 전통적으로 애플리케이션은 세션 아이디를 기반으로하는 세션 쿠키를 통해서 서버측에 사용자의 식별자identity를 저장하고 값을 유지합니다. 이러한 구조에서는 개발자가 서버 고유의 방식으로 세션 저장소를 구성하거나 완전히 독립적인 세션 저장소 계층을 구현해야 합니다.

토큰은 최신의 인증 방식으로, 서버측 세션 아이디로 불가능했던 문제를 해결할 수 있습니다. 세션 아이디 대신에 토큰을 사용해서 서버측 부하를 낮출 수 있고 능률적인 접근 권한 관리를 할 수 있으며 분산/클라우드 기반 인프라스트럭처에 더 잘 대응할 수 있습니다. 토큰 인증에서는 사용자가 확인 가능한 증명서를 제시했을 때 토큰이 생성됩니다. 최초의 인증은 보통 사용자 이름과 암호를 통해서 이루어지며, API 키 또는 다른 서비스에서 발급된 토큰을 사용할 수도 있습니다.

JWT의 구조

JWT는 크게 세 부분(헤더header, 페이로드payload, 시그너처signature)으로 나눠져 있습니다. 다음의 JWT의 예입니다.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
.
eyJzdWIiOiJ1c2Vycy9Uek1Vb2NNRjRwIiwibmFtZSI6IlJvYmVydCBUb2tlbiBNYW4iLCJzY29wZSI6InNlbGYgZ3JvdXBzL2FkbWlucyIsImV4cCI6IjEzMDA4MTkzODAifQ
.
1pVOLQduFWW3muii1LExVBt2TK1-MdRI4QjhKryaDwc

이 예에서 첫 번째 부분은 이 토큰에 대해 설명하는 헤더입니다. 두 번째 부분은 페이로드payload이며 JWT의 권한claims을 담고 있습니다. 세 번째 부분은 이 토큰의 무결성을 검증하기 위한 시그너처 해쉬signature hash입니다.

페이로드 부분을 디코드하면 다음과 같은 JWS의 권한claims을 담고 있는 JSON 객체를 확인할 수 있습니다.

{
"sub": "users/TzMUocMF4p",
"name": "Robert Token Man",
"scope": "self groups/admins",
"exp": "1300819380"
}

이 권한claim에는 다음과 같은 사항들이 담겨 있습니다.

  • 사용자가 누구인지와 사용자 리소스에 대한 URI (sub claim)
  • 이 토큰으로 이 사용자가 무엇에 접근할 수 있는 가 (scope claim)
  • 언제 토큰이 만료되는가. 서버의 API에서는 이 토큰을 검증할 때 이 만료 시간을 확인합니다.

이 토큰은 개발자가 알고 있는 비밀 키로 서명되어 있으므로 이 토큰에 담겨 있는 권한claim을 암묵적으로 신뢰할 수 있습니다.

JWE, JWS, JWT

JWT 규격에 따르면 “JWT는 권한claims 집합을 JSW와 (또는) JWE 구조로 인코드한 JSON 객체로 표현된다”라고 되어 있습니다. 기술적으로 “JWT”는 서명되지 않은 토큰을 의미하지만 일반적인 상황에서는 JWT는 JWS나 JWS+JWE를 의미합니다.

JWS — JSON Web Signature

서버는 JWT를 JWS 체계로 서명해서 시그너처signature와 함께 클라이언트로 전송합니다. 시그너처는 JWT에 포함된 권한claim이 위조되었거나 변경되지 않았다는 것을 보장합니다. 즉, JWS를 통해 위변조를 확인할 수 있을 뿐 JWT는 근본적으로 암호화 되지않은 문자열plaintext입니다. 따라서 JWT에 민감한 정보를 저장해서는 안됩니다.

JWE — JSON Web Encryption

반면에 JWE 체계에서는 내용이 서명없이 암호화됩니다. JWT에 암호화를 통한 기밀성은 부여하지만 JWE를 JWS로 서명해서 담는 만큼의 보안성을 제공하지는 못합니다. 즉, JWS+JWE로 안전한 토큰을 만들 수 있습니다.

OAuth

OAuth 2.0은 인증authentication과 허가authorization를 제공하는 서비스와 상호 연동을 하기 위한 프레임워크입니다. 수많은 모바일 및 웹 애플리케이션에 폭 넓게 도입되었습니다. OAuth 2.0에서 토큰의 형태를 규정하고 있지는 않지만 JWT가 사실상의 빠르게 산업 표준이 되어 가고 있습니다.

OAuth의 구조에서는 두 가지 토큰 타입(액세스access 토큰, 리프레쉬refresh 토큰)이 있습니다. 최초의 인증에서 사용자의 애플리케이션은 이 두 가지 토큰을 발급 받습니다. 액세스 토큰은 상대적으로 짧은 시간 이후에 무효화expire되도록 설정되어 있으므로 최초의 액세스 토큰이 무효화되면 리프레쉬refresh 토큰을 사용해서 새로운 토큰을 획득할 수 있습니다. 리프레쉬 토큰에도 유효 기간을 설정expiration을 할 수 있어서 그 시점까지 무제한으로 사용할 수 있습니다. 액세스 토큰과 리프레쉬 토큰 둘다 내장된 보안(서명될 때)을 가지고 있어서 변조를 방지할 수 있으며 특정 기간 동안만 유효합니다.

자바에서 JWT의 생성과 검증

이제 애플리케이션에서 토큰을 만들어서 사용하는 방법에 대해서 설명하겠습니다.

JSON Web Token의 생성과 검증까지 모든 기능을 제공하는 자바 라이브러리인 JJWT를 사용하겠습니다.

생성

JJWT가 플루언트 인터페이스를 기반하므로 JWT의 생성 과정은 세 단계를 거쳐야합니다.

  1. Issuer, Subject, Expiration, ID와 같은 토큰의 내부 권한claims를 정의합니다.
  2. JWT를 암호화 서명을 해서 JWS를 만듭니다.
  3. JWT Compact Serialization 규칙에 따라 URL로 사용할 수 있도록 JWT를 압축합니다.

최종 JWT는 3부분으로 이루어져 있으며 지정된 키로 특정 서명 알고리즘으로 서명된어 Base64 인코딩된 문자열입니다.

JJWT 라이브러리로 JWT를 생성하는 예입니다.

String jwt = Jwts.builder()
.setSubject("users/TzMUocMF4p")
.setExpiration(new Date(1300819380))
.claim("name", "Robert Token Man")
.claim("scope", "self groups/admins")
.signWith(
SignatureAlgorithm.HS256,
"secret".getBytes("UTF-8")
)
.compact();

검증

일반적으로 JWT를 생성해서 요청한 클라이언트로 전달합니다. 클라이언트는 토큰을 저장해두고 서버로 요청할 때마다 전달하게 됩니다. 보통의 경우 쿠키 값이나 HTTP의 Authorization 헤더에 담아 보내게 됩니다.

HTTP/1.1

GET /secure-resource

Host: https://yourapplication.com

Authorization: Bearer eyJraWQiOiIzMUUzRDZaM0xaMVdFSEJGWVRQRksxRzY4IiwiYWxnIjoiSFMyNTYifQ.eyJqdGkiOiI2a3NjVFMyUjZuYlU3c1RhZ0h0aWFXIiwiaWF0IjoxNDQ1ODU0Njk0LCJpc3MiOiJodHRwczovL2FwaS5zdG9ybXBhdGguY29tL3YxL2FwcGxpY2F0aW9ucy8zUUlNbEpLS04yd2hHQ1l6WFh3MXQ4Iiwic3ViIjoiaHR0cHM6Ly9hcGkuc3Rvcm1wYXRoLmNvbS92MS9hY2NvdW50cy8xeG15U0dLMXB5VVc1c25qOENvcmU1IiwiZXhwIjoxNDQ1ODU4Mjk0LCJydGkiOiI2a3NjVE9pTUNESVZWM05qVTIyUnlTIn0.VJyMOicMOdcOCtytsx4hoPHy3Hl3AfGNfi2ydy8AmG4

서버에서 JWT를 수신하면 신뢰성을 확인(디지털 서명을 확인하고 무효화expired되지 않았는지 검사하고 변조되지 않았는지 검증)한 후 토큰을 보낸 사용자에 대한 정보를 획득합니다.

위에서 만든 JWT를 검증하는 예입니다.

String jwt = <jwt passed in from above>
Jws<Claims> claims = Jwts.parser()
.setSigningKey("secret".getBytes("UTF-8"))
.parseClaimsJws(jwt)
String scope = claims.getBody().get("scope")
assertEquals(scope, "self groups/admins");

만약 서명에 오류가 있다면 parseClaimsJws()에서 SignatureException가 발생합니다. 파싱에 성공했다면 예제의 마지막 라인과 같이 각각의 권한claim를 가져와서 값을 검사할 수 있습니다.

예외

JJWT로 JWT 작업을 수행하는 과정에서 검증 오류가 발생할 수 있습니다. JJWT의 에외들은 모두 RuntimeExceptionsJwtException의 하위 클래스입니다.

  • ClaimJwtException: JWT 권한claim 검사가 실패했을 때
  • ExpiredJwtException: 유효 기간이 지난 JWT를 수신한 경우
  • MalformedJwtException: 구조적인 문제가 있는 JWT인 경우
  • PrematureJwtException: 접근이 허용되기 전인 JWT가 수신된 경우
  • SignatureException: 시그너처 연산이 실패하였거나, JWT의 시그너처 검증이 실패한 경우
  • UnsupportedJwtException: 수신한 JWT의 형식이 애플리케이션에서 원하는 형식과 맞지 않는 경우. 예를 들어, 암호화된 JWT를 사용하는 애프리케이션에 암호화되지 않은 JWT가 전달되는 경우에 이 예외가 발생합니다.

더 많은 예외 클래스가 JJWT에서 정의되어 있으며 io.jsonwebtoken 팩키지에서 확인할 수 있습니다.

토큰은 안전한가?

토큰을 안전하게 사용하기위해서 다음과 같은 모범 사례를 따르도록 권장합니다.

  • JWT는 안전한 HttpOnly 쿠키에 저장해야 합니다. 이렇게 해야 Cross-Site Scripting(XSS) 공격을 방지할 수 있습니다.
  • 쿠키를 사용해서 JWT를 전송한다면, CSRF 방어가 무엇보다 중요합니다. 악의적인 다른 도메인에 의해서 사용자가 인식하지 못 하는 사이에 우리가 구축한 웹 서버로 요청이 발생할 수 있기 때문입니다. 토큰의 전송 방식으로 쿠키를 사용한다면 CSRF에 대한 대비책을 반드시 준비 해야 합니다.
  • 강력한 키key로 토큰을 서명해야 하며 키key는 인증 서비스에서만 접근해야 합니다. 토큰을 사용해서 사용자를 인증할 때마다 항상 보안 키로 서명되어 있는지 검사해야 합니다.
  • 민감한 데이터는 JWT로 저장하면 안됩니다. 토큰은 일반적으로 조작을 방지하기 위한 목적으로 서명되므로 권한claim 데이터는 쉽게 디코드decode해서 볼 수 있습니다. 민감한 정보를 토큰으로 저장하면 유출될 수 있습니다. 서명을 위한 보안 키는 토콘의 발행자issuer와 소비자consumer만 접근할 수 있어야 하며 절대 외부에서의 접근을 허용해서는 안됩니다.
  • 리플레이replay 공격에 대비하려면 nonce(jti claim)과 유효기간expiration time (exp claim), 생성시간creation time을 권한claims에 포함시켜야 합니다. 여기에 대한 자세한 사항은 JWT 규격에 정의되어 있습니다.

JWT Inspector

JWT Inspector는 크롬 브라우저의 오픈소스 익스텐션으로 개발자가 JWT를 브라우저 내에서 디버그할 수 있도록 도와줍니다.

Show your support

Clapping shows how much you appreciated Out of Bedlam’s story.