Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: JWT 토큰 발급 기능 추가 (#39) #41

Merged
merged 3 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/main/kotlin/kr/galaxyhub/sc/auth/config/JwtConfig.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions src/main/kotlin/kr/galaxyhub/sc/auth/domain/JwtExtractor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kr.galaxyhub.sc.auth.domain

import java.util.UUID

fun interface JwtExtractor {

fun parse(jwtToken: String): UUID
}
53 changes: 53 additions & 0 deletions src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImpl.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}

21 changes: 16 additions & 5 deletions src/main/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImpl.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
106 changes: 106 additions & 0 deletions src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtExtractorImplTest.kt
Original file line number Diff line number Diff line change
@@ -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<UnauthorizedException> {
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<UnauthorizedException> {
jwtExtractor.parse(token)
}
ex shouldHaveMessage "만료된 JWT 토큰 입니다."
}
}

context("JWT 토큰이 서명 되지 않으면") {
val token = Jwts.builder()
.compact()

it("UnauthorizedException 예외를 던진다.") {
val ex = shouldThrow<UnauthorizedException> {
jwtExtractor.parse(token)
}
ex shouldHaveMessage "잘못된 형식의 JWT 토큰 입니다."
}
}

context("JWT 토큰이 다른 secretKey로 서명 되면") {
val token = Jwts.builder()
.signWith(otherKey)
.compact()

it("UnauthorizedException 예외를 던진다.") {
val ex = shouldThrow<UnauthorizedException> {
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<InternalServerError> {
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
}
}
}
})
47 changes: 47 additions & 0 deletions src/test/kotlin/kr/galaxyhub/sc/auth/infra/JwtProviderImplTest.kt
Original file line number Diff line number Diff line change
@@ -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<Exception> { jwtParser.parseSignedClaims(token) }
}

it("토큰은 다른 secretKey로 검증할 수 없다.") {
val otherParser = Jwts.parser()
.verifyWith(otherKey)
.clock { now }
.build()

shouldThrow<Exception> { otherParser.parseSignedClaims(token) }
}
}
}
})
2 changes: 2 additions & 0 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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