diff --git a/src/main/kotlin/kr/galaxyhub/sc/auth/config/JwtConfig.kt b/src/main/kotlin/kr/galaxyhub/sc/auth/config/JwtConfig.kt new file mode 100644 index 0000000..58e1ca5 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/auth/config/JwtConfig.kt @@ -0,0 +1,38 @@ +package kr.galaxyhub.sc.auth.config + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import java.util.Date +import java.util.concurrent.TimeUnit +import kr.galaxyhub.sc.auth.domain.JwtExtractor +import kr.galaxyhub.sc.auth.domain.JwtProvider +import kr.galaxyhub.sc.auth.infra.JwtExtractorImpl +import kr.galaxyhub.sc.auth.infra.JwtProviderImpl +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JwtConfig( + @Value("\${galaxyhub.jwt.secret-key}") private val secretKey: String, +) { + + @Bean + fun jwtProvider(): JwtProvider { + val expirationMilliseconds: Long = TimeUnit.MINUTES.toMillis(30) + return JwtProviderImpl( + Keys.hmacShaKeyFor(secretKey.encodeToByteArray()), + ::Date, // == () -> new Date() + expirationMilliseconds, + ) + } + + @Bean + fun jwtExtractor(): JwtExtractor { + val jwtParser = Jwts.parser() + .clock(::Date) + .verifyWith(Keys.hmacShaKeyFor(secretKey.encodeToByteArray())) + .build() + return JwtExtractorImpl(jwtParser) + } +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/auth/domain/JwtExtractor.kt b/src/main/kotlin/kr/galaxyhub/sc/auth/domain/JwtExtractor.kt new file mode 100644 index 0000000..203429b --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/auth/domain/JwtExtractor.kt @@ -0,0 +1,8 @@ +package kr.galaxyhub.sc.auth.domain + +import java.util.UUID + +fun interface JwtExtractor { + + fun parse(jwtToken: String): UUID +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImpl.kt b/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImpl.kt new file mode 100644 index 0000000..62bd7e0 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImpl.kt @@ -0,0 +1,53 @@ +package kr.galaxyhub.sc.auth.infra + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.JwtParser +import java.util.UUID +import kr.galaxyhub.sc.auth.domain.JwtExtractor +import kr.galaxyhub.sc.common.exception.InternalServerError +import kr.galaxyhub.sc.common.exception.UnauthorizedException + +private val log = KotlinLogging.logger {} + +class JwtExtractorImpl( + private val jwtParser: JwtParser, +) : JwtExtractor { + + override fun parse(jwtToken: String): UUID { + if (!jwtParser.isSigned(jwtToken)) { // Javadoc에 파싱 전 isSigned 메서드를 호출하는 것이 더 효율적이라고 하여 적용 + throw UnauthorizedException("잘못된 형식의 JWT 토큰 입니다.") + } + val claims = extractClaims(jwtToken) + val memberId = getMemberIdFrom(claims) + return UUID.fromString(memberId) + } + + private fun getMemberIdFrom(claims: Claims): String { + return (claims["memberId"] as? String) + ?: run { + log.error { "JWT 토큰 페이로드에 memberId가 없습니다. claims=$claims" } + throw InternalServerError("서버에서 JWT 토큰 파싱 중 문제가 발생했습니다.") + } + } + + private fun extractClaims(jwtToken: String): Claims { + return runCatching { jwtParser.parseSignedClaims(jwtToken).payload } + .onFailure { handleJwtException(it) } + .getOrThrow() + } + + private fun handleJwtException(it: Throwable) { + throw when (it) { + is ExpiredJwtException -> UnauthorizedException("만료된 JWT 토큰 입니다.") + is JwtException, is IllegalArgumentException -> UnauthorizedException("잘못된 형식의 JWT 토큰 입니다.") + else -> { + log.error(it) { "서버에서 JWT 토큰 파싱 중 문제가 발생했습니다." } + it + } + } + } +} + diff --git a/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImpl.kt b/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImpl.kt index 3da7bc2..b3a4246 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImpl.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImpl.kt @@ -1,14 +1,25 @@ package kr.galaxyhub.sc.auth.infra +import io.jsonwebtoken.Clock +import io.jsonwebtoken.Jwts +import java.util.Date +import javax.crypto.SecretKey import kr.galaxyhub.sc.auth.domain.JwtProvider import kr.galaxyhub.sc.member.domain.Member -import org.springframework.stereotype.Component -@Component -class JwtProviderImpl : JwtProvider { +class JwtProviderImpl( + private val secretKey: SecretKey, + private val clock: Clock, + private val expirationMilliseconds: Long, +) : JwtProvider { override fun provide(member: Member): String { - // TODO 새로운 이슈로 만들어 기능 추가할 것 - return "accessToken" + val now = clock.now() + return Jwts.builder() + .signWith(secretKey) + .claim("memberId", member.id) + .issuedAt(now) + .expiration(Date(now.time + expirationMilliseconds)) + .compact() } } diff --git a/src/main/kotlin/kr/galaxyhub/sc/common/exception/GalaxyhubException.kt b/src/main/kotlin/kr/galaxyhub/sc/common/exception/GalaxyhubException.kt index ce2a9d2..5041e6b 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/common/exception/GalaxyhubException.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/common/exception/GalaxyhubException.kt @@ -3,7 +3,7 @@ package kr.galaxyhub.sc.common.exception import kr.galaxyhub.sc.common.support.LogLevel import org.springframework.http.HttpStatusCode -open class GalaxyhubException( +sealed class GalaxyhubException( message: String, val httpStatus: HttpStatusCode, val logLevel: LogLevel, diff --git a/src/main/kotlin/kr/galaxyhub/sc/common/exception/UnauthorizedException.kt b/src/main/kotlin/kr/galaxyhub/sc/common/exception/UnauthorizedException.kt new file mode 100644 index 0000000..3d63936 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/common/exception/UnauthorizedException.kt @@ -0,0 +1,11 @@ +package kr.galaxyhub.sc.common.exception + +import kr.galaxyhub.sc.common.support.LogLevel +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +class UnauthorizedException( + message: String, + httpStatus: HttpStatusCode = HttpStatus.UNAUTHORIZED, + logLevel: LogLevel = LogLevel.INFO, +) : GalaxyhubException(message, httpStatus, logLevel) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 806582d..2130d9f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -26,3 +26,5 @@ galaxyhub: client_id: 123123 client_secret: 123123 redirect_uri: http://localhost:8080/api/v1/auth/oauth2/login?socialType=discord + jwt: + secret-key: galaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhub diff --git a/src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImplTest.kt b/src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImplTest.kt new file mode 100644 index 0000000..e3f349b --- /dev/null +++ b/src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImplTest.kt @@ -0,0 +1,106 @@ +package kr.galaxyhub.sc.auth.infra + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage +import java.time.Instant +import java.util.Date +import java.util.UUID +import kr.galaxyhub.sc.common.exception.InternalServerError +import kr.galaxyhub.sc.common.exception.UnauthorizedException + +class JwtExtractorImplTest : DescribeSpec({ + + val secretKey = Keys.hmacShaKeyFor("galaxyhub".repeat(10).encodeToByteArray()) + val otherKey = Keys.hmacShaKeyFor("seokjin8678".repeat(10).encodeToByteArray()) + val now = Date.from(Instant.parse("2023-12-20T17:09:00Z")) + + describe("parse") { + val jwtExtractor = JwtExtractorImpl( + Jwts.parser() + .verifyWith(secretKey) + .clock { now } + .build() + ) + + context("유효하지 않은 JWT 토큰이면") { + val token = "" + + it("UnauthorizedException 예외를 던진다.") { + val ex = shouldThrow { + jwtExtractor.parse(token) + } + ex shouldHaveMessage "잘못된 형식의 JWT 토큰 입니다." + } + } + + context("JWT 토큰이 만료되면") { + val token = Jwts.builder() + .signWith(secretKey) + .expiration(Date(now.time - 1_000)) + .compact() + + it("UnauthorizedException 예외를 던진다.") { + val ex = shouldThrow { + jwtExtractor.parse(token) + } + ex shouldHaveMessage "만료된 JWT 토큰 입니다." + } + } + + context("JWT 토큰이 서명 되지 않으면") { + val token = Jwts.builder() + .compact() + + it("UnauthorizedException 예외를 던진다.") { + val ex = shouldThrow { + jwtExtractor.parse(token) + } + ex shouldHaveMessage "잘못된 형식의 JWT 토큰 입니다." + } + } + + context("JWT 토큰이 다른 secretKey로 서명 되면") { + val token = Jwts.builder() + .signWith(otherKey) + .compact() + + it("UnauthorizedException 예외를 던진다.") { + val ex = shouldThrow { + jwtExtractor.parse(token) + } + ex shouldHaveMessage "잘못된 형식의 JWT 토큰 입니다." + } + } + + context("JWT 토큰에 memberId 페이로드가 없으면") { + val token = Jwts.builder() + .signWith(secretKey) + .expiration((Date(now.time + 1_000))) + .compact() + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { + jwtExtractor.parse(token) + } + ex shouldHaveMessage "서버에서 JWT 토큰 파싱 중 문제가 발생했습니다." + } + } + + context("JWT 토큰이 유효하면") { + val memberId = UUID.randomUUID() + val token = Jwts.builder() + .claim("memberId", memberId) + .signWith(secretKey) + .expiration((Date(now.time + 1_000))) + .compact() + + it("memberId가 반환된다.") { + jwtExtractor.parse(token) shouldBe memberId + } + } + } +}) diff --git a/src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImplTest.kt b/src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImplTest.kt new file mode 100644 index 0000000..6f78b25 --- /dev/null +++ b/src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImplTest.kt @@ -0,0 +1,47 @@ +package kr.galaxyhub.sc.auth.infra + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import java.time.Instant +import java.util.Date +import java.util.concurrent.TimeUnit +import kr.galaxyhub.sc.member.domain.Member +import kr.galaxyhub.sc.member.domain.SocialType + +class JwtProviderImplTest : DescribeSpec({ + + val secretKey = Keys.hmacShaKeyFor("galaxyhub".repeat(10).encodeToByteArray()) + val otherKey = Keys.hmacShaKeyFor("seokjin8678".repeat(10).encodeToByteArray()) + val now = Date.from(Instant.parse("2023-12-20T17:09:00Z")) + + describe("provide") { + val jwtProvider = JwtProviderImpl(secretKey, { now }, TimeUnit.MINUTES.toMillis(30)) + + context("secretKey로 JWT 토큰을 생성하면") { + val member = Member("1", SocialType.LOCAL, "seokjin8678", null, null) + + val token = jwtProvider.provide(member) + + it("토큰은 secretKey로 검증할 수 있다.") { + val jwtParser = Jwts.parser() + .verifyWith(secretKey) + .clock { now } + .build() + + shouldNotThrow { jwtParser.parseSignedClaims(token) } + } + + it("토큰은 다른 secretKey로 검증할 수 없다.") { + val otherParser = Jwts.parser() + .verifyWith(otherKey) + .clock { now } + .build() + + shouldThrow { otherParser.parseSignedClaims(token) } + } + } + } +}) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 2eaadd2..e04d483 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -24,3 +24,5 @@ galaxyhub: client_id: discord_id client_secret: discord_client_secret redirect_uri: http://localhost:8080/api/v1/auth/oauth2/code?provider=discord + jwt: + secret-key: galaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhub