From a059ec1e18a82d78f5070e8b1c582ae7ea4f6822 Mon Sep 17 00:00:00 2001 From: RinRinPARK Date: Fri, 13 Sep 2024 02:30:28 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20Apple=20login=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + .../phote/controller/AuthController.kt | 14 ++ .../phote/service/AppleAuthService.kt | 153 ++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/main/kotlin/com/swm_standard/phote/service/AppleAuthService.kt diff --git a/build.gradle.kts b/build.gradle.kts index ac06726..594f7cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,6 +66,8 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.9.0-RC") implementation("org.springframework.boot:spring-boot-starter-webflux") testImplementation("com.navercorp.fixturemonkey:fixture-monkey-kotlin:1.0.23") + implementation("org.bouncycastle:bcpkix-jdk18on:1.75") + implementation("com.nimbusds:nimbus-jose-jwt:9.12") // querydsl implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") diff --git a/src/main/kotlin/com/swm_standard/phote/controller/AuthController.kt b/src/main/kotlin/com/swm_standard/phote/controller/AuthController.kt index e208698..ed4a9bf 100644 --- a/src/main/kotlin/com/swm_standard/phote/controller/AuthController.kt +++ b/src/main/kotlin/com/swm_standard/phote/controller/AuthController.kt @@ -6,6 +6,7 @@ import com.swm_standard.phote.dto.RenewAccessTokenResponse import com.swm_standard.phote.dto.UserInfoResponse import com.swm_standard.phote.service.GoogleAuthService import com.swm_standard.phote.service.KaKaoAuthService +import com.swm_standard.phote.service.AppleAuthService import com.swm_standard.phote.service.TokenService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag @@ -22,6 +23,7 @@ import java.util.UUID class AuthController( private val googleAuthService: GoogleAuthService, private val kaKaoAuthService: KaKaoAuthService, + private val appleAuthService: AppleAuthService, private val tokenService: TokenService, ) { @Operation(summary = "google-login", description = "구글 로그인/회원가입") @@ -48,6 +50,18 @@ class AuthController( return BaseResponse(msg = message, data = userInfo) } + @Operation(summary = "apple-login", description = "애플 로그인/회원가입") + @PostMapping("/apple-login") + fun appleLogin( + @RequestBody request: LoginRequest, + ): BaseResponse { + val idToken = appleAuthService.getTokenFromApple(request) + val userInfo = appleAuthService.getUserInfoFromApple(idToken) + + val message = if (userInfo.isMember == false) "회원가입 성공" else "로그인 성공" + return BaseResponse(msg = message, data = userInfo) + } + @Operation(summary = "renewAccessToken", description = "refreshToken으로 accessToken 갱신") @PostMapping("/refreshtoken") fun renewAccessToken( diff --git a/src/main/kotlin/com/swm_standard/phote/service/AppleAuthService.kt b/src/main/kotlin/com/swm_standard/phote/service/AppleAuthService.kt new file mode 100644 index 0000000..7264802 --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/service/AppleAuthService.kt @@ -0,0 +1,153 @@ +package com.swm_standard.phote.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.swm_standard.phote.common.authority.JwtTokenProvider +import com.swm_standard.phote.common.module.NicknameGenerator +import com.swm_standard.phote.common.module.ProfileImageGenerator +import com.swm_standard.phote.dto.LoginRequest +import com.swm_standard.phote.dto.UserInfoResponse +import com.swm_standard.phote.entity.Member +import com.swm_standard.phote.entity.Provider +import com.swm_standard.phote.repository.MemberRepository +import io.jsonwebtoken.JwsHeader +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestTemplate +import java.security.PrivateKey +import java.security.Security +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Date +import java.util.Base64 + +@Service +class AppleAuthService( + private val memberRepository: MemberRepository, + private val jwtTokenProvider: JwtTokenProvider, + private val tokenService: TokenService, +) { + @Value("\${APPLE_CLIENT_ID}") + lateinit var clientId: String + + @Value("\${APPLE_KEY_ID}") + lateinit var keyId: String + + @Value("\${APPLE_TEAM_ID}") + lateinit var teamId: String + + @Value("\${APPLE_AUTH_URL}") + lateinit var authUrl: String + + @Value("\${APPLE_K8_KEY}") + lateinit var k8Key: String + + fun getTokenFromApple(request: LoginRequest): String { + val headers = HttpHeaders() + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8") + + val body: MultiValueMap = LinkedMultiValueMap() + body.add("client_id", clientId) + body.add("client_secret", generateClientSecret()) + body.add("code", request.code) + body.add("grant_type", "authorization_code") + body.add("redirect_uri", request.redirectUri) + + val appleTokenRequest = HttpEntity(body, headers) + val response = + RestTemplate().exchange( + "https://appleid.apple.com/auth/token", + HttpMethod.POST, + appleTokenRequest, + String::class.java, + ) + + val responseBody = response.body + val objectMapper = ObjectMapper() + val jsonNode = objectMapper.readTree(responseBody) + + return jsonNode["id_token"].asText() + } + + fun getUserInfoFromApple(token: String): UserInfoResponse { + + val signedJWT: SignedJWT = SignedJWT.parse(token) + val getPayload: JWTClaimsSet = signedJWT.getJWTClaimsSet() + + val email = getPayload.getStringClaim("email") + + var member = memberRepository.findByEmail(email) + + val isMember: Boolean + + if (member == null) { + val nicknameGenerator = NicknameGenerator() + + member = + Member( + name = nicknameGenerator.randomNickname(), + email = email, + image = ProfileImageGenerator().imageGenerator(), + provider = Provider.APPLE, + ) + + memberRepository.save(member) + + isMember = false + } else { + isMember = true + } + + val dto = + UserInfoResponse( + name = member.name, + email = member.email, + picture = member.image, + isMember = isMember, + userId = member.id, + accessToken = jwtTokenProvider.createToken(member.id), + ) + + dto.refreshToken = tokenService.generateRefreshToken(member.id) + + return dto + } + + private fun generateClientSecret(): String { + val expiration: LocalDateTime = LocalDateTime.now().plusMinutes(5) + + return Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, keyId) + .setIssuer(teamId) + .setAudience(authUrl) + .setSubject(clientId) + .setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant())) + .setIssuedAt(Date()) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact() + } + + private fun getPrivateKey(): PrivateKey { + Security.addProvider(BouncyCastleProvider()) + val converter = JcaPEMKeyConverter().setProvider("BC") + + return try { + val privateKeyBytes = Base64.getDecoder().decode(k8Key) + val privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes) + converter.getPrivateKey(privateKeyInfo) + } catch (e: Exception) { + throw RuntimeException("private key생성 실패", e) + } + } +}