diff --git a/app/src/main/kotlin/com/wespot/ApiApplication.kt b/app/src/main/kotlin/com/wespot/ApiApplication.kt index a6f41736..29dad63e 100644 --- a/app/src/main/kotlin/com/wespot/ApiApplication.kt +++ b/app/src/main/kotlin/com/wespot/ApiApplication.kt @@ -5,8 +5,6 @@ import org.springframework.boot.runApplication import org.springframework.cloud.openfeign.EnableFeignClients import org.springframework.data.jpa.repository.config.EnableJpaAuditing -@EnableJpaAuditing -@EnableFeignClients @SpringBootApplication(scanBasePackages = ["com.wespot"]) class ApiApplication diff --git a/app/src/main/kotlin/com/wespot/config/JpaAuditingConfig.kt b/app/src/main/kotlin/com/wespot/config/JpaAuditingConfig.kt new file mode 100644 index 00000000..927bf849 --- /dev/null +++ b/app/src/main/kotlin/com/wespot/config/JpaAuditingConfig.kt @@ -0,0 +1,9 @@ +package com.wespot.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaAuditingConfig { +} diff --git a/app/src/main/kotlin/com/wespot/config/OpenFeignConfig.kt b/app/src/main/kotlin/com/wespot/config/OpenFeignConfig.kt new file mode 100644 index 00000000..220bf59a --- /dev/null +++ b/app/src/main/kotlin/com/wespot/config/OpenFeignConfig.kt @@ -0,0 +1,32 @@ +package com.wespot.config + +import com.wespot.error.CustomErrorDecoder +import feign.codec.ErrorDecoder +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.cloud.openfeign.EnableFeignClients +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.web.client.RestTemplate + + +@Configuration +@EnableFeignClients(basePackages = ["com.wespot"]) +class OpenFeignConfig { + + @Bean + fun errorDecoder(): ErrorDecoder { + return CustomErrorDecoder() + } + + @Bean + fun feignMessageConverter(): MappingJackson2HttpMessageConverter { + return MappingJackson2HttpMessageConverter() + } + + @Bean + fun restTemplate(messageConverters: List>): RestTemplate { + return RestTemplateBuilder().additionalMessageConverters(messageConverters).build() + } +} \ No newline at end of file diff --git a/app/src/main/resources/backend-submodule b/app/src/main/resources/backend-submodule index 7026cbec..0df1be66 160000 --- a/app/src/main/resources/backend-submodule +++ b/app/src/main/resources/backend-submodule @@ -1 +1 @@ -Subproject commit 7026cbec46e0ddbbd623b101eda33721c53744da +Subproject commit 0df1be660e1552143e8e5243c78736203b110bc0 diff --git a/build.gradle.kts b/build.gradle.kts index 10b8d15e..f5655069 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,9 +31,6 @@ allprojects { } } -repositories { - mavenCentral() -} subprojects { apply(plugin = "java") @@ -41,6 +38,8 @@ subprojects { apply(plugin = "kotlin") apply(plugin = "kotlin-spring") apply(plugin = "kotlin-kapt") + apply(plugin = "kotlin-noarg") + apply(plugin = "kotlin-jpa") apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") @@ -55,6 +54,9 @@ subprojects { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("mysql:mysql-connector-java:8.0.32") + // https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk15on + implementation("org.bouncycastle:bcpkix-jdk15on:1.69") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/common/src/main/kotlin/com/wespot/error/CustomErrorDecoder.kt b/common/src/main/kotlin/com/wespot/error/CustomErrorDecoder.kt new file mode 100644 index 00000000..5125832c --- /dev/null +++ b/common/src/main/kotlin/com/wespot/error/CustomErrorDecoder.kt @@ -0,0 +1,17 @@ +package com.wespot.error + +import feign.Response +import feign.codec.ErrorDecoder +import java.util.NoSuchElementException + +class CustomErrorDecoder : ErrorDecoder { + override fun decode(methodKey: String, response: Response): Exception { + return when (response.status()) { + 400 -> IllegalArgumentException("OAuth 요청이 잘못되었습니다 (Bad request)") + 401 -> IllegalArgumentException("OAuth 인증에 실패하였습니다 (Authentication failed)") + 404 -> NoSuchElementException("OAuth 리소스를 찾을 수 없습니다 (Resource not found)") + 500 -> RuntimeException("OAuth 내부 서버 오류입니다 (Internal server error)") + else -> RuntimeException("OAuth 연결 중 알 수 없는 오류가 발생했습니다)") + } + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index dbc2fd70..cd6871c5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -2,9 +2,20 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar dependencies { + // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api + implementation("io.jsonwebtoken:jjwt-api:0.11.2") + implementation("io.jsonwebtoken:jjwt-impl:0.11.2") + implementation("io.jsonwebtoken:jjwt-jackson:0.11.2") + + // Test dependencies + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.kotest:kotest-runner-junit5:5.8.0") + testImplementation("io.kotest:kotest-assertions-core:5.8.0") + testImplementation("io.kotest:kotest-property:5.8.0") + testImplementation("io.mockk:mockk:1.13.7") + implementation(project(":common")) implementation(project(":domain")) - implementation(project(":infrastructure:mysql")) } tasks.named("jar") { diff --git a/core/src/main/kotlin/com/wespot/auth/dto/AuthLoginRequest.kt b/core/src/main/kotlin/com/wespot/auth/dto/AuthLoginRequest.kt new file mode 100644 index 00000000..c011610c --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/AuthLoginRequest.kt @@ -0,0 +1,10 @@ +package com.wespot.auth.dto + +import com.wespot.user.SocialType + +data class AuthLoginRequest( + val socialType: SocialType, + val authorizationCode: String?, + val identityToken: String?, + val fcmToken: String? +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/OAuthIdAndRefreshToken.kt b/core/src/main/kotlin/com/wespot/auth/dto/OAuthIdAndRefreshToken.kt new file mode 100644 index 00000000..1f4fc25a --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/OAuthIdAndRefreshToken.kt @@ -0,0 +1,6 @@ +package com.wespot.auth.dto + +data class OAuthIdAndRefreshToken( + val oAuthId: String, + val refreshToken: String +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/apple/ApplePublicKey.kt b/core/src/main/kotlin/com/wespot/auth/dto/apple/ApplePublicKey.kt new file mode 100644 index 00000000..9c4bb81c --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/apple/ApplePublicKey.kt @@ -0,0 +1,10 @@ +package com.wespot.auth.dto.apple + +data class ApplePublicKey( + val kty: String, + val kid: String, + val use: String, + val alg: String, + val n: String, + val e: String +) \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/dto/apple/ApplePublicKeysResult.kt b/core/src/main/kotlin/com/wespot/auth/dto/apple/ApplePublicKeysResult.kt new file mode 100644 index 00000000..5ead7dce --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/apple/ApplePublicKeysResult.kt @@ -0,0 +1,15 @@ +package com.wespot.auth.dto.apple + +/** + * https://developer.apple.com/documentation/sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature 참고 + */ +data class ApplePublicKeysResult( + // 공개키 목록 + val keys: List +) { + fun getMatchesKey(alg: String?, kid: String?): ApplePublicKey { + return keys + .firstOrNull { key -> key.alg == alg && key.kid == kid } + ?: throw IllegalArgumentException("Apple JWT 값의 alg, kid 정보가 올바르지 않습니다.") + } +} diff --git a/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleRevokeRequest.kt b/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleRevokeRequest.kt new file mode 100644 index 00000000..18ebbff2 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleRevokeRequest.kt @@ -0,0 +1,16 @@ +package com.wespot.auth.dto.apple + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens + */ +data class AppleRevokeRequest( + @JsonProperty("client_id") + val clientId: String, + @JsonProperty("client_secret") + val clientSecret: String, + val token: String, + @JsonProperty("token_type_hint") + val tokenTypeHint: String +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleTokenRequest.kt b/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleTokenRequest.kt new file mode 100644 index 00000000..eb69911c --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleTokenRequest.kt @@ -0,0 +1,18 @@ +package com.wespot.auth.dto.apple + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens 참고 + */ +data class AppleTokenRequest( + @JsonProperty("client_id") + val clientId: String, + @JsonProperty("client_secret") + val clientSecret: String, + val code: String?, + @JsonProperty("grant_type") + val grantType: String, + @JsonProperty("redirect_uri") + val redirectUri: String? +) \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleTokenResult.kt b/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleTokenResult.kt new file mode 100644 index 00000000..375fb0a0 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/apple/AppleTokenResult.kt @@ -0,0 +1,20 @@ +package com.wespot.auth.dto.apple + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens 참고 + */ +data class AppleTokenResult( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("token_type") + val tokenType: String, + @JsonProperty("expires_in") + val expiresIn: Int, + @JsonProperty("refresh_token") + val refreshToken: String?, + @JsonProperty("id_token") + val idToken: String, + val error: String?, +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoRevokeResult.kt b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoRevokeResult.kt new file mode 100644 index 00000000..8fbf008d --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoRevokeResult.kt @@ -0,0 +1,5 @@ +package com.wespot.auth.dto.kakao + +data class KakaoRevokeResult( + val id : Long, +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoTokenRequest.kt b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoTokenRequest.kt new file mode 100644 index 00000000..91658f80 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoTokenRequest.kt @@ -0,0 +1,16 @@ +package com.wespot.auth.dto.kakao + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token-request-body 참고 + */ +data class KakaoTokenRequest( + @JsonProperty("grant_type") + val grantType: String, + @JsonProperty("client_id") + val clientId: String, + @JsonProperty("redirect_uri") + val redirectUri: String, + val code: String, +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoTokenResult.kt b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoTokenResult.kt new file mode 100644 index 00000000..45afe2c3 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoTokenResult.kt @@ -0,0 +1,17 @@ +package com.wespot.auth.dto.kakao + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token-response 참고 + */ +data class KakaoTokenResult( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("token_type") + val tokenType: String, + @JsonProperty("refresh_token") + val refreshToken: String?, + @JsonProperty("refresh_token_expires_in") + val refreshTokenExpiresIn: String?, +) diff --git a/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoUserInfoResult.kt b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoUserInfoResult.kt new file mode 100644 index 00000000..ffbbdacd --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/dto/kakao/KakaoUserInfoResult.kt @@ -0,0 +1,9 @@ +package com.wespot.auth.dto.kakao + +/** + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info-response 참고 + */ +data class KakaoUserInfoResult( + val id: Long, + val connected_at: String, +) diff --git a/core/src/main/kotlin/com/wespot/auth/service/SocialAuthService.kt b/core/src/main/kotlin/com/wespot/auth/service/SocialAuthService.kt new file mode 100644 index 00000000..8e2cc6ad --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/SocialAuthService.kt @@ -0,0 +1,11 @@ +package com.wespot.auth.service + +import com.wespot.auth.dto.AuthLoginRequest +import com.wespot.auth.dto.OAuthIdAndRefreshToken +import com.wespot.user.SocialType + +interface SocialAuthService { + fun fetchAuthToken(authLoginRequest: AuthLoginRequest): OAuthIdAndRefreshToken + fun isSupport(socialType: SocialType): Boolean + fun revoke(socialId: String, socialRefreshToken: String?): Boolean +} diff --git a/core/src/main/kotlin/com/wespot/auth/service/SocialAuthServiceFactory.kt b/core/src/main/kotlin/com/wespot/auth/service/SocialAuthServiceFactory.kt new file mode 100644 index 00000000..40547c07 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/SocialAuthServiceFactory.kt @@ -0,0 +1,15 @@ +package com.wespot.auth.service + +import com.wespot.user.SocialType +import org.springframework.stereotype.Component +import org.springframework.stereotype.Service + +@Service +class SocialAuthServiceFactory( + private val services: List +) { + fun getService(socialType: SocialType): SocialAuthService { + return services.find { it.isSupport(socialType) } + ?: throw IllegalArgumentException("해당 소셜로그인을 지원하지 않습니다. social type: $socialType") + } +} diff --git a/core/src/main/kotlin/com/wespot/auth/service/SocialHeaderConfiguration.kt b/core/src/main/kotlin/com/wespot/auth/service/SocialHeaderConfiguration.kt new file mode 100644 index 00000000..b2c97f71 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/SocialHeaderConfiguration.kt @@ -0,0 +1,28 @@ +package com.wespot.auth.service + +import feign.Logger +import feign.RequestInterceptor +import feign.RequestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.http.MediaType + +class SocialHeaderConfiguration { + companion object { + private const val CONTENT_TYPE_HEADER = "Content-Type" + } + + @Bean + fun requestInterceptor(): RequestInterceptor { + return RequestInterceptor { template: RequestTemplate -> + template.header( + CONTENT_TYPE_HEADER, + MediaType.APPLICATION_FORM_URLENCODED_VALUE + ) + } + } + + @Bean + fun feignLoggerLevel(): Logger.Level { + return Logger.Level.FULL + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/service/apple/AppleClient.kt b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleClient.kt new file mode 100644 index 00000000..db838b97 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleClient.kt @@ -0,0 +1,50 @@ +package com.wespot.auth.service.apple + +import com.wespot.auth.dto.apple.ApplePublicKeysResult +import com.wespot.auth.dto.apple.AppleRevokeRequest +import com.wespot.auth.dto.apple.AppleTokenResult +import com.wespot.auth.service.SocialHeaderConfiguration +import feign.Response +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam + +@FeignClient( + name = "apple", + url = "https://appleid.apple.com/auth", + configuration = [SocialHeaderConfiguration::class] +) +interface AppleClient { + + /** + * Apple 공개키 가져오기 + * https://appleid.apple.com/auth/keys + */ + @GetMapping("/keys") + fun getApplePublicKeys(): ApplePublicKeysResult + + /** + * Apple get Token + * https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens + */ + @PostMapping("/token", produces = [MediaType.APPLICATION_FORM_URLENCODED_VALUE]) + fun getToken( + @RequestParam("client_id") clientId: String, + @RequestParam("client_secret") clientSecret: String, + @RequestParam("code") code: String, + @RequestParam("grant_type") grantType: String, + @RequestParam("redirect_uri") redirectUri: String, + ): AppleTokenResult + + /** + * Apple revoke Token + * https://developer.apple.com/documentation/sign_in_with_apple/revoking_tokens + */ + @PostMapping("/revoke", produces = [MediaType.APPLICATION_FORM_URLENCODED_VALUE]) + fun revoke( + @RequestBody appleRevokeRequest: AppleRevokeRequest + ): Response +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/service/apple/AppleCreateClientSecret.kt b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleCreateClientSecret.kt new file mode 100644 index 00000000..24ddede0 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleCreateClientSecret.kt @@ -0,0 +1,57 @@ +package com.wespot.auth.service.apple + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.io.StringReader +import java.security.PrivateKey +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@Component +class AppleCreateClientSecret( + @Value("\${apple.appleTeamId}") + private val appleTeamId: String, + + @Value("\${apple.appleAud}") + private val appleAud: String, + + @Value("\${apple.appleKeyId}") + private val appleKeyId: String, + + @Value("\${apple.appleKey}") + private val appleKey: String +) { + fun createClientSecret(): String { + val expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant()) + val jwtHeader: Map = mapOf("kid" to appleKeyId, "alg" to "ES256") + + return Jwts.builder() + .setHeader(jwtHeader) + .setIssuer(appleTeamId) + .setIssuedAt(Date(System.currentTimeMillis())) // 발행 시간 + .setExpiration(expirationDate) // 만료 시간 + .setAudience("https://appleid.apple.com") + .setSubject(appleAud) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact() + } + + private fun getPrivateKey(): PrivateKey { + return try { + val privateKeyContent = appleKey + val pemReader = StringReader(privateKeyContent) + val pemParser = PEMParser(pemReader) + val converter = JcaPEMKeyConverter() + val objects = pemParser.readObject() as PrivateKeyInfo + converter.getPrivateKey(objects) + } catch (e: Exception) { + throw IllegalArgumentException ("Apple private key 생성 실패: ${e.message}") + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/service/apple/AppleJwtParser.kt b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleJwtParser.kt new file mode 100644 index 00000000..73524155 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleJwtParser.kt @@ -0,0 +1,24 @@ +package com.wespot.auth.service.apple + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Component +import java.util.* + +@Component +class AppleJwtParser(private val objectMapper: ObjectMapper) { + + companion object { + private const val HEADER_INDEX = 0 + } + + fun parseHeaders(identityToken: String): Map { + return try { + val encodedHeader = identityToken.split(".")[HEADER_INDEX] + val decodedHeader = String(Base64.getUrlDecoder().decode(encodedHeader)) + objectMapper.readValue(decodedHeader, object : TypeReference>() {}) + } catch (e: Exception) { + throw IllegalArgumentException("Apple OAuth Identity Token 형식이 올바르지 않습니다.") + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/service/apple/ApplePublicKeyGenerator.kt b/core/src/main/kotlin/com/wespot/auth/service/apple/ApplePublicKeyGenerator.kt new file mode 100644 index 00000000..fb1dbbd7 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/apple/ApplePublicKeyGenerator.kt @@ -0,0 +1,41 @@ +package com.wespot.auth.service.apple + +import com.wespot.auth.dto.apple.ApplePublicKeysResult +import org.springframework.stereotype.Component +import java.math.BigInteger +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.RSAPublicKeySpec +import java.util.* + + +@Component +class ApplePublicKeyGenerator { + companion object { + private const val SIGN_ALGORITHM_HEADER_KEY = "alg" + private const val KEY_ID_HEADER_KEY = "kid" + private const val POSITIVE_SIGN_NUMBER = 1 + } + + fun generatePublicKey(headers: Map, applePublicKeys: ApplePublicKeysResult): PublicKey { + val applePublicKey = applePublicKeys.getMatchesKey( + headers[SIGN_ALGORITHM_HEADER_KEY], + headers[KEY_ID_HEADER_KEY] + ) + + val nBytes = Base64.getUrlDecoder().decode(applePublicKey.n) + val eBytes = Base64.getUrlDecoder().decode(applePublicKey.e) + + val n = BigInteger(POSITIVE_SIGN_NUMBER, nBytes) + val e = BigInteger(POSITIVE_SIGN_NUMBER, eBytes) + + val publicKeySpec = RSAPublicKeySpec(n, e) + + return try { + val keyFactory = KeyFactory.getInstance("RSA") + keyFactory.generatePublic(publicKeySpec) + } catch (exception: Exception) { + throw IllegalArgumentException("Apple OAuth 로그인 중 public key 생성에 문제가 발생했습니다.", exception) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/service/apple/AppleService.kt b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleService.kt new file mode 100644 index 00000000..dabff5d1 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/apple/AppleService.kt @@ -0,0 +1,84 @@ +package com.wespot.auth.service.apple + +import com.wespot.auth.dto.AuthLoginRequest +import com.wespot.auth.dto.OAuthIdAndRefreshToken +import com.wespot.auth.dto.apple.AppleRevokeRequest +import com.wespot.auth.dto.apple.AppleTokenResult +import com.wespot.auth.service.SocialAuthService +import com.wespot.user.SocialType +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class AppleService( + private val appleClient: AppleClient, + private val applePublicKeyGenerator: ApplePublicKeyGenerator, + private val appleJwtParser: AppleJwtParser, + private val appleCreateClientSecret: AppleCreateClientSecret, + @Value("\${apple.appleAud}") private val appleAud: String, + @Value("\${apple.appleRedirectUri}") private val appleRedirectUri: String, +) : SocialAuthService { + + companion object { + private const val NOT_SUPPORTED = "not supported" + private const val GRANT_TYPE = "authorization_code" + private const val TOKEN_TYPE_HINT = "refresh_token" + } + + override fun fetchAuthToken(authLoginRequest: AuthLoginRequest): OAuthIdAndRefreshToken { + val appleId = getAppleId(authLoginRequest.identityToken + ?: throw IllegalArgumentException("Apple ID token이 없습니다.")) + val appleTokenResult = generateAuthToken(authLoginRequest.authorizationCode + ?: throw IllegalArgumentException("Authorization code가 없습니다.")) + + return OAuthIdAndRefreshToken( + oAuthId = appleId, + refreshToken = appleTokenResult.refreshToken ?: NOT_SUPPORTED + ) + } + + override fun isSupport(socialType: SocialType): Boolean { + return socialType == SocialType.APPLE + } + + private fun getAppleId(identityToken: String): String { + val headers = appleJwtParser.parseHeaders(identityToken) + val applePublicKeys = appleClient.getApplePublicKeys() + val publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeys) + val claims: Claims = Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(identityToken) + .body + + return claims.subject + } + + private fun generateAuthToken(authorizationCode: String): AppleTokenResult { + val clientSecret = appleCreateClientSecret.createClientSecret() + + return appleClient.getToken( + clientId = appleAud, + clientSecret = clientSecret, + code = authorizationCode, + grantType = GRANT_TYPE, + redirectUri = appleRedirectUri + ) + } + + override fun revoke(socialId: String, socialRefreshToken: String?): Boolean { + val response = appleClient.revoke( + AppleRevokeRequest( + clientId = appleAud, + clientSecret = appleCreateClientSecret.createClientSecret(), + token = socialRefreshToken ?: throw IllegalArgumentException("Refresh token is null"), + tokenTypeHint = TOKEN_TYPE_HINT + ) + ) + + require(response.status() == 200) { "Failed to revoke the token. Status: ${response.status()}" } + return true + } +} diff --git a/core/src/main/kotlin/com/wespot/auth/service/kakao/KakaoClient.kt b/core/src/main/kotlin/com/wespot/auth/service/kakao/KakaoClient.kt new file mode 100644 index 00000000..4a44c3d3 --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/kakao/KakaoClient.kt @@ -0,0 +1,40 @@ +package com.wespot.auth.service.kakao + +import com.wespot.auth.dto.kakao.KakaoRevokeResult +import com.wespot.auth.dto.kakao.KakaoUserInfoResult +import com.wespot.auth.service.SocialHeaderConfiguration +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestParam + +@FeignClient( + name = "kakao", + url = "https://kapi.kakao.com", + configuration = [SocialHeaderConfiguration::class] +) +interface KakaoClient { + + /** + * 사용자 정보 가져오기 + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + */ + @GetMapping("/v2/user/me", produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getUserInfo( + @RequestHeader("Authorization") authorization: String + ): KakaoUserInfoResult + + + /** + * 연결 끊기 + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-unlink + */ + @PostMapping("/v1/user/unlink", produces = [MediaType.APPLICATION_JSON_VALUE]) + fun unlink( + @RequestHeader("Authorization") adminKey: String, + @RequestParam("target_id_type") targetIdType: String, + @RequestParam("target_id") targetId: String + ): KakaoRevokeResult +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wespot/auth/service/kakao/KakaoService.kt b/core/src/main/kotlin/com/wespot/auth/service/kakao/KakaoService.kt new file mode 100644 index 00000000..ee84d89b --- /dev/null +++ b/core/src/main/kotlin/com/wespot/auth/service/kakao/KakaoService.kt @@ -0,0 +1,48 @@ +package com.wespot.auth.service.kakao + +import com.wespot.auth.dto.AuthLoginRequest +import com.wespot.auth.dto.OAuthIdAndRefreshToken +import com.wespot.auth.service.SocialAuthService +import com.wespot.user.SocialType +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class KakaoService( + private val kakaoClient: KakaoClient, + @Value("\${kakao.adminKey}") private val adminKey: String +) : SocialAuthService { + + companion object { + private const val NOT_SUPPORTED = "not supported" + private const val KAKAO_PREFIX = "KakaoAK " + } + + override fun fetchAuthToken(authLoginRequest: AuthLoginRequest): OAuthIdAndRefreshToken { + val kakaoId = getKakaoId(authLoginRequest.identityToken + ?: throw IllegalArgumentException("Kakao ID가 입력되지 않았습니다.")) + + return OAuthIdAndRefreshToken( + oAuthId = kakaoId, refreshToken = NOT_SUPPORTED + ) + } + + override fun isSupport(socialType: SocialType): Boolean { + return socialType == SocialType.KAKAO + } + + override fun revoke(socialId: String, socialRefreshToken: String?): Boolean { + kakaoClient.unlink( + adminKey = "$KAKAO_PREFIX$adminKey", + targetIdType = "user_id", + targetId = socialId + ) + return true + } + + private fun getKakaoId(accessToken: String): String { + val kakaoUserInfo = kakaoClient.getUserInfo("Bearer $accessToken") + require(kakaoUserInfo.id > 0) { "Kakao 로그인에 실패하였습니다. 사용자 정보를 가져오는 데 문제가 발생하였습니다." } + return kakaoUserInfo.id.toString() + } +} diff --git a/core/src/test/kotlin/com/wespot/auth/service/SocialAuthServiceFactoryTest.kt b/core/src/test/kotlin/com/wespot/auth/service/SocialAuthServiceFactoryTest.kt new file mode 100644 index 00000000..e4870970 --- /dev/null +++ b/core/src/test/kotlin/com/wespot/auth/service/SocialAuthServiceFactoryTest.kt @@ -0,0 +1,38 @@ +package com.wespot.auth.service + +import com.wespot.auth.service.kakao.KakaoService +import com.wespot.auth.service.apple.AppleService +import com.wespot.user.SocialType +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.kotest.assertions.throwables.shouldThrow +import io.mockk.every +import io.mockk.mockk + +class SocialAuthServiceFactoryTest : BehaviorSpec({ + val kakaoService = mockk() + val appleService = mockk() + val factory = SocialAuthServiceFactory(listOf(kakaoService, appleService)) + + given("SocialAuthServiceFactory 테스트") { + every { kakaoService.isSupport(SocialType.KAKAO) } returns true + every { kakaoService.isSupport(SocialType.APPLE) } returns false + + every { appleService.isSupport(SocialType.KAKAO) } returns false + every { appleService.isSupport(SocialType.APPLE) } returns true + + `when`("Kakao 소셜 타입에 대한 서비스를 요청할 때") { + then("KakaoService를 반환해야 한다") { + val service = factory.getService(SocialType.KAKAO) + service shouldBe kakaoService + } + } + + `when`("Apple 소셜 타입에 대한 서비스를 요청할 때") { + then("AppleService를 반환해야 한다") { + val service = factory.getService(SocialType.APPLE) + service shouldBe appleService + } + } + } +}) diff --git a/domain/src/main/kotlin/com/wespot/user/Social.kt b/domain/src/main/kotlin/com/wespot/user/Social.kt index aa232c76..78994684 100644 --- a/domain/src/main/kotlin/com/wespot/user/Social.kt +++ b/domain/src/main/kotlin/com/wespot/user/Social.kt @@ -3,5 +3,6 @@ package com.wespot.user data class Social( val socialType: SocialType, val socialId: Long, + val socialRefreshToken: String ) { } \ No newline at end of file diff --git a/domain/src/main/kotlin/com/wespot/user/SocialType.kt b/domain/src/main/kotlin/com/wespot/user/SocialType.kt index 88019126..07e7b574 100644 --- a/domain/src/main/kotlin/com/wespot/user/SocialType.kt +++ b/domain/src/main/kotlin/com/wespot/user/SocialType.kt @@ -1,5 +1,6 @@ package com.wespot.user enum class SocialType { - APPLE, KAKAO + APPLE, + KAKAO } \ No newline at end of file diff --git a/domain/src/main/kotlin/com/wespot/user/User.kt b/domain/src/main/kotlin/com/wespot/user/User.kt index d086518d..e486325f 100644 --- a/domain/src/main/kotlin/com/wespot/user/User.kt +++ b/domain/src/main/kotlin/com/wespot/user/User.kt @@ -7,7 +7,7 @@ data class User( val id: Long, val school: School, val grade: Int, - val group: Int, + val groupNumber: Int, val setting: Setting, val profile: Profile, val fcm: FCM, diff --git a/domain/src/main/kotlin/com/wespot/vote/Vote.kt b/domain/src/main/kotlin/com/wespot/vote/Vote.kt index 4ed7c7aa..314ad665 100644 --- a/domain/src/main/kotlin/com/wespot/vote/Vote.kt +++ b/domain/src/main/kotlin/com/wespot/vote/Vote.kt @@ -6,7 +6,7 @@ data class Vote( val id: Long, val schoolName: String, val grade: Int, - val group: Int, + val groupNumber: Int, val date: LocalDateTime, val ballots: List ) { diff --git a/infrastructure/mysql/build.gradle.kts b/infrastructure/mysql/build.gradle.kts index 3a7f3fc6..7cc3c986 100644 --- a/infrastructure/mysql/build.gradle.kts +++ b/infrastructure/mysql/build.gradle.kts @@ -1,12 +1,13 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar dependencies { - implementation("mysql:mysql-connector-java:8.0.32") + runtimeOnly("com.mysql:mysql-connector-j") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.data:spring-data-commons") implementation(project(":common")) implementation(project(":domain")) + implementation(project(":core")) } tasks.named("jar") { diff --git a/infrastructure/mysql/src/main/kotlin/com/wespot/message/MessageJpaRepository.kt b/infrastructure/mysql/src/main/kotlin/com/wespot/message/MessageJpaRepository.kt index fc99eaf8..d87f8fce 100644 --- a/infrastructure/mysql/src/main/kotlin/com/wespot/message/MessageJpaRepository.kt +++ b/infrastructure/mysql/src/main/kotlin/com/wespot/message/MessageJpaRepository.kt @@ -2,5 +2,5 @@ package com.wespot.message import org.springframework.data.jpa.repository.JpaRepository -interface MessageJpaRepository : JpaRepository { +interface MessageJpaRepository : JpaRepository { } \ No newline at end of file diff --git a/infrastructure/mysql/src/main/kotlin/com/wespot/user/SocialJpaEntity.kt b/infrastructure/mysql/src/main/kotlin/com/wespot/user/SocialJpaEntity.kt index eb8267c3..2740e57c 100644 --- a/infrastructure/mysql/src/main/kotlin/com/wespot/user/SocialJpaEntity.kt +++ b/infrastructure/mysql/src/main/kotlin/com/wespot/user/SocialJpaEntity.kt @@ -12,7 +12,6 @@ class SocialJpaEntity( @field: NotNull val socialType: Long, - @field: NotNull val socialRefreshToken: String ) \ No newline at end of file diff --git a/infrastructure/mysql/src/main/kotlin/com/wespot/user/UserJpaEntity.kt b/infrastructure/mysql/src/main/kotlin/com/wespot/user/UserJpaEntity.kt index b42aaa0c..212cb9d3 100644 --- a/infrastructure/mysql/src/main/kotlin/com/wespot/user/UserJpaEntity.kt +++ b/infrastructure/mysql/src/main/kotlin/com/wespot/user/UserJpaEntity.kt @@ -8,7 +8,6 @@ import java.time.LocalDateTime @Entity @Table(name = "user") class UserJpaEntity( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long, @@ -20,7 +19,7 @@ class UserJpaEntity( val grade: Int, @field: NotNull - val group: Int, + val groupNumber: Int, @OneToOne(fetch = FetchType.LAZY) @JoinColumn( @@ -43,7 +42,6 @@ class UserJpaEntity( val fcm: FCMJpaEntity, @Embedded - @field: NotNull val social: SocialJpaEntity, @Embedded diff --git a/infrastructure/mysql/src/main/kotlin/com/wespot/vote/VoteJpaEntity.kt b/infrastructure/mysql/src/main/kotlin/com/wespot/vote/VoteJpaEntity.kt index abd9d872..97adc710 100644 --- a/infrastructure/mysql/src/main/kotlin/com/wespot/vote/VoteJpaEntity.kt +++ b/infrastructure/mysql/src/main/kotlin/com/wespot/vote/VoteJpaEntity.kt @@ -19,14 +19,14 @@ class VoteJpaEntity( val grade: Int, @field: NotNull - val group: Int, + val groupNumber: Int, @field: NotNull val date: LocalDateTime, - @field: NotNull - @OneToMany(mappedBy = "vote", cascade = [CascadeType.PERSIST]) - val ballots: List +// @field: NotNull +// @OneToMany(mappedBy = "vote", cascade = [CascadeType.PERSIST]) +// val ballots: List, )