사용자 로그인 후 활동을 위한 토큰이 필요하게 되어 jwt를 생성하는 작업을 정리해보도록 하겠다.
이렇게 사용하는 것 같은데 각자의 깃허브를 들어가보니 jjwt의 깃허브가 가장 업데이트나 설명이 잘 되어있는 것 같아 jjwt를 선택하게 되었다.
dependencies {
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
}
jjwt는 인터페이스 api와 impl, jackson을 나누어 의존성을 추가해줘야한다.
덕분에 클래스 참조없이 runtimeOnly로 구성된 impl, jackson을 가볍게 사용할 수 있게 되었다.
이후에 테스트 시에도 주입할 때 편할 것 같아 보인다.
jwt 생성은 access token도 있지만 refresh token도 있고 여러 방면에서 사용할 수 있기 때문에 provider로 만들어 생성과 검증을 맡기도록 하겠다.
@Component
class JwtProvider (
@Value("\${jwt.secret}")
private val secret: String,
@Value("\${jwt.issuer}")
private val issuer: String
) {
private val secretKey: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
fun createJwt(jwtPayload: JwtPayload): String {
val expiredTime = jwtPayload.now.plusSeconds(jwtPayload.expiredTimestamp)
val jwtBuilder = Jwts.builder()
if (jwtPayload.claims.isNullOrEmpty()) {
return jwtBuilder
.id(UUID.randomUUID().toString())
.subject(jwtPayload.identificationValue)
.issuer(issuer)
.issuedAt(DateUtil.convertZoneDateTimeToDate(jwtPayload.now))
.expiration(DateUtil.convertZoneDateTimeToDate(expiredTime))
.signWith(secretKey, Jwts.SIG.HS512)
.compact()
}
return jwtBuilder.claims(jwtPayload.claims)
.id(UUID.randomUUID().toString())
.subject(jwtPayload.identificationValue)
.issuer(issuer)
.issuedAt(DateUtil.convertZoneDateTimeToDate(jwtPayload.now))
.expiration(DateUtil.convertZoneDateTimeToDate(expiredTime))
.signWith(secretKey, Jwts.SIG.HS512)
.compact()
}
fun verifyToken(jwt: String?): JwtPayload {
try {
val claimsJwt = Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(jwt)
return JwtPayload(
claimsJwt.payload[jwt, String::class.java],
claimsJwt.payload.expiration.time,
)
} catch (e: SignatureException) {
// 비밀키 처리
throw Error("Invalid JWT signature")
} catch (error: ExpiredJwtException) {
// 유효시간 지남
throw Error("Invalid JWT signature")
}
}
}
jwt 생성 시에 필요한 정보들 중 비밀키 등 필요한 정보들을 동적으로 주입받기 위해서 컴포넌트로 등록한 후
subject안에 유저의 uuid를 넣고 jwt의 중복 발행을 방지하기 위해 jti를 랜덤 uuid로 생성해주었다.
expiration은 jwtPayload안에 넣어져있는데 이유는 관리의 편의성, 테스트의 편의성에 있다.
또한 claims가 필요한 jwt와 아닌 jwt 생성을 구별하기 위해 생성을 분리하였다.

공식 깃허브에는 시크릿키를 토큰 발급 시마다 새로운 키로 발급하는 것을 권장한다.
(보통 시크릿키를 문자열로 넣게 되고 그것이 보안의 취약점이 되기 때문)
하지만 이렇게 새로운 키를 발급하게 되면 파드를 여러개 쓰는 서버라던가.. 재배포 되는 상황 등등 키가 바뀌는 상황에서 검증을 하는 것이 문제가 되지 않을까하는 고민이 들어서 일단은 보통 많이 사용하는 문자열로 시크릿키를 만들게 되었다.
그대신 만들 때 문자열을 그대로 사용하지 않고 jwt에서 권장하는 방법으로 HMAC-SHA 알고리즘을 사용해 암호화하여 사용하기로 하자ㅎㅎ
@DisplayName("jwt 생성 및 검증 테스트")
class JwtProviderTest (
@Value("\${jwt.secret}")
private val secret: String,
@Value("\${jwt.issuer}")
private val issuer: String,
@Autowired
private var jwtProvider: JwtProvider
) {
@Test
fun contextLoads() {
assertNotNull(secret)
assertNotNull(issuer)
}
@BeforeEach
fun setUp() {
jwtProvider = JwtProvider(secret, issuer)
}
@Test
@DisplayName("jwt 생성 테스트")
fun `jwt 생성에 성공`() {
// given
val secretKey: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
val jwtPayload = createJwtPayload(
email = email,
expiredTimestamp = 86400,
issuedAt = ZonedDateTime.now()
)
val expiration = jwtPayload.now.plusSeconds(jwtPayload.expiredTimestamp)
// when
val jwt: String = jwtProvider.createJwt(jwtPayload)
// then
val claimsJwt = Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(jwt)
assertEquals(claimsJwt.payload.issuer, issuer)
assertEquals(claimsJwt.payload["email"], email)
assertEquals(
claimsJwt.payload.expiration.toString(),
DateUtil.convertZoneDateTimeToDate(expiration).toString()
)
assertNotNull(claimsJwt.payload.id)
}
private fun createJwtPayload(email: String, expiredTimestamp: Long, issuedAt: ZonedDateTime): JwtPayload
= JwtPayload(
email = email,
expiredTimestamp = expiredTimestamp,
now = issuedAt
)
companion object {
private val email = "test@test.com"
}
}
일단 JwtProvider 자체가 secret 문자열과 issuer을 주입받기 때문에 테스트 코드에서도 주입을 받아야한다.
그래서 테스트를 진행하기전 BeforeEach 데코레이터를 사용하여 주입을 받고 시작하도록 진행하였다.

테스트는 제대로 된 값이 들어갔는지 존재해야되는 값이 존재하는지에 대한 내용을 검증하였다.

테스트를 돌리는 과정에서
잘 안보이지만 시크릿키를 생성하는 과정에서 key의 byte 길이가 맞지 않다는 에러가 나왔다.
테스트 시 의존성 주입을 하지 않아서 나오는 에러로 필자의 경우 JwtProvider의 의존성을 주입하지 않았다. @Autowired로 탐색하게 하자!
(usecase를 테스트할 때는 fake 객체를 만드는 방법도 고려해볼 법하다)
이 글에 검증까지 보충하도록 하고 마무리하도록 하겠다.