From da9c7953f49633f8fd5349981b448771ad1bd655 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:49:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EB=90=A0=20subject=20+=20claim=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9D=98=20JwtUtils=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtUtils.kt | 99 +++++++++++++++++++ .../kotlin/roomescape/auth/JwtUtilsTest.kt | 63 ++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt create mode 100644 src/test/kotlin/roomescape/auth/JwtUtilsTest.kt diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt new file mode 100644 index 00000000..88fd9b18 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt @@ -0,0 +1,99 @@ +package roomescape.auth.infrastructure.jwt + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import java.util.* +import javax.crypto.SecretKey + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class JwtUtils( + @Value("\${security.jwt.token.secret-key}") + private val secretKeyString: String, + + @Value("\${security.jwt.token.ttl-seconds}") + private val tokenTtlSeconds: Long +) { + private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) + + fun createToken(subject: String, claims: Map): String { + log.debug { "[JwtUtils.createToken] 토큰 생성 시작: subject=$subject, claims=${claims}" } + + val date = Date() + val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000)) + + return Jwts.builder() + .subject(subject) + .claims(claims) + .issuedAt(date) + .expiration(accessTokenExpiredAt) + .signWith(secretKey) + .compact() + .also { + log.debug { "[JwtUtils.createToken] 토큰 생성 완료. token=${it}" } + } + } + + fun extractSubject(token: String?): String { + return runWithHandle { + log.debug { "[JwtUtils.extractSubject] subject 조회 시작: token=$token" } + + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + .subject + ?.also { + log.debug { "[JwtUtils.extractSubject] subject 조회 완료: subject=${it}" } + } + ?: run { + log.debug { "[JwtUtils.extractSubject] subject 조회 실패: token=${token}" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } + } + + fun extractClaim(token: String?, key: String): String { + return runWithHandle { + log.debug { "[JwtUtils.extractClaim] claim 조회 시작: token=$token, claimKey=$key" } + + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + .get(key, String::class.java) + ?.also { + log.debug { "[JwtHandler.extractClaim] claim 조회 완료: claim=${it}" } + } + ?: run { + log.info { "[JwtUtils.extractClaim] claim=${key} 조회 실패: token=$token" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } + } + + private fun runWithHandle(block: () -> T): T { + try { + return block() + } catch (e: AuthException) { + throw e + } catch (_: IllegalArgumentException) { + throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) + } catch (_: ExpiredJwtException) { + throw AuthException(AuthErrorCode.EXPIRED_TOKEN) + } catch (e: Exception) { + log.warn { "[JwtUtils] 예외 발생: message=${e.message}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + } +} diff --git a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt new file mode 100644 index 00000000..627ed8dd --- /dev/null +++ b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt @@ -0,0 +1,63 @@ +package roomescape.auth + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertThrows +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.common.config.next +import roomescape.util.tsidFactory + +class JwtUtilsTest( +) : FunSpec() { + private val jwtUtils: JwtUtils = JwtUtils(secretKeyString = "caSf+JhhY9J9VcZxDQ7SNNOEIAJSZ9onsFstGNv9bjPHmHoTTcX+5wway5+//SPi", tokenTtlSeconds = 5) + + init { + context("종합 테스트") { + test("Subject + Claim을 담아 토큰을 생성한 뒤 읽어온다.") { + val subject = "${tsidFactory.next()}" + val claim = mapOf("name" to "sangdol") + + jwtUtils.createToken(subject, claim).also { token -> + val extractedSubject = jwtUtils.extractSubject(token) + val name = jwtUtils.extractClaim(token, "name") + + extractedSubject shouldBe subject + name shouldBe "sangdol" + } + } + } + + context("실패 테스트") { + val subject = "${tsidFactory.next()}" + val claim = mapOf("name" to "sangdol") + val commonToken = jwtUtils.createToken(subject, claim) + + context("subject를 가져올 때 null 토큰을 입력하면 실패한다.") { + shouldThrow { + jwtUtils.extractSubject(null) + }.also { + it.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND + } + } + + context("claim을 가져올 때 null 토큰을 입력하면 실패한다.") { + shouldThrow { + jwtUtils.extractClaim(token = null, key = "") + }.also { + it.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND + } + } + + context("토큰은 유효하나 claim이 없으면 실패한다.") { + shouldThrow { + jwtUtils.extractClaim(token = commonToken, key = "abcde") + }.also { + it.errorCode shouldBe AuthErrorCode.MEMBER_NOT_FOUND + } + } + } + } +} \ No newline at end of file