From bef9b9037de883215d2771aad7b00d4cb026f465 Mon Sep 17 00:00:00 2001 From: San Kim Date: Fri, 26 Jan 2024 16:03:23 +0900 Subject: [PATCH] =?UTF-8?q?[WEAV-64]=20=E2=9C=A8=20security=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/properties/JwtTokenProperties.kt | 23 +++ buildSrc/src/main/kotlin/Version.kt | 3 + settings.gradle.kts | 3 + support/security/build.gradle.kts | 4 + .../security/jwt/error/JwtExpiredException.kt | 3 + .../jwt/error/JwtVerificationException.kt | 3 + .../security/jwt/util/JwtTokenProvider.kt | 95 ++++++++++++ .../support/security/jwt/vo/JwtClaims.kt | 80 ++++++++++ .../support/security/jwt/vo/JwtClaimsDsl.kt | 96 ++++++++++++ .../security/jwt/util/JwtTokenProviderTest.kt | 146 ++++++++++++++++++ 10 files changed, 456 insertions(+) create mode 100644 application/src/main/kotlin/com/studentcenter/weave/application/common/properties/JwtTokenProperties.kt create mode 100644 support/security/build.gradle.kts create mode 100644 support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtExpiredException.kt create mode 100644 support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtVerificationException.kt create mode 100644 support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProvider.kt create mode 100644 support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaims.kt create mode 100644 support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaimsDsl.kt create mode 100644 support/security/src/test/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProviderTest.kt diff --git a/application/src/main/kotlin/com/studentcenter/weave/application/common/properties/JwtTokenProperties.kt b/application/src/main/kotlin/com/studentcenter/weave/application/common/properties/JwtTokenProperties.kt new file mode 100644 index 00000000..ccae9e93 --- /dev/null +++ b/application/src/main/kotlin/com/studentcenter/weave/application/common/properties/JwtTokenProperties.kt @@ -0,0 +1,23 @@ +package com.studentcenter.weave.application.common.properties + +import com.studentcenter.weave.domain.enum.SocialLoginProvider +import com.studentcenter.weave.support.common.vo.Url +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(value = "user.jwt") +data class JwtTokenProperties( + val access: TokenProperties, + val refresh: TokenProperties, + val socialLoginProvider: Map, +) { + + data class TokenProperties( + val expireSeconds: Long, + val secret: String + ) + + data class Provider( + val jwksUri: Url, + ) + +} diff --git a/buildSrc/src/main/kotlin/Version.kt b/buildSrc/src/main/kotlin/Version.kt index 57cab6d2..71aa06c2 100644 --- a/buildSrc/src/main/kotlin/Version.kt +++ b/buildSrc/src/main/kotlin/Version.kt @@ -20,4 +20,7 @@ object Version { const val MYSQL = "8.0.33" const val H2 = "2.2.224" + + const val AUTH0_JAVA_JWT = "4.4.0" + const val AUTH0_JWKS_RSA = "0.22.1" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 107c2281..0a5e2e43 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,9 @@ project(":support:logger").projectDir = file("support/logger") include(":support:mail") project(":support:mail").projectDir = file("support/mail") +include(":support:security") +project(":support:security").projectDir = file("support/security") + // domain include(":domain") project(":domain").projectDir = file("domain") diff --git a/support/security/build.gradle.kts b/support/security/build.gradle.kts new file mode 100644 index 00000000..df3a7ba1 --- /dev/null +++ b/support/security/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies { + implementation("com.auth0:java-jwt:${Version.AUTH0_JAVA_JWT}") + implementation("com.auth0:jwks-rsa:${Version.AUTH0_JWKS_RSA}") +} diff --git a/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtExpiredException.kt b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtExpiredException.kt new file mode 100644 index 00000000..28a2b80f --- /dev/null +++ b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtExpiredException.kt @@ -0,0 +1,3 @@ +package com.studentcenter.weave.support.security.jwt.error + +class JwtExpiredException(message: String? = null) : RuntimeException(message) diff --git a/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtVerificationException.kt b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtVerificationException.kt new file mode 100644 index 00000000..1c86df56 --- /dev/null +++ b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/error/JwtVerificationException.kt @@ -0,0 +1,3 @@ +package com.studentcenter.weave.support.security.jwt.error + +class JwtVerificationException(message: String? = null) : RuntimeException(message) diff --git a/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProvider.kt b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProvider.kt new file mode 100644 index 00000000..40a97a66 --- /dev/null +++ b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProvider.kt @@ -0,0 +1,95 @@ +package com.studentcenter.weave.support.security.jwt.util + +import com.auth0.jwk.Jwk +import com.auth0.jwk.JwkProviderBuilder +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.TokenExpiredException +import com.auth0.jwt.interfaces.DecodedJWT +import com.studentcenter.weave.support.security.jwt.error.JwtExpiredException +import com.studentcenter.weave.support.security.jwt.error.JwtVerificationException +import com.studentcenter.weave.support.security.jwt.vo.JwtClaims +import java.net.URL +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.util.concurrent.TimeUnit + +object JwtTokenProvider { + + fun createToken( + jwtClaims: JwtClaims, + secret: String, + ): String { + return JWT + .create() + .withJWTId(jwtClaims.jti) + .withSubject(jwtClaims.sub) + .withIssuer(jwtClaims.iss) + .withAudience(*jwtClaims.aud?.toTypedArray() ?: arrayOf()) + .withIssuedAt(jwtClaims.iat) + .withNotBefore(jwtClaims.nbf) + .withExpiresAt(jwtClaims.exp) + .withPayload(jwtClaims.customClaims) + .sign(Algorithm.HMAC256(secret)) + } + + fun verifyToken( + token: String, + secret: String, + ): Result { + val algorithm: Algorithm = Algorithm.HMAC256(secret) + return verifyToken(token, algorithm) + } + + fun verifyJwksBasedToken( + token: String, + jwksUri: URL, + ): Result { + val provider = JwkProviderBuilder(jwksUri) + .cached(10, 60 * 24, TimeUnit.HOURS) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build() + val jwt: DecodedJWT = JWT.decode(token) + val jwk: Jwk = provider[jwt.keyId] + val algorithm: Algorithm = extractAlgorithm(jwk) + return verifyToken(token, algorithm) + } + + private fun extractAlgorithm(jwk: Jwk): Algorithm { + return when (jwk.algorithm) { + "ES256" -> Algorithm.ECDSA256(jwk.publicKey as ECPublicKey, null) + "ES384" -> Algorithm.ECDSA384(jwk.publicKey as ECPublicKey, null) + "ES512" -> Algorithm.ECDSA512(jwk.publicKey as ECPublicKey, null) + "RS256" -> Algorithm.RSA256(jwk.publicKey as RSAPublicKey, null) + "RS384" -> Algorithm.RSA384(jwk.publicKey as RSAPublicKey, null) + "RS512" -> Algorithm.RSA512(jwk.publicKey as RSAPublicKey, null) + else -> throw IllegalArgumentException("지원되지 않는 알고리즘입니다.") + } + } + + private fun verifyToken( + token: String, + algorithm: Algorithm, + ): Result { + return runCatching { + JWT + .require(algorithm) + .build() + .verify(token) + }.mapCatching { claims -> + JwtClaims.from(claims) + }.onFailure { exception -> + handleException(exception) + } + } + + private fun handleException(ex: Throwable): Nothing { + exceptionHandlerList.first { it.first.isAssignableFrom(ex::class.java) }.second.invoke(ex) + } + + private val exceptionHandlerList = listOf, (Throwable) -> Nothing>>( + TokenExpiredException::class.java to { throw JwtExpiredException("토큰이 만료되었습니다.") }, + Throwable::class.java to { e -> throw JwtVerificationException("토큰 유효성 검사에 실패했습니다. message=${e.message}") } + ) + +} diff --git a/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaims.kt b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaims.kt new file mode 100644 index 00000000..b65e2cbe --- /dev/null +++ b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaims.kt @@ -0,0 +1,80 @@ +package com.studentcenter.weave.support.security.jwt.vo + +import com.auth0.jwt.interfaces.DecodedJWT +import java.time.Instant +import java.util.* + +/** + * Represents the claims of a JSON Web Token (JWT). + * + * @property registeredClaims The registered claims of the JWT. + * @property customClaims The custom claims of the JWT. + */ +data class JwtClaims( + val registeredClaims: RegisteredClaims, + val customClaims: MutableMap = mutableMapOf() +) { + + + /** + * Represents the registered claims defined in a JSON Web Token (JWT). + * + * @property sub The subject claim identifying the principal that is the subject of the JWT + * @property exp The expiration time claim that specifies the time on or after which the JWT must not be accepted for processing + * @property iat The issued at claim that identifies the time at which the JWT was issued + * @property nbf The not before claim that identifies the time before which the JWT must not be accepted for processing + * @property iss The issuer claim that identifies the entity that issued the JWT + * @property aud The audience claim that identifies the recipients for which the JWT is intended + * @property jti The JWT ID claim that provides a unique identifier for the JWT + */ + data class RegisteredClaims( + val sub: String? = null, + val exp: Date? = Date.from(Instant.now().plusSeconds(DEFAULT_EXPIRATION_TIME_SEC)), + val iat: Date? = Date.from(Instant.now()), + val nbf: Date? = Date.from(Instant.now()), + val iss: String? = null, + val aud: List? = null, + val jti: String? = null, + ) + + val sub: String? + get() = registeredClaims.sub + val exp: Date? + get() = registeredClaims.exp + val iat: Date? + get() = registeredClaims.iat + val nbf: Date? + get() = registeredClaims.nbf + val iss: String? + get() = registeredClaims.iss + val aud: List? + get() = registeredClaims.aud + val jti: String? + get() = registeredClaims.jti + + companion object { + + const val DEFAULT_EXPIRATION_TIME_SEC: Long = 60 * 60 // 1 hour + + fun from(claims: DecodedJWT): JwtClaims { + val registeredClaims = RegisteredClaims( + sub = claims.subject, + exp = claims.expiresAt, + iat = claims.issuedAt, + nbf = claims.notBefore, + iss = claims.issuer, + aud = claims.audience, + jti = claims.id, + ) + val customClaims: MutableMap = mutableMapOf() + claims.claims + .filter { it.key !in registeredClaims::class.members.map { member -> member.name } } + .forEach { (key, value) -> + customClaims[key] = value.`as`(Any::class.java) + } + return JwtClaims(registeredClaims, customClaims) + } + + } + +} diff --git a/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaimsDsl.kt b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaimsDsl.kt new file mode 100644 index 00000000..4207f278 --- /dev/null +++ b/support/security/src/main/kotlin/com/studentcenter/weave/support/security/jwt/vo/JwtClaimsDsl.kt @@ -0,0 +1,96 @@ +package com.studentcenter.weave.support.security.jwt.vo + +import java.time.Instant +import java.util.* + +/** + * Marks the annotated class as a DSL marker for creating JwtClaims objects. + */ +@DslMarker +annotation class JwtClaimsDsl + +/** + * Builder class for creating JwtClaims objects. + */ +@JwtClaimsDsl +class JwtClaimsBuilder { + private var registeredClaims = JwtClaims.RegisteredClaims() + private val customClaims = mutableMapOf() + + /** + * Sets the registered claims for a JSON Web Token (JWT) using the provided DSL. + * ``` + * sub: Subject + * exp: Expiration Time + * iat: Issued At + * nbf: Not Before + * iss: Issuer + * aud: Audience + * jti: JWT ID + * ``` + * + * @param init The DSL for setting registered claims. + * @see JwtClaims.registeredClaims + **/ + fun registeredClaims(init: RegisteredClaimsBuilder.() -> Unit) { + val builder = RegisteredClaimsBuilder().apply(init) + registeredClaims = builder.build() + } + + /** + * Sets the custom claims for a JSON Web Token (JWT) using the provided DSL. + * ``` + * this["customKey1"] = "customValue1" + * this["customKey2"] = "customValue2" + * ``` + * @param block The DSL for setting custom claims. + * @see JwtClaims.customClaims + */ + fun customClaims(block: MutableMap.() -> Unit) { + customClaims.apply(block) + } + + fun build(): JwtClaims = JwtClaims(registeredClaims, customClaims) +} + +/** + * A builder class that creates instances of `JwtClaims.RegisteredClaims`. + * The class provides methods to set the registered claims for a JSON Web Token (JWT). + */ +@JwtClaimsDsl +class RegisteredClaimsBuilder { + var sub: String? = null + var exp: Date? = Date.from(Instant.now().plusSeconds(JwtClaims.DEFAULT_EXPIRATION_TIME_SEC)) + var iat: Date? = Date.from(Instant.now()) + var nbf: Date? = Date.from(Instant.now()) + var iss: String? = null + var aud: List? = null + var jti: String? = null + + fun build(): JwtClaims.RegisteredClaims = + JwtClaims.RegisteredClaims(sub, exp, iat, nbf, iss, aud, jti) +} + +/** + * Creates a new JwtClaims object using the provided DSL. + * ``` + * val jwt = JwtClaims { + * registeredClaims { + * sub = "subject" + * // ... Set Registered Claim ... + * } + * customClaims { + * this["customKey1"] = "customValue1" + * // ... Set Custom Claim ... + * } + * } + * ``` + * @param init The DSL for creating a JwtClaims object. + * @return A new JwtClaims object. + * @see JwtClaimsBuilder + * @see JwtClaimsDsl + * @see RegisteredClaimsBuilder + * @example + * + */ +fun JwtClaims(init: JwtClaimsBuilder.() -> Unit): JwtClaims = JwtClaimsBuilder().apply(init).build() diff --git a/support/security/src/test/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProviderTest.kt b/support/security/src/test/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProviderTest.kt new file mode 100644 index 00000000..7f54ae40 --- /dev/null +++ b/support/security/src/test/kotlin/com/studentcenter/weave/support/security/jwt/util/JwtTokenProviderTest.kt @@ -0,0 +1,146 @@ +package com.studentcenter.weave.support.security.jwt.util + +import com.auth0.jwt.JWT +import com.studentcenter.weave.support.security.jwt.error.JwtExpiredException +import com.studentcenter.weave.support.security.jwt.error.JwtVerificationException +import com.studentcenter.weave.support.security.jwt.vo.JwtClaims +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import java.util.* + +class JwtTokenProviderTest : DescribeSpec({ + + describe("createToken(jwksClaims, secret)") { + + context("유효한 secret key가 전달되면") { + + val secret = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabc" + + it("해당 secret으로 서명된 토큰을 발급한다.") { + // arrange + val claims = JwtClaims { + registeredClaims { + sub = "sub" + aud = listOf("aud1", "aud2") + } + customClaims { + this["key"] = "value" + this["list"] = listOf("a", "b", "c") + } + } + + // act + val token: String = JwtTokenProvider.createToken( + jwtClaims = claims, + secret = secret, + ) + + // assert + val body = JWT.decode(token) + val jwtClaims = JwtClaims.from(body) + + jwtClaims.sub shouldBe "sub" + jwtClaims.aud shouldBe setOf("aud1", "aud2") + jwtClaims.customClaims["key"] shouldBe "value" + jwtClaims.customClaims["list"] shouldBe listOf("a", "b", "c") + } + } + + } + + describe("verifyToken(token, secret)") { + + context("유효한 secret키가 전달되면") { + + val secret = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabc" + + it("해당 secret으로 서명된 토큰을 복호화한다.") { + // arrange + val claims = JwtClaims { + registeredClaims { + sub = "sub" + aud = listOf("aud1", "aud2") + } + customClaims { + this["key"] = "value" + this["list"] = listOf("a", "b", "c") + } + } + val token: String = JwtTokenProvider.createToken( + jwtClaims = claims, + secret = secret, + ) + + // act + val result = runCatching { + JwtTokenProvider.verifyToken(token, secret) + }.exceptionOrNull() + + // assert + result shouldBe null + } + } + + context("토큰이 서명된 secret과 다른 secret이 주어지면") { + + val secret = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabc" + val anotherKey = "testtestetesttestetesttestetesttestetesttestetesttestetestteste" + + it("JwtVerificationException을 발생시킨다.") { + // arrange + val token: String = JwtTokenProvider.createToken( + jwtClaims = JwtClaims { + registeredClaims { + sub = "sub" + aud = listOf("aud1", "aud2") + } + customClaims { + this["key"] = "value" + } + }, + secret = secret, + ) + + // act + val result = runCatching { + JwtTokenProvider.verifyToken(token, anotherKey) + }.exceptionOrNull() + + // assert + result.shouldBeInstanceOf() + } + } + + context("만료된 토큰이 주어지면") { + + val secret = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabc" + + it("JwtExpiredException 예외를 발생시킨다") { + // arrange + val token: String = JwtTokenProvider.createToken( + jwtClaims = JwtClaims { + registeredClaims { + sub = "sub" + aud = listOf("aud1", "aud2") + exp = Date.from(Date().toInstant().minusSeconds(1)) + } + customClaims { + this["key"] = "value" + } + }, + secret = secret, + ) + + // act + val result = runCatching { + JwtTokenProvider.verifyToken(token, secret) + }.exceptionOrNull() + + // assert + result.shouldBeInstanceOf() + } + } + } + +})