DevYGwan
JWT의 목적 + 다양한 암호화 알고리즘 본문
이번에는, JWT에 대해서 공부한 것을 공유해보려고 합니다. 기본적으로 JWT는 Json Web Token의 약자로, 데이터를 외부와 주고 받을 때 인증과 권한 부여을 위해 주로 사용합니다. 이번에 JWT Token을 가지고 이를 포함한 QR Code을 생성하여 처리하는 로직을 구현했는데, 이를 구현하면서 단순히 라이브러리를 통해 JWT을 생성하고, 인증하는 과정을 넘어서 JWT의 목적과, 암호화 기법에 대해 공부하게 되어 이를 공유해보도록 하겠습니다.
공유에 앞서, 간단하게 JWT을 한번 정리해보도록 하겠습니다.
※ JWT란
- JWT(Json Web Token)의 약자로, Json 기반의 토큰을 의미한다.
- JWT는 클라이언트-서버 통신에서 효율적이고 간단한 인증 수단이다.
- JWT는 크게 세가지 부분으로 나뉘며, " . " 을 기준으로 구분된다.
- Header(헤더) : JWT의 타입과 사용된 해싱 알고리즘 정보를 포함한다.
- Payload(페이로드) : 토큰에 담길 실제 데이터(클레임)을 포함한다.
- Signature(서명) : 헤더와 페이로드를 합친 비밀 키를 사용해 서명한 값을 의미해 주로 토큰이 변경되지 않았음을 검증하기 위해 사용된다.
※ JWT 사용 시 주의할 점
- 서명은 무결성을 보장하지만, 암호화되진 않음
- 페이로드는 누구나 Base64 디코딩을 통해 내용을 볼 수 있어 민감한 데이터는 포함하지 말아야 한다.
- 토큰 탈취 위험이 있기 때문에 유효기간을 설정하고, 만료된 토큰을 처리하는 로직이 필요하다.
- 서명에 사용된 비밀 키는 안전하게 관리 및 보관해야 한다.
아주 간단하게 JWT에 대해서 설명드렸습니다.
그럼 이제부터는 JWT에 주로 사용하는 알고리즘에 대해서 설명드리도록 하겠습니다.
※ 기본적인 JWT 처리 의존성 추가
Spring과 접목시켜 JWT을 처리할 때 주로 아래와 같은 라이브러리들을 추가해서 처리한 경험이 있으실 겁니다.
//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
- io.jsonwebtoken:jjwt-api을 통해 JWT 생성, 파싱, 서명, 검증과 관련된 작업을 처리한다.
- io.jsonwebtoken:jjwt-jackson을 통해 Json 형태로 들어온 JWT 데이터를 직렬화하고 역직렬화 작업을 처리한다.
- io.jsonwebtoken:jjwt-impl을 통해 서명 알고리즘 처리나 페이로드의 클레임 파싱 등의 작업을 처리한다.
이번에 제가 사용할 라이브러리는 아래와 같습니다.
// jwt
implementation("com.auth0:java-jwt:4.4.0")
이 라이브러리는 기본적으로 JWT를 처리해주는 라이브러리라는 점에서는 비슷하지만, 상황에 따라 차이가 존재합니다.
상황 | 추천 알고리즘 | 추천 이유 |
간단한 JWT 생성/검증 | com.auth0:java-jwt | 간단하고 직관적인 API |
커스텀 클레임/복잡한 검증 | io.jsonwebtoken | 유연한 클레임 추가 및 검증 기능 제공 |
서명 알고리즘 다양성 | io.jsonwebtoken | RSA/ECDSA와 같은 다양한 서명 알고리즘 지원 |
Auth0 관련 프로젝트 | com.auth0:java-jwt | Auth0와의 통합성이 높음 |
의존성 간소화가 필요 | com.auth0:java-jwt | 단일 의존성 설치로 간단한 설정 |
정리하자면,
- io.jsonwebtoken (JJWT): 복잡한 커스텀 요구사항이 많고, 유연성이 중요한 경우 적합. 3가지 의존성을 다 추가해야 정상 작동함.
- com.auth0:java-jwt: 간단한 프로젝트나 Auth0와 통합이 필요한 경우 적합.
저희는 기본적으로 AuthO을 사용하고 있고, 복잡한 커스텀을 할 필요가 없어 간편하게 처리할 수 있는 java-jwt 의존성을 통해 처리했습니다.
※ 다양한 종류의 JWT 암호화 알고리즘
1. HMAC(Hash-based Message Authentication Code)
- HS256 : HMAC + SHA-256
- HS384 : HMAC + SHA-384
- HS512 : HMAC + SHA-512
JWT에서 HMAC은 대칭키 알고리즘으로 사용된다. 주로 키와 메시지를 조합하여 고유한 해시 값을 생성하고, 이 값을 검증하여 데이터가 변경되지 않았는지 확인하기 때문에 해시 함수에서 생성된 길이가 서명 길이가 된다.
알고리즘 종류 | 서명 길이 |
HS256 | 32byte 길이의 고정된 서명 값 생성 |
HS384 | 48byte 길이의 고정된 서명 값 생성 |
HS512 | 54byte 길이의 고정된 서명 값 생성 |
2. RSA (Rivest-Shamir-Adleman)
- RS256: RSA + SHA-256
- RS384: RSA + SHA-384
- RS512: RSA + SHA-512
JWT에서 RSA는 비대칭키 알고리즘으로 사용된다. 발급자는 private key인 개인 키로 JWT 토큰을 생성하며, 검증자는 public key로 검증합니다. RSA 알고리즘은 기본적으로 RSA 키 길이와 동일하다. (RSA 키 길이는 2048 bit 이상을 권장한다.)
알고리즘 종류 | RSA 키 길이 | 서명 길이 |
RS256 | 2048bit의 RSA키 길이 | 256byte 길이의 서명 값 생성 |
RS384 | 3072bit의 RSA키 길이 | 384byte 길이의 서명 값 생성 |
RS512 | 4096bit의 RSA키 길이 | 512byte 길이의 서명 값 생성 |
3. ECDSA (Elliptic Curve Digital Signature Algorithm)
- ES256: ECDSA + SHA-256
- ES384: ECDSA + SHA-384
- ES512: ECDSA + SHA-512
JWT에서 ECDSA 알고리즘은 RSA랑 같은 비대칭 키 알고리즘으로 사용된다. 따라서 private key와 public key가 존재하고 작동 방식 또한 RSA와 같다. 하지만 ECDSA 알고리즘은 RSA보다 더 짧은 키 길이로 높은 보안을 제공한다. 이러한 이유는 ECDS는 타원 곡선 암호학 방식으로 데이터를 암호화하는데, 이 방식의 수학적 특성으로 인해 높은 보안성을 제공할 수 있다.
알고리즘 종류 | 타원 곡선 | r 크기 | s 크기 | 서명 길이 |
ES256 | P-256 | 32byte | 32byte | 64byte 길이의 서명 값 생성 |
ES384 | P-384 | 48byte | 48byte | 96byte 길이의 서명 값 생성 |
ES512 | P-512 | 66byte | 66byte | 132byte 길이의 서명 값 생성 |
이렇게 대표적으로 많이 사용하는 JWT 알고리즘에 대해서 정리해 보았습니다.
그렇다면, 제가 처한 상황과 사용한 방식에 대해서 간단히 설명드리도록 하겠습니다.
※ 요구 사항
저는 JWT 토큰을 가지고, 아래와 같은 요구사항을 처리해야 했습니다.
- 기기 등록에 필요한 QR코드를 생성
- QR 코드 인증
이렇게 2가지 기능이 필요했습니다. 따라서 QR 코드가 저희가 발급한 QR 코드인지를 확인하기 위해 QR 코드 안에 저희가 발급한 토큰을 넣어 해당 토큰을 가지고 인증 처리를 했습니다. 이 때, 고려할 점은 QR 코드 발급은 Python 기반 코드로 처리 했고 이렇게 발급된 QR코드를 특정 화면에서 스캔하게 되면, 이러한 QR 코드의 JWT 토큰이 백엔드로 넘어와 검증하는 로직으로 처리된다는 점입니다.
※ 저희가 사용한 방법
기본적인 QR 코드 발급과, JWT Token 생성은 Python에서 제공하는 라이브러리로 손쉽게 구현이 가능했습니다. 하지만, 중요한 점은 JWT Token 생성에 사용하는 알고리즘입니다. 저희는 위에서 정리한 여러 가지의 JWT 암호화 알고리즘 중 ES512 방식을 사용해서 JWT Token을 생성했습니다. ES512 방식의 알고리즘을 사용한 이유는
- 비대칭 키 알고리즘 방식이여서 키 교환의 위험부담이 적어짐
- 짧은 키로도 높은 보안을 제공함
- 짧은 키로 인해 JWT Token의 값의 길이가 작아짐
저희는 기본적으로 QR 코드에 JWT Token을 넣어야 됐습니다. 토큰을 생성하는 곳과 검증하는 곳이 다르기 때문에 키 교환의 부담이 적은 비대칭키 알고리즘을 사용하는 것이 더 보안적으로 유리하다고 생각해 비대칭키 알고리즘을 사용하기로 결정했습니다. 그런데, 비대칭 키의 의 RSA의 경우 키 길이가 너무 길다는 문제가 발생했습니다. 키 길이가 길어지니 서명 길이도 자연스럽게 길어지고, 그 결과 JWT Token을 저장하고 있는 QR 코드가 너무 빼곡해져 잘 인식이 안되는 문제가 발생했습니다. 따라서 짧은 키로 더 좋은 보안을 제공하는 ECDSA 방식을 선택하게 되었고 이 중 적당한 ES512 방식을 사용해서 JWT를 생성해 QR 코드 url의 params 값으로 넣었습니다. 아래는 제가 임의로 발행한 QR 코드입니다. 보시는 바와 같이 132 byte여도 정말 빼곡한 것을 확인할 수 있습니다.
그렇다면, jwt 관련된 처리를 코드를 통해 살펴보도록 하겠습니다.
1. 키 생성
기본적으로 백엔드의 역할은, 들어온 jwt에 대한 인증 처리 입니다. jwt 생성은 전적으로 python 코드에서 담당하기 때문입니다. jwt 생성 전, jwt 생성을 위한 secret key & public key 값을 생성해야 합니다. 해당 키 값은 명령어를 통해 간단하게 생성이 가능합니다.
# ES512 private key 값 생성
openssl ecparam -genkey -name secp521r1 -noout -out private_key_es512.pem
# ES512 public key 값 생성
openssl ec -in private_key_es512.pem -pubout -out public_key_es512.pem
이렇게 생성된 private key 값은 token 생성 주체인 python 코드에서만 사용하기 때문에 공유될 필요가 없고(공유하면 안됨) 해당 토큰을 처리하는 여러 서버에 private key 값을 통해 생성된 public key 값을 공유합니다. 따라서 이렇게 생성된 public key 값을 전 백엔드 서버에 공유했습니다.
2. jwt 토큰 값 생성
토큰 생성은 별도의 python 코드로 구현되어 있는데, python의 jwt 라이브러리를 통해 간편하게 구현했습니다. private key 값을 읽고 payload를 생성한 후 해당 키를 기반으로 jwt.encode 메서드를 통해 생성해 구현했습니다. 생성은 python 코드기도 하고 간단하기 때문에 따로 올리진 않겠습니다.
3. jwt 토큰 값 검증
검증은 kotlin으로 된 별도의 백엔드 서버에서 진행합니다. 제가 사용한 라이브러리는 위에서 소개한 바와 같이 "com.auth0:java-jwt" 라이브러리입니다. ES512 방식은 비대칭 키 알고리즘으로, 토큰을 생성할 때는 Private Key를 사용하고, 검증할 때는 Public Key를 사용합니다. 이는 대칭 키 알고리즘(예: HMAC)과 달리 서명 생성과 검증에 서로 다른 키를 사용한다는 특징이 있습니다. 따라서 이에 맞게 구현을 진행해야합니다.
1. "com.auth0:java-jwt" 의존성을 추가합니다.
// jwt
implementation("com.auth0:java-jwt:4.4.0")
2. 1번에서 생성한 public key 값을 특정 경로에 저장
- 검증에 필요한 값은 private key 값이 아닌, public key 값 입니다.
3. 해당 public key 값 읽어서 ECPublicKey 값 추출
private fun loadPublicKey(): ECPublicKey {
val publicKeyPemFile = ClassPathResource(qrCodePublicKeyPath)
val publicKeyPEM = Files.readString(publicKeyPemFile.file.toPath())
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s".toRegex(), "")
.trim()
val decodedKey = Base64.getDecoder().decode(publicKeyPEM)
val keySpec = X509EncodedKeySpec(decodedKey)
val keyFactory = KeyFactory.getInstance("EC")
return keyFactory.generatePublic(keySpec) as ECPublicKey
}
- 기본적으로 pem 형식의 public key는 사용하기 위해서는 선처리가 필요합니다.
- PEM 파일에서 prefix와 endfix 문장을 제거 및 공백을 제거한 후 추출한 Base64 문자열을 디코딩하여 바이너리 공개 키 데이터로 변환 후 Java ECPublicKey 객체로 변환하여 사용합니다.
4. 서명 검증에 사용할 JWTProcessor 정의
private fun createJwtProcessor(ecPublicKey: ECPublicKey): DefaultJWTProcessor<SecurityContext> {
val ec512Jwk = ECKey.Builder(P_521, ecPublicKey).build()
val jwkSet = JWKSet(ec512Jwk)
return DefaultJWTProcessor<SecurityContext>().apply {
jwsKeySelector = JWSVerificationKeySelector(
JWSAlgorithm.ES512,
ImmutableJWKSet(jwkSet),
)
}
}
- DefaultJWTProcessor는 JWT 처리 및 검증을 담당하는 기본 프로세서로, JWT의 서명을 검증하고, 토큰의 유효성을 확인합니다.
- ECKey.Builder를 사용하여 ES512 공개 키(JWK)를 생성하고, 생성된 키를 JWKSet에 추가하여 키 정보를 저장합니다.
- 위에서 생성한 jwkSet과 사용된 알고리즘을 기반으로 jwsKeySelector를 정의하여, 서명 검증에 사용할 키를 선택하고 이를 DefaultJWTProcessor에 설정합니다.
이 부분을 찾아보면, Algorithms 을 통해 손쉽게 구현할 수 있는 방법도 있는데, 이 방법을 사용하니 ES512 방식 같은 경우에는 알고리즘이 잘못되었다는 오류를 계속 발생시켜서 이러한 방식을 통해 우회하여 처리를 진행했습니다. 혹시나 이 방법으로 구현하신 분이 있으시면 공유해주시면 테스트 해보겠습니다.
# ex val algorithm = Algorithm.ECDSA512(publicKey, null)
5. 서명 검증을 위한 JwtDecoder 정의 후 @Bean 등록
@Bean
fun qrCodeJwtDecoder(): JwtDecoder {
val ecPublicKey = loadPublicKey()
val jwtProcessor = createJwtProcessor(ecPublicKey)
return NimbusJwtDecoder(jwtProcessor)
}
- NimbusJwtDecoder는 Spring Security에서 제공하는 디코더로, JWT를 디코딩하고 서명을 검증하는 역할을 합니다.
- 최종적으로 public key 파일을 읽고, 이를 기반으로 jwt processor을 만든다음에, JwtDecoder을 생성해 빈으로 등록합니다.
6. 등록된 JwtDecoder Bean을 사용하여 검증 및 payload 값 추출
@Component
class JWTService(
private val qrCodeJwtDecoder: JwtDecoder,
) {
private val logger = KotlinLogging.logger {}
fun jwtTest(
token: String,
) {
try {
// jwt token validate & extract ptc
val jwt = getJwtWithTokenValidation(token)
val id = jwt.claims["id"] as String
// ...
} catch (ex: Exception) {
logger.info { ex.message }
}
}
fun getJwtWithTokenValidation(token: String): Jwt {
try {
return qrCodeJwtDecoder.decode(token)
} catch (ex: Exception) {
logger.info { ex.message }
throw IllegalArgumentException("invalidate token")
}
}
}
- 보시는 바와 같이 등록된 JwtDecoder의 .decode 메서드를 사용하여 간단하게 토큰 검증이 가능한 것을 알 수 있습니다.
- .decode 값을 사용하여 반환된 Jwt값의 .claims["payload key 값"] 을 통해 직관적으로 payload의 값을 추출할 수 있습니다.
※ JWT의 목적
JWT 알고리즘에서 비대칭 키 알고리즘이 사용될 수 있는 이유는, JWT의 주된 목적이 데이터를 암호화하는 것이 아니라 데이터의 무결성과 신뢰성을 보장하는 데 있기 때문입니다. 서명 검증을 통해 발신자의 신원을 증명하고, 데이터가 전송 중 변조되지 않았음을 확인할 수 있습니다. 이러한 방식은 데이터를 보호하면서도 암호화가 필요하지 않은 상황에서 매우 효율적입니다.
JWT는 데이터를 암호화하지 않고도 무결성과 신뢰성을 보장할 수 있는 토큰 기반 인증 방식입니다. JWT는 대칭 키(HMAC) 알고리즘과 비대칭 키(ECDSA, RSA) 알고리즘 모두를 지원하며, 사용 목적과 보안 요구 사항에 따라 선택적으로 적용할 수 있습니다.
- 대칭 키 알고리즘(HMAC)을 사용하는 경우, 동일한 비밀 키를 이용해 토큰의 서명을 생성하고 검증합니다. 이는 키가 양쪽에서 공유되어야 하기 때문에 키 관리가 중요하며, 주로 서버 간 통신과 같이 단일 신뢰 관계에서 사용됩니다.
- 비대칭 키 알고리즘(ECDSA, RSA)을 사용하는 경우, 프라이빗 키로 서명을 생성하고 퍼블릭 키로 서명을 검증합니다. 이를 통해 발신자의 신원을 증명하고, 데이터가 전송 중 변조되지 않았음을 확인할 수 있습니다. 이 방식은 공개 키를 다수와 공유할 수 있어 분산된 시스템이나 클라이언트-서버 모델에서 효과적으로 사용됩니다.
또한, JWT의 페이로드에는 발급자(iss), 수신자(aud), 만료 시간(exp)과 같은 클레임이 포함되어 있으며, 이를 통해 토큰이 특정 조건에서만 유효하도록 제어할 수 있습니다. 이러한 구조 덕분에 JWT는 대칭 및 비대칭 키를 유연하게 활용하여 다양한 환경에서 데이터의 무결성과 신뢰성을 보장합니다.
※ 정리
이번에 전반적으로 Jwt 알고리즘에 대해 경험했습니다. 처음에 프로젝트를 진행할 때 jwt의 경우 기본적으로 제공하는 알고리즘을 사용해서 생성 및 처리해 알고리즘에 대해서 깊게 생각해 본 적이 없었습니다. 그래서 이번 기회에 여러 jwt 알고리즘에 대해 알게 되었고 상황에 따라 유연하게 알고리즘을 선택할 수 있는 기회가 되었던 것 같습니다.
ps) jwt.io을 통한 알고리즘 별 검증 방법
한번도 이 Algorithm 부분을 깊게 본적이 없었는데, 이렇게 JWT 처리를 위한 여러 알고리즘이 존재하고 내가 사용한 알고리즘에 맡게 검증 또한 간편하게 처리할 수 있습니다.
'Study > JAVA' 카테고리의 다른 글
"좋은 코드"의 작성 기술 (2) | 2024.01.22 |
---|---|
java의 final 키워드 :: 참조 변수 & 변수 (1) | 2023.04.21 |
JAVA 특징 정리 (0) | 2023.01.04 |
ArrayList<>(), List.of(), Arrays.asList() 차이점 (0) | 2022.11.16 |