시작하며
이번에 새롭게 사이드프로젝트를 진행하면서 백엔드 언어로 코틀린을 선정했다.
자바랑 여러가지 차이가 있고 뭐가 장점이고 하는 의사결정 과정이 중요한건 아니고,
중요한건 코틀린으로 언어를 바꾸면서 기존에 만들었던 코드를 다시 짜야 한다는 것이다.
JWT 발행이나 인증, Intercepter 같은 기능들이 만들어놓은지 몇년 됐으니 이참에 새롭게 갈아엎자고 생각했다.
기존에 [Spring Boot] 인터셉터 인증 처리 제외 어노테이션 만들기 이런 글을 올린 적이 있었는데 주석에 기재된 날짜를 보니 벌써 3년 전이다.
아까워 하지 말고 전부 버리고 새로 만들기로 했다.
기존에는 JWT 발행 라이브러리로 com.auth0/java-jwt
를 사용했었는데 코틀린으로 언어를 변경하면서 io.jsonwebtoken:jjwt-api:0.12.3
로 라이브러리를 변경했다.
다만 jjwt-api가 0.12.x로 버전을 올리면서 대부분의 메소드가 deprecated 되었다고 한다.
gpt도 그렇고 jetbrain ai도 (둘이 같은 놈이지만..) 구글에 나오는 수많은 블로그 글들도 대부분 이전 버전을 사용하고 있어 deprecated되지 않은 메소드는 어떻게 쓰는데! 하고 골머리를 앓았다.
혹시나 나처럼 고민하는 사람이 있을까 기록을 남긴다.
환경은 아래와 같다
- Java 17
- Spring Boot 3.2.0
- jjwt 0.12.3
디펜던시 추가
dependencies {
...
// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
...
}
이 글을 쓰는 시점에는 0.12.3이 최신인데 위 mvnrepository 링크에서 최신 버전을 확인하고 넣어야 한다.
TokenProvidor 생성
package me.huiya.core.Security
import org.springframework.stereotype.Service
@Service
class TokenProvider() {
}
Security
패키지를 하나 생성하고 그 밑으로 클래스를 생성했다.
아직은 아무 기능도 없는 빈 껍데기다.
RS512 키를 생성하자
JWT Signature를 만들기 위해서는 여러 방법이 있는데 그중에서 비대칭 암호화인 RSA를 사용하는 RS512를 선택했다. 요즘은 ES512나 뭐 이것저것 있다고 하는데 RSA 익숙하니까…
KeyPair keyPair = Jwts.SIG.RS256.keyPair().build(); //or RS384, RS512, PS256, etc...
jjwt 공식 문서에 따르면 위처럼 비대칭 키 쌍을 만들어주는 함수도 있다고 한다.
다만 나는 하나의 사이트에서 동일한 키를 사용하고 서버 재실행마다 키가 바뀌길 원치 않았기 때문에 키 파일을 작성하는 쪽으로 진행했다.
손으로 만들기는 귀찮기 때문에 gen-rs512.sh 파일을 작성했다.
#!/bin/bash
echo -e "# Don't add passphrase"
ssh-keygen -t rsa -b 4096 -m PEM -E SHA512 -f jwtRS512.key -N ""
# Don't add passphrase
openssl rsa -in jwtRS512.key -pubout -outform PEM -out jwtRS512.key.pub
openssl pkcs8 -topk8 -inform pem -in jwtRS512.key -outform pem -nocrypt -out jwtRS512_pkcs8.key
cat jwtRS512.key
cat jwtRS512.key.pub
cat jwtRS512_pkcs8.key
파일을 실행하면 3가지 결과물이 나오게 되는데 pub 파일은 공개키, 나머지 두개는 비밀키 파일이다.
그 중 jwtRS512.key 파일은 PKCS#1 방식으로 jjwt에서 사용할 수 없다.
jjwt에서는 PKCS#8 방식을 요구하므로 jwtRS512_pkcs8.key 파일을 쓰자.
설정 파일에 키 경로 / JWT 설정 추가
application.properties
파일에 아래 내용을 추가했다.
# JWT 설정
core.jwt.secret-key-path=secrets/jwtRS512_pkcs8.key # 비밀키 경로
core.jwt.public-key-path=secrets/jwtRS512.key.pub # 공개키 경로
core.jwt.expiration-hours=3 # 만료시간 3시간으로 설정
core.jwt.issuer=huiya # 토큰 발행자
실제 키 파일의 경로는 src/main/resources/secrets/
이다.
resources 밑에 위치해 있어야 파일을 읽어올 때 수월하게 읽을 수 있다. 그 외에는 어디에 놓든 상관 없다.
설정 값 읽어오기
...
import org.springframework.beans.factory.annotation.Value
@Service
class TokenProvider(
@Value("\${core.jwt.secret-key-path}")
private val privateKeyPath: String,
@Value("\${core.jwt.public-key-path}")
private val publicKeyPath: String,
@Value("\${core.jwt.expiration-hours}")
private val expirationHours: Long,
@Value("\${core.jwt.issuer}")
private val issuer: String
) {
...
코틀린에서 설정 파일 읽어오는 건 어렵지 않다. 자바때보다 쉬워진 것 같다.
파일에서 내용물 읽어오고 키로 변환하자
...
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
class TokenProvider(
...
) {
private lateinit var secretKey: RSAPrivateKey;
private lateinit var publicKey: RSAPublicKey;
init {
val rsaPrivateKey: RSAPrivateKey? = readPrivateKeyFromResource(privateKeyPath)
if(rsaPrivateKey != null) {
secretKey = rsaPrivateKey
}
val rsaPublicKey: RSAPublicKey? = readPublicKeyFromResource(publicKeyPath)
if(rsaPublicKey != null) {
publicKey = rsaPublicKey
}
}
private fun readPrivateKeyFromResource(path: String): RSAPrivateKey? {
val keyFactory = KeyFactory.getInstance("RSA")
val keyStream = javaClass.classLoader.getResourceAsStream(path)
keyStream?.use {
val reader = BufferedReader(InputStreamReader(it))
val lines = reader.readLines()
if (lines.isEmpty()) {
// println("The file is empty or could not be read")
return null
}
val keyString = lines.drop(1).dropLast(1).joinToString("")
val privateKeyBytes = Base64.getDecoder().decode(keyString)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
return keyFactory.generatePrivate(keySpec) as RSAPrivateKey
}
// println("The file could not be found")
return null
}
private fun readPublicKeyFromResource(path: String): RSAPublicKey? {
val keyFactory = KeyFactory.getInstance("RSA")
val keyStream = javaClass.classLoader.getResourceAsStream(path)
keyStream?.use {
val reader = BufferedReader(InputStreamReader(it))
val lines = reader.readLines()
if (lines.isEmpty()) {
return null
}
val keyString = lines.drop(1).dropLast(1).joinToString("")
val publicKeyBytes = Base64.getDecoder().decode(keyString)
val keySpec = X509EncodedKeySpec(publicKeyBytes)
return keyFactory.generatePublic(keySpec) as RSAPublicKey
}
return null
}
...
각각 경로를 입력받아 RSAPrivateKey와 RSAPublicKey로 변환해주는 두 메소드를 만들었다.
이 메소드를 init 구문에서 실행하면 클래스가 생성될때 자동으로 실행된다.
또 @Service 어노테이션을 통해 빈으로 등록되어 한번만 생성되는게 보장되기 때문에 서버 초기 실행시 한번만 읽어오는 코드가 되었다.
이제 진짜 토큰 생성하기
...
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import io.jsonwebtoken.Jwt
import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Base64
import java.util.Date
@Service
class TokenProvider(
...
@Value("\${core.jwt.expiration-hours}")
private val expirationHours: Long,
@Value("\${core.jwt.issuer}")
private val issuer: String
) {
...
/**
* 주어진 연산을 사용하여 지정된 클레임들로 JWT 토큰을 생성합니다.
*
* @param operation `JwtBuilder` 객체를 매개변수로 받고, 토큰에 추가 클레임들을 설정하는 람다 함수입니다.
* @return 생성된 JWT 토큰을 문자열 형태로 반환합니다.
*/
fun createToken(operation: (JwtBuilder) -> Unit): String {
val jwt: JwtBuilder = Jwts.builder()
// JWT header
.header()
.type("JWT") // type 선언
.and()
// pay load
.issuer(issuer)
.issuedAt(Timestamp.valueOf(LocalDateTime.now()))
.expiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS)))
operation(jwt)
// 람다식 통해서 커스텀 claim 입력하도록 열어주기
return jwt
.signWith(secretKey)
.compact()
}
}
설정파일에 미리 등록해놨던 issuer와 expiration-hours을 활용해서 토큰을 생성했다.
createToken에서 람다식을 통해서 커스텀 claim을 입력할 수 있게 열어놓았는데, 아래 예시처럼 사용하면 된다.
var token = tokenProvider.createToken { jwtBuilder ->
jwtBuilder.claim("test", "123")
}
// token:
// eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJpc3MiOiJ3ZWF2ZXIiLCJpYXQiOjE3MDE2MTY5NzgsImV4cCI6MTcwMTYyNzc3OCwidGVzdCI6IjEyMyJ9.fvBkuqrtMK5qLzo9w9235Ynvck40XLt7MiM-GBHjftkzp0M-Mzl6MS96zQjxXnVEM4VtQnyAaXVGJrP2WLKe_cu0jvFCstljAsgelgViCuiLQxh_8mqRBIIP63eOYYcrP_YfylGC9M0yjZ6Q4d4GQSWwG7I_3wP7wD3DLzvYc3TCTZHvzULHI6VYInURqgRnOfKYR_6lY2KGZZZv2vqEW5Lau52OS2SBeE61SahECGeps8hxGeQ2a0i669jbbKK1OAQpTaTpi5C-_whNpVNsd63aP_-1aRdCOrnCu_bXgSbSL5Oq-V19VW0ZYNbAe5F6AYPXSeewcv-sLSVnOpm09s7DmWglydMyS7YkwJX3BoO01ZZ3jDS_hZ_GcbCnGUZ8g8_42H8BVQYOu93ZcZ4kVinAoS7UjOHL65D99FHGyHK31RAYUmbxjmC8HLlH3vIdYemI43ELtwVzEx5g8cg1cxhtp6vg1zygSt-tngXbr2OIniaYB_5yfUHMkxSn3ApvfBx73kUTmrkyKS5hGzfYAFFiDlrHjCdudCKOixDlPdwMOQq-qBJI51D5LZFLcNtONgnKqDgE9pRLFfoZ1Gmsu8nbAHW69zDiem3PbihvZhSlR1i9kOgF0anha5I6NDXDNXWgoMAKSuXtZwz31PmQwVuBBJioTywCco2pQZGP5IE
이렇게 하면 TokenProvidor와 실제 필요한 값을 넣는 구현부를 분리해서 전체적인 결합도를 낮추는데 도움이 된다.
다음에 새로운 프로젝트를 시작한다 하더라도 issuer나 만료시간은 공통적으로 들어가는 요소기 때문에 TokenProvidor를 재활용 할 수 있다.
토큰 읽어오기
...
@Service
class TokenProvider(
...
) {
...
/**
* JWT 토큰을 디코딩하고 그 안에 포함된 클레임을 반환합니다. 기본 클레임은 반환되지 않음에 주의!
*
* @param token JWT Token String.
* @return 디코딩된 JWT claims
* @throws io.jsonwebtoken.security.SignatureException JWT 키 오류
* @throws io.jsonwebtoken.IncorrectClaimException iss 등 claim이 정확하지 않은 오류
* @throws io.jsonwebtoken.ExpiredJwtException 만료된 토큰 오류
*/
fun decodeToken(token: String): Map<String, Any> {
val jws: Jws<Claims> = Jwts.parser()
.verifyWith(publicKey)
.requireIssuer(issuer)
.clockSkewSeconds(1 * 60) // 만료시간 오차범위 1min
.build()
.parseSignedClaims(token)
val claims: Claims = jws.payload
// 기본 claim은 제거하고 custom claims만 리턴하도록 구성
val standardClaims = listOf("iss", "iat", "exp", "sub", "aud", "nbf", "jti")
val result: MutableMap<String, Any> = mutableMapOf()
for ((key, value) in claims) {
if (key !in standardClaims) {
result[key] = value
}
}
return result
}
}
tokenString을 입력하면 미리 준비된 RSAPublicKey로 복호화하고 claim을 리턴해주는데, 표준 claim은 일반적인 개발시 사용하지 않을 것 같아 제거했다.
val _re: Map<String, Any> = tokenProvider.decodeToken(token)
println(_re)
// {test=123}
리턴값이 Map 구조이기 때문에 사용하는 쪽에서 jjwt를 import 할 필요도 없고 jjwt에 대해 아무것도 몰라도 된다.
마무리
TokenProvidor의 최종 결과물은 아래에 남겨놓았다.
package me.huiya.weaver.Security
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import io.jsonwebtoken.Jwt
import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Base64
import java.util.Date
@Service
class TokenProvider(
@Value("\${core.jwt.secret-key-path}")
private val privateKeyPath: String,
@Value("\${core.jwt.public-key-path}")
private val publicKeyPath: String,
@Value("\${core.jwt.expiration-hours}")
private val expirationHours: Long,
@Value("\${core.jwt.refresh-expiration-hours}")
private val refreshExpirationHours: Long,
@Value("\${core.jwt.issuer}")
private val issuer: String
) {
private lateinit var secretKey: RSAPrivateKey;
private lateinit var publicKey: RSAPublicKey;
init {
val rsaPrivateKey: RSAPrivateKey? = readPrivateKeyFromResource(privateKeyPath)
if(rsaPrivateKey != null) {
secretKey = rsaPrivateKey
}
val rsaPublicKey: RSAPublicKey? = readPublicKeyFromResource(publicKeyPath)
if(rsaPublicKey != null) {
publicKey = rsaPublicKey
}
}
private fun readPrivateKeyFromResource(path: String): RSAPrivateKey? {
val keyFactory = KeyFactory.getInstance("RSA")
val keyStream = javaClass.classLoader.getResourceAsStream(path)
keyStream?.use {
val reader = BufferedReader(InputStreamReader(it))
val lines = reader.readLines()
if (lines.isEmpty()) {
// println("The file is empty or could not be read")
return null
}
val keyString = lines.drop(1).dropLast(1).joinToString("")
val privateKeyBytes = Base64.getDecoder().decode(keyString)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
return keyFactory.generatePrivate(keySpec) as RSAPrivateKey
}
// println("The file could not be found")
return null
}
private fun readPublicKeyFromResource(path: String): RSAPublicKey? {
val keyFactory = KeyFactory.getInstance("RSA")
val keyStream = javaClass.classLoader.getResourceAsStream(path)
keyStream?.use {
val reader = BufferedReader(InputStreamReader(it))
val lines = reader.readLines()
if (lines.isEmpty()) {
return null
}
val keyString = lines.drop(1).dropLast(1).joinToString("")
val publicKeyBytes = Base64.getDecoder().decode(keyString)
val keySpec = X509EncodedKeySpec(publicKeyBytes)
return keyFactory.generatePublic(keySpec) as RSAPublicKey
}
return null
}
/**
* 주어진 연산을 사용하여 지정된 클레임들로 JWT 토큰을 생성합니다.
*
* @param operation `JwtBuilder` 객체를 매개변수로 받고, 토큰에 추가 클레임들을 설정하는 람다 함수입니다.
*
* @return 생성된 JWT 토큰을 문자열 형태로 반환합니다.
*/
fun createToken(operation: (JwtBuilder) -> Unit): String {
val jwt: JwtBuilder = Jwts.builder()
// JWT header
.header()
.type("JWT")
.and()
// pay load
.issuer(issuer)
.issuedAt(Timestamp.valueOf(LocalDateTime.now()))
.expiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS)))
// TODO: refresh token 구현 바람
operation(jwt)
// 람다식 통해서 커스텀 claim 입력하도록 열어주기
return jwt
.signWith(secretKey)
.compact()
}
/**
* JWT 토큰을 디코딩하고 그 안에 포함된 클레임을 반환합니다. 기본 클레임은 반환되지 않음에 주의!
*
* @param token JWT Token String.
* @return 디코딩된 JWT claims
* @throws io.jsonwebtoken.security.SignatureException JWT 키 오류
* @throws io.jsonwebtoken.IncorrectClaimException iss 등 claim이 정확하지 않은 오류
* @throws io.jsonwebtoken.ExpiredJwtException 만료된 토큰 오류
*/
fun decodeToken(token: String): Map<String, Any> {
val jws: Jws<Claims> = Jwts.parser()
.verifyWith(publicKey)
.requireIssuer(issuer)
.clockSkewSeconds(1 * 60) // 만료시간 오차범위 1min
.build()
.parseSignedClaims(token)
val claims: Claims = jws.payload
// 기본 claim은 제거하고 custom claims만 리턴하도록 구성
val standardClaims = listOf("iss", "iat", "exp", "sub", "aud", "nbf", "jti")
val result: MutableMap<String, Any> = mutableMapOf()
for ((key, value) in claims) {
if (key !in standardClaims) {
result[key] = value
}
}
return result
}
}
이 글에서 소개한건 jjwt 라이브러리를 사용해서 JWT를 구현한 것 뿐이다.
이걸 이용해서 사용자 인증은 또 따로 처리해줘야 한다.
참고한 글
'개발 > Backend' 카테고리의 다른 글
[Spring Boot] Log4J2 shell 취약점 대응하기 (0) | 2021.12.13 |
---|---|
[Spring Boot] 인터셉터 인증 처리 제외 어노테이션 만들기 (0) | 2021.07.15 |
[Spring Boot] 작업 비동기로 실행하기 (2) | 2021.07.13 |
[Spring Boot] html 템플릿 메일 보내기 - Thymeleaf (0) | 2021.07.13 |
Access-Control-Allow-Origin을 넣어도 POST에서 CORS 에러가 발생할때 (0) | 2020.07.09 |