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: Apple login #79

Merged
merged 5 commits into from
Aug 29, 2024
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mashup.pic.auth.applicationService

import com.mashup.pic.auth.applicationService.dto.AppleLoginServiceRequest
import com.mashup.pic.auth.applicationService.dto.LoginServiceRequest
import com.mashup.pic.auth.applicationService.dto.ReissueServiceRequest
import com.mashup.pic.auth.controller.dto.LoginResponse
Expand All @@ -9,7 +10,8 @@ import com.mashup.pic.domain.user.UserDto
import com.mashup.pic.domain.user.UserService
import com.mashup.pic.security.authentication.UserInfo
import com.mashup.pic.security.jwt.JwtManager
import com.mashup.pic.security.oidc.IdTokenValidator
import com.mashup.pic.security.oidc.AppleIdTokenValidator
import com.mashup.pic.security.oidc.KakaoIdTokenValidator
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

Expand All @@ -19,11 +21,22 @@ class AuthApplicationService(
private val userService: UserService,
private val refreshTokenService: RefreshTokenService,
private val jwtManager: JwtManager,
private val idTokenValidator: IdTokenValidator
private val kakaoIdTokenValidator: KakaoIdTokenValidator,
private val appleIdTokenValidator: AppleIdTokenValidator
) {
@Transactional
fun login(request: LoginServiceRequest): LoginResponse {
val oAuthId = idTokenValidator.validateAndGetId(request.idToken, request.nickname)
val oAuthId = kakaoIdTokenValidator.validateAndGetId(request.idToken, request.nickname)
val user = userService.findUserByOAuthIdOrNull(oAuthId) ?: createUser(oAuthId, request)

val authToken = jwtManager.generateAuthToken(user.toUserInfo())
refreshTokenService.saveToken(user.id, authToken.refreshToken)
return LoginResponse.from(user, authToken)
}

@Transactional
fun appleLogin(request: AppleLoginServiceRequest): LoginResponse {
val oAuthId = appleIdTokenValidator.validateAndGetId(request.idToken, request.user)
val user = userService.findUserByOAuthIdOrNull(oAuthId) ?: createUser(oAuthId, request)

val authToken = jwtManager.generateAuthToken(user.toUserInfo())
Expand All @@ -46,21 +59,38 @@ class AuthApplicationService(
}

private fun createUser(
oAuthId: Long,
oAuthId: String,
request: LoginServiceRequest
): UserDto {
return userService.create(
oAuthId = oAuthId,
provider = request.provider,
nickname = request.nickname,
profileImage = request.profileImage
)
}

private fun createUser(
oAuthId: String,
request: AppleLoginServiceRequest
): UserDto {
return userService.create(
oAuthId = oAuthId,
provider = request.provider,
nickname = request.fullName,
profileImage = DEFAULT_PROFILE_IMAGE
)
}

fun UserDto.toUserInfo(): UserInfo {
return UserInfo(
id = this.id,
nickname = this.nickname,
roles = this.roles
)
}

companion object {
const val DEFAULT_PROFILE_IMAGE = "https://www.testhouse.net/wp-content/uploads/2021/11/default-avatar.jpg"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.mashup.pic.auth.applicationService.dto

import com.mashup.pic.domain.user.LoginProvider

data class AppleLoginServiceRequest(
val idToken: String,
val provider: LoginProvider,
val fullName: String,
val user: String
)
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
package com.mashup.pic.auth.applicationService.dto

import com.mashup.pic.domain.user.LoginProvider

data class LoginServiceRequest(
val idToken: String,
val provider: LoginProvider,
val nickname: String,
val profileImage: String
)

enum class LoginProvider {
KAKAO,
NAVER,
GOOGLE
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.mashup.pic.auth.controller

import com.mashup.pic.auth.applicationService.AuthApplicationService
import com.mashup.pic.auth.controller.dto.AppleLoginRequest
import com.mashup.pic.auth.controller.dto.LoginRequest
import com.mashup.pic.auth.controller.dto.LoginResponse
import com.mashup.pic.auth.controller.dto.ReissueRequest
Expand All @@ -22,14 +23,23 @@ class AuthController(
private val authApplicationService: AuthApplicationService
) {
@SecurityRequirements(value = [])
@Operation(summary = "๋กœ๊ทธ์ธ", description = "OIDC์˜ IDํ† ํฐ์œผ๋กœ ๋กœ๊ทธ์ธ")
@Operation(summary = "์นด์นด์˜ค ๋กœ๊ทธ์ธ", description = "OIDC์˜ IDํ† ํฐ์œผ๋กœ ๋กœ๊ทธ์ธ")
@PostMapping("/login")
fun login(
fun kakaoLogin(
@Valid @RequestBody loginRequest: LoginRequest
): ApiResponse<LoginResponse> {
return ApiResponse.success(authApplicationService.login(loginRequest.toServiceRequest()))
}

@SecurityRequirements(value = [])
@Operation(summary = "์• ํ”Œ ๋กœ๊ทธ์ธ", description = "OIDC์˜ IDํ† ํฐ์œผ๋กœ ๋กœ๊ทธ์ธ")
@PostMapping("/apple-login")
fun appleLogin(
@Valid @RequestBody loginRequest: AppleLoginRequest
): ApiResponse<LoginResponse> {
return ApiResponse.success(authApplicationService.appleLogin(loginRequest.toServiceRequest()))
}

@SecurityRequirements(value = [])
@Operation(summary = "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰", description = "๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์œผ๋กœ ์•ก์„ธ์Šค ํ† ํฐ, ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ์žฌ๋ฐœ๊ธ‰")
@PostMapping("/token")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.mashup.pic.auth.controller.dto

import com.mashup.pic.auth.applicationService.dto.AppleLoginServiceRequest
import com.mashup.pic.domain.user.LoginProvider
import jakarta.validation.constraints.NotBlank

data class AppleLoginRequest(
@NotBlank val idToken: String,
@NotBlank val fullName: String,
@NotBlank val user: String
) {
fun toServiceRequest(): AppleLoginServiceRequest {
return AppleLoginServiceRequest(
idToken = idToken,
provider = LoginProvider.APPLE,
fullName = fullName,
user = user
)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.mashup.pic.auth.controller.dto

import com.mashup.pic.auth.applicationService.dto.LoginProvider
import com.mashup.pic.auth.applicationService.dto.LoginServiceRequest
import com.mashup.pic.domain.user.LoginProvider
import jakarta.validation.constraints.NotBlank

data class LoginRequest(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.mashup.pic.security.oidc

import com.fasterxml.jackson.databind.ObjectMapper
import com.mashup.pic.common.exception.PicException
import com.mashup.pic.common.exception.PicExceptionType
import com.mashup.pic.external.apple.AppleClient
import com.mashup.pic.external.common.response.JwkKey
import io.jsonwebtoken.Jwts
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.math.BigInteger
import java.security.Key
import java.security.KeyFactory
import java.security.spec.RSAPublicKeySpec
import java.util.Base64

@Component
@Profile("!test")
class AppleIdTokenValidator(
private val appleJwksClient: AppleClient,
private val objectMapper: ObjectMapper,
@Value("\${apple.issuer}") private val issuer: String,
@Value("\${apple.audience}") private val audience: String
) : IdTokenValidator {
private val decoder = Base64.getUrlDecoder()
private val keyFactory = KeyFactory.getInstance(SIGNING_ALGORITHM)

override fun validateAndGetId(
idToken: String,
nickname: String
): String {
verifyPayload(idToken, nickname)
verifySignature(idToken)
return extractSub(idToken)
}

private fun extractSub(idToken: String): String {
val payload = decodePayload(idToken)
return payload[SUB_KEY] as String? ?: throw PicException.of(PicExceptionType.ARGUMENT_NOT_VALID, "Can't extract SUB")
}

private fun verifyPayload(
idToken: String,
sub: String
) {
val payload = decodePayload(idToken)
require(payload[ISSUER_KEY] == issuer) { "Invalid issuer" }
require(payload[AUDIENCE_KEY] == audience) { "Invalid audience" }
require(payload[SUB_KEY] == sub) { "Invalid nickname" }
}

private fun verifySignature(idToken: String) {
val kid = extractKid(idToken)
val publicKey = getPublicKey(kid)
Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(idToken)
}

private fun extractKid(idToken: String): String {
val header = decodeHeader(idToken)
return header[KID_KEY] as? String ?: throw PicException.of(PicExceptionType.ARGUMENT_NOT_VALID, "Can't extract KID")
}

private fun getPublicKey(kid: String): Key {
val jwk = getJwkByKid(kid)
val n = BigInteger(1, decoder.decode(jwk.n))
val e = BigInteger(1, decoder.decode(jwk.e))
val keySpec = RSAPublicKeySpec(n, e)
return keyFactory.generatePublic(keySpec)
}

private fun getJwkByKid(kid: String): JwkKey {
return appleJwksClient.getJwks().getJwkKeyByKid(kid)
?: appleJwksClient.refreshAndGetJwks().getJwkKeyByKid(kid)
?: throw PicException.of(PicExceptionType.ARGUMENT_NOT_VALID, "Can't find the Jwk matching the KID")
}

private fun decodePayload(idToken: String): Map<String, Any> {
val payload = idToken.split(".")[1]
val decodedPayload = String(decoder.decode(payload))
return objectMapper.readValue(decodedPayload, Map::class.java) as Map<String, Any>
}

private fun decodeHeader(idToken: String): Map<String, Any> {
val header = idToken.split(".")[0]
val decodedHeader = String(decoder.decode(header))
return objectMapper.readValue(decodedHeader, Map::class.java) as Map<String, Any>
}

companion object {
private const val ISSUER_KEY = "iss"
private const val AUDIENCE_KEY = "aud"
private const val SUB_KEY = "sub"
private const val KID_KEY = "kid"
private const val SIGNING_ALGORITHM = "RSA"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ interface IdTokenValidator {
fun validateAndGetId(
idToken: String,
nickname: String
): Long
): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ class KakaoIdTokenValidator(
override fun validateAndGetId(
idToken: String,
nickname: String
): Long {
): String {
verifyPayload(idToken, nickname)
verifySignature(idToken)
return extractSub(idToken).toLong()
return extractSub(idToken)
}

private fun extractSub(idToken: String): String {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.mashup.pic.user.applicationService

import com.mashup.pic.domain.user.UserService
import com.mashup.pic.external.common.JwksClient
import com.mashup.pic.external.kakao.KakaoClient
import com.mashup.pic.security.authentication.UserInfo
import com.mashup.pic.security.jwt.JwtManager
import org.springframework.stereotype.Service
Expand All @@ -11,7 +11,7 @@ import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
class UserApplicationService(
private val userService: UserService,
private val kakaoClient: JwksClient,
private val kakaoClient: KakaoClient,
private val jwtManager: JwtManager
) {
fun callbackPage(code: String): String? {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class FakeJwksClient : JwksClient {
TODO("Do Nothing in testing environment")
}

override fun getOAuthId(code: String): Long {
fun getOAuthId(code: String): Long {
TODO("Do Nothing in testing environment")
}
}

This file was deleted.

Loading
Loading