Skip to content
Merged
7 changes: 7 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")

val jjwtVersion = "0.12.5"
implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion")
runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion")
implementation("com.nimbusds:nimbus-jose-jwt:9.37.2")
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.depromeet.makers

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@ConfigurationPropertiesScan
@SpringBootApplication
class DepromeetMakersBeApplication

fun main(args: Array<String>) {
runApplication<DepromeetMakersBeApplication>(*args)
runApplication<DepromeetMakersBeApplication>(*args)
}
127 changes: 127 additions & 0 deletions src/main/kotlin/com/depromeet/makers/components/JWTTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.depromeet.makers.components

import com.depromeet.makers.config.properties.AppProperties
import com.depromeet.makers.domain.exception.DomainException
import com.depromeet.makers.domain.exception.ErrorCode
import com.depromeet.makers.domain.vo.TokenPair
import com.depromeet.makers.util.logger
import io.jsonwebtoken.Jwts
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.stereotype.Component
import java.util.Date
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec

@Component
class JWTTokenProvider(
private val appProperties: AppProperties,
) {
private val logger = logger()
private final val signKey: SecretKey = SecretKeySpec(appProperties.token.secretKey.toByteArray(), "AES")
private val jwtParser = Jwts
.parser()
.decryptWith(signKey)
.build()

fun generateTokenPair(authentication: Authentication): TokenPair {
val accessToken = generateAccessToken(authentication)
val refreshToken = generateRefreshToken(authentication)
return TokenPair(accessToken, refreshToken)
}

private fun generateAccessToken(authentication: Authentication): String {
val authorities = authentication.authorities.joinToString(",") {
it.authority
}
return Jwts.builder()
.header()
.add(TOKEN_TYPE_HEADER_KEY, ACCESS_TOKEN_TYPE_VALUE)
.and()
.claims()
.add(USER_ID_CLAIM_KEY, authentication.name)
.add(AUTHORITIES_CLAIM_KEY, authorities)
.and()
.expiration(generateAccessTokenExpiration())
.encryptWith(signKey, Jwts.ENC.A128CBC_HS256)
.compact()
}

private fun generateRefreshToken(authentication: Authentication): String = Jwts.builder()
.header()
.add(TOKEN_TYPE_HEADER_KEY, REFRESH_TOKEN_TYPE_VALUE)
.and()
.claims()
.add(USER_ID_CLAIM_KEY, authentication.name)
.and()
.expiration(generateRefreshTokenExpiration())
.encryptWith(signKey, Jwts.ENC.A128CBC_HS256)
.compact()

fun parseAuthentication(accessToken: String): Authentication {
val claims = runCatching {
jwtParser.parseEncryptedClaims(accessToken)
}.getOrElse {
throw DomainException(ErrorCode.TOKEN_EXPIRED)
}
val tokenType = claims.header[TOKEN_TYPE_HEADER_KEY] ?: run {
logger.error("Token type not found in header - claims($claims)")
throw RuntimeException("Invalid token type!")
}
if (tokenType != ACCESS_TOKEN_TYPE_VALUE) {
logger.error("Token is not an access token - tokenType($tokenType)")
throw RuntimeException()
}

val userId = claims.payload[USER_ID_CLAIM_KEY] as? String? ?: run {
logger.error("Cannot parse userId from claims - claims($claims)")
throw RuntimeException()
}
val authoritiesStr = claims.payload[AUTHORITIES_CLAIM_KEY] as? String?
val authorities = if (authoritiesStr.isNullOrEmpty()) {
emptyList()
} else {
claims.payload[AUTHORITIES_CLAIM_KEY]?.toString()
?.split(",")
?.map { SimpleGrantedAuthority("ROLE_$it") }
?: emptyList()
}

return UsernamePasswordAuthenticationToken(
userId,
accessToken,
authorities,
)
}

fun getMemberIdFromRefreshToken(refreshToken: String): String {
val claims = jwtParser.parseEncryptedClaims(refreshToken)
val tokenType = claims.header[TOKEN_TYPE_HEADER_KEY] ?: run {
logger.error("Token type not found in header - claims($claims)")
throw RuntimeException()
}
if (tokenType != REFRESH_TOKEN_TYPE_VALUE) {
logger.error("Token is not an refresh token - tokenType($tokenType)")
throw RuntimeException()
}

return claims.payload[USER_ID_CLAIM_KEY] as? String ?: run {
logger.error("Cannot parse userId from claims - claims($claims)")
throw RuntimeException()
}
}

private fun generateAccessTokenExpiration() = Date(System.currentTimeMillis() + appProperties.token.expiration.access * 1000)

private fun generateRefreshTokenExpiration() = Date(System.currentTimeMillis() + appProperties.token.expiration.refresh * 1000)

companion object {
const val USER_ID_CLAIM_KEY = "user_id"
const val AUTHORITIES_CLAIM_KEY = "authorities"

const val TOKEN_TYPE_HEADER_KEY = "token_type"
const val ACCESS_TOKEN_TYPE_VALUE = "access_token"
const val REFRESH_TOKEN_TYPE_VALUE = "refresh_token"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.depromeet.makers.components

import com.depromeet.makers.domain.exception.DomainException
import com.depromeet.makers.domain.exception.ErrorCode
import com.depromeet.makers.presentation.web.dto.response.AppleKeyResponse
import com.depromeet.makers.presentation.web.dto.response.KakaoAccountResponse
import com.depromeet.makers.repository.client.AppleAuthClient
import com.depromeet.makers.repository.client.KakaoAuthClient
import com.depromeet.makers.util.logger
import com.fasterxml.jackson.databind.ObjectMapper
import com.nimbusds.jose.jwk.JWK
import io.jsonwebtoken.Jwts
import org.springframework.stereotype.Component
import java.util.Base64

@Component
class SocialLoginProvider(
private val objectMapper: ObjectMapper,
private val appleAuthClient: AppleAuthClient,
private val kakaoAuthClient: KakaoAuthClient,
) {
private val logger = logger()

fun authenticateFromKakao(accessToken: String): KakaoAccountResponse = try {
kakaoAuthClient.getKakaoAccount(mapOf("Authorization" to "Bearer $accessToken"))
} catch(e: Exception) {
logger.error("[SocialLoginProvider] failed kakao with error - ${e.message}")
throw DomainException(ErrorCode.INTERNAL_ERROR)
}


fun authenticateFromApple(identityToken: String): String {
val pubKeys = try {
appleAuthClient.getApplyKeys()
} catch(e: Exception) {
logger.error("[SocialLoginProvider] failed applekey with error - ${e.message}")
throw DomainException(ErrorCode.INTERNAL_ERROR)
}
return getUserIdFromAppleIdentity(pubKeys.keys.toTypedArray(), identityToken)
}


private fun getUserIdFromAppleIdentity(keys: Array<AppleKeyResponse>, identityToken: String): String {
val tokenParts = identityToken.split("\\.")
val headerPart = String(Base64.getDecoder().decode(tokenParts[0]))
val headerNode = objectMapper.readTree(headerPart)
val kid = headerNode.get("kid").asText()

val keyStr = keys.firstOrNull { it.kid == kid }
?: throw RuntimeException("Apple Key not found")

val pubKey = JWK.parse(objectMapper.writeValueAsString(keyStr))
.toRSAKey()
.toRSAPublicKey()

return Jwts.parser()
.verifyWith(pubKey)
.build()
.parseSignedClaims(identityToken)
.payload
.get("sub", String::class.java)
}
}
36 changes: 36 additions & 0 deletions src/main/kotlin/com/depromeet/makers/config/SwaggerConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.depromeet.makers.config

import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import io.swagger.v3.oas.models.servers.Server
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class SwaggerConfig {
@Bean
fun openAPI(): OpenAPI = OpenAPI()
.servers(
listOf(
Server()
.description("API 서버 URL")
.url("http://localhost:8080"),
),
)
.info(
Info()
.title("Depromeet Makers BE API")
.version("1.0"),
)
.addSecurityItem(SecurityRequirement().addList("JWT 토큰"))
.components(
Components().addSecuritySchemes(
"JWT 토큰",
SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("Bearer").bearerFormat("JWT"),
),
)
}

30 changes: 29 additions & 1 deletion src/main/kotlin/com/depromeet/makers/config/WebConfig.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
package com.depromeet.makers.config

import com.depromeet.makers.repository.client.AppleAuthClient
import com.depromeet.makers.repository.client.KakaoAuthClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
import org.springframework.web.client.support.RestClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig: WebMvcConfigurer {
class WebConfig : WebMvcConfigurer {
@Bean
fun restClient(): RestClient {
return RestClient.create()
}

@Bean
fun appleAuthClient(restClient: RestClient): AppleAuthClient {
val restClientAdapter = RestClientAdapter.create(restClient)
return HttpServiceProxyFactory
.builderFor(restClientAdapter)
.build()
.createClient(AppleAuthClient::class.java)
}

@Bean
fun kakaoAuthClient(restClient: RestClient): KakaoAuthClient {
val restClientAdapter = RestClientAdapter.create(restClient)
return HttpServiceProxyFactory
.builderFor(restClientAdapter)
.build()
.createClient(KakaoAuthClient::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.makers.config

import com.depromeet.makers.domain.exception.DomainException
import com.depromeet.makers.domain.exception.ErrorCode
import com.depromeet.makers.presentation.web.dto.response.ErrorResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
Expand All @@ -13,7 +14,7 @@ class WebExceptionHandler {
fun handleDomainException(e: DomainException): ResponseEntity<ErrorResponse> {
val errorCode = e.errorCode
if (errorCode.isCriticalError()) {
// return handleUnhandledException(e)
return handleUnhandledException(e)
}

return ResponseEntity.status(HttpStatus.BAD_REQUEST)
Expand All @@ -22,8 +23,9 @@ class WebExceptionHandler {

@ExceptionHandler
fun handleUnhandledException(e: Exception): ResponseEntity<ErrorResponse> {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse(0, ""))
e.printStackTrace()
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse(ErrorCode.INTERNAL_ERROR.code, ErrorCode.INTERNAL_ERROR.message))
}

}
Loading