diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2c7348d5..a3674c36 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -6,7 +6,7 @@ reviews: high_level_summary: true poem: false review_status: true - collapse_walkthrough: true + collapse_walkthrough: false auto_review: enabled: true drafts: false diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 8558dd2d..9cdf1d12 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -31,6 +31,7 @@ jobs: echo "${{ secrets.DEV_SECRET_PROPERTIES }}" > ./secret/application-dev-secret.properties echo "${{ secrets.PROD_SECRET_PROPERTIES }}" > ./secret/application-prod-secret.properties echo "${{ secrets.TEST_SECRET_PROPERTIES }}" > ./secret/application-test-secret.properties + echo "${{ secrets.APPLE_AUTH_KEY }}" > ./secret/AuthKey.p8 chmod 600 ./secret/* - name: Set up JDK 21 diff --git a/.github/workflows/dev-ci-cd.yml b/.github/workflows/dev-ci-cd.yml index 3b8b3941..b5b5dbce 100644 --- a/.github/workflows/dev-ci-cd.yml +++ b/.github/workflows/dev-ci-cd.yml @@ -29,6 +29,7 @@ jobs: mkdir ./secret echo "${{ secrets.DEV_SECRET_PROPERTIES }}" > ./secret/application-dev-secret.properties echo "${{ secrets.TEST_SECRET_PROPERTIES }}" > ./secret/application-test-secret.properties + echo "${{ secrets.APPLE_AUTH_KEY }}" > ./secret/AuthKey.p8 chmod 600 ./secret/* - name: Set up Docker Buildx diff --git a/.github/workflows/prod-ci-cd.yml b/.github/workflows/prod-ci-cd.yml index e2050472..2d57c9cf 100644 --- a/.github/workflows/prod-ci-cd.yml +++ b/.github/workflows/prod-ci-cd.yml @@ -29,6 +29,7 @@ jobs: mkdir ./secret echo "${{ secrets.PROD_SECRET_PROPERTIES }}" > ./secret/application-prod-secret.properties echo "${{ secrets.TEST_SECRET_PROPERTIES }}" > ./secret/application-test-secret.properties + echo "${{ secrets.APPLE_AUTH_KEY }}" > ./secret/AuthKey.p8 chmod 600 ./secret/* - name: Set up Docker Buildx diff --git a/apis/build.gradle.kts b/apis/build.gradle.kts index 6f766de9..96e2b325 100644 --- a/apis/build.gradle.kts +++ b/apis/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(Dependencies.Spring.BOOT_STARTER_SECURITY) implementation(Dependencies.Spring.BOOT_STARTER_VALIDATION) implementation(Dependencies.Spring.BOOT_STARTER_ACTUATOR) + implementation(Dependencies.Spring.BOOT_STARTER_OAUTH2_CLIENT) implementation(Dependencies.Database.MYSQL_CONNECTOR) @@ -18,6 +19,11 @@ dependencies { implementation(Dependencies.Logging.KOTLIN_LOGGING) + implementation(Dependencies.BouncyCastle.BC_PROV) + implementation(Dependencies.BouncyCastle.BC_PKIX) + + annotationProcessor(Dependencies.Spring.CONFIGURATION_PROCESSOR) + testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) testImplementation(Dependencies.TestContainers.MYSQL) testImplementation(Dependencies.TestContainers.JUNIT_JUPITER) diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt index 5dff07fc..0bccabfc 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt @@ -12,9 +12,6 @@ import org.yapp.apis.auth.dto.response.UserProfileResponse import org.yapp.apis.auth.usecase.AuthUseCase import java.util.* -/** - * Implementation of the authentication controller API. - */ @RestController @RequestMapping("/api/v1/auth") class AuthController( diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt index 4431f5bd..20ea6d73 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt @@ -21,28 +21,28 @@ import org.yapp.apis.auth.dto.response.UserProfileResponse import org.yapp.globalutils.exception.ErrorResponse import java.util.* -@Tag(name = "Authentication", description = "Authentication API") +@Tag(name = "Authentication", description = "인증 관련 API") interface AuthControllerApi { @Operation( - summary = "Sign in or sign up with social login", - description = "Sign in a user with social login credentials (Kakao or Apple). If the user doesn't exist, they will be automatically registered." + summary = "소셜 로그인", + description = "카카오 또는 애플 계정으로 로그인합니다. 사용자가 존재하지 않으면 자동으로 회원가입됩니다." ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "Successful sign in or sign up", + description = "로그인/회원가입 성공", content = [Content(schema = Schema(implementation = AuthResponse::class))] ), ApiResponse( responseCode = "400", - description = "Invalid request or credentials", + description = "잘못된 요청 또는 인증 정보", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ), ApiResponse( responseCode = "409", - description = "Email already in use with a different account", + description = "이미 다른 계정으로 사용 중인 이메일", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] @@ -51,24 +51,24 @@ interface AuthControllerApi { fun signIn(@RequestBody @Valid request: SocialLoginRequest): ResponseEntity @Operation( - summary = "Refresh token", - description = "Refresh an access token using a refresh token. Returns both a new access token and a new refresh token." + summary = "토큰 갱신", + description = "리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다. 새로운 액세스 토큰과 리프레시 토큰을 반환합니다." ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "Successful token refresh", + description = "토큰 갱신 성공", content = [Content(schema = Schema(implementation = AuthResponse::class))] ), ApiResponse( responseCode = "400", - description = "Invalid refresh token", + description = "유효하지 않은 리프레시 토큰", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ), ApiResponse( responseCode = "404", - description = "Refresh token not found", + description = "리프레시 토큰을 찾을 수 없음", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] @@ -76,13 +76,13 @@ interface AuthControllerApi { @PostMapping("/refresh") fun refreshToken(@RequestBody @Valid request: TokenRefreshRequest): ResponseEntity - @Operation(summary = "Sign out", description = "Sign out a user by invalidating their refresh token") + @Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하여 사용자를 로그아웃합니다") @ApiResponses( value = [ - ApiResponse(responseCode = "204", description = "Successful sign out"), + ApiResponse(responseCode = "204", description = "로그아웃 성공"), ApiResponse( responseCode = "400", - description = "Invalid user ID", + description = "유효하지 않은 사용자 ID", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] @@ -90,17 +90,17 @@ interface AuthControllerApi { @PostMapping("/signout") fun signOut(@AuthenticationPrincipal userId: UUID): ResponseEntity - @Operation(summary = "Get user profile", description = "Retrieves profile information for the given user ID.") + @Operation(summary = "사용자 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보를 조회합니다.") @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "User profile retrieved successfully", + description = "사용자 프로필 조회 성공", content = [Content(schema = Schema(implementation = UserProfileResponse::class))] ), ApiResponse( responseCode = "404", - description = "User not found", + description = "사용자를 찾을 수 없음", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] @@ -108,17 +108,17 @@ interface AuthControllerApi { @GetMapping("/me") fun getUserProfile(@AuthenticationPrincipal userId: UUID): ResponseEntity - @Operation(summary = "Update terms agreement", description = "Updates the user's terms agreement status") + @Operation(summary = "약관 동의 상태 수정", description = "사용자의 약관 동의 상태를 업데이트합니다") @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "Terms agreement status updated successfully", + description = "약관 동의 상태 업데이트 성공", content = [Content(schema = Schema(implementation = UserProfileResponse::class))] ), ApiResponse( responseCode = "404", - description = "User not found", + description = "사용자를 찾을 수 없음", content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/FindOrCreateUserRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/FindOrCreateUserRequest.kt index 8132dfa9..9642ee30 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/FindOrCreateUserRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/FindOrCreateUserRequest.kt @@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import org.yapp.apis.auth.dto.response.UserCreateInfoResponse -import org.yapp.apis.util.NicknameGenerator +import org.yapp.apis.auth.util.NicknameGenerator import org.yapp.domain.user.ProviderType @Schema( diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SaveAppleRefreshTokenRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SaveAppleRefreshTokenRequest.kt new file mode 100644 index 00000000..0e4e9273 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SaveAppleRefreshTokenRequest.kt @@ -0,0 +1,50 @@ +package org.yapp.apis.auth.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import org.yapp.apis.auth.dto.response.CreateUserResponse +import org.yapp.apis.auth.strategy.AppleAuthCredentials +import java.util.UUID + +@Schema( + name = "SaveAppleRefreshTokenRequest", + description = "Request DTO for saving Apple refresh token with user ID and authorization code" +) +data class SaveAppleRefreshTokenRequest private constructor( + @Schema( + description = "Unique identifier of the user", + example = "a1b2c3d4-e5f6-7890-1234-56789abcdef0" + ) + @field:NotNull(message = "userId must not be null") + val userId: UUID? = null, + + @Schema( + description = "Authorization code from Apple OAuth process", + example = "cdef1234-abcd-5678-efgh-9012ijklmnop" + ) + @field:NotBlank(message = "authorizationCode must not be blank") + val authorizationCode: String? = null, + + @Schema( + description = "Apple refresh token, nullable if not issued yet", + example = "apple-refresh-token-example" + ) + val appleRefreshToken: String? = null +) { + fun validUserId(): UUID = userId!! + fun validAuthorizationCode(): String = authorizationCode!! + + companion object { + fun of( + userResponse: CreateUserResponse, + credentials: AppleAuthCredentials + ): SaveAppleRefreshTokenRequest { + return SaveAppleRefreshTokenRequest( + userId = userResponse.id, + authorizationCode = credentials.authorizationCode, + appleRefreshToken = userResponse.appleRefreshToken + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt index 3adae361..61238588 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt @@ -28,7 +28,14 @@ data class SocialLoginRequest private constructor( required = true ) @field:NotBlank(message = "OAuth token is required") - val oauthToken: String? = null + val oauthToken: String? = null, + + @Schema( + description = "Authorization code used to issue Apple access/refresh tokens (required only for Apple login)", + example = "c322a426...", + required = false + ) + val authorizationCode: String? = null ) { fun validProviderType(): String = providerType!! fun validOauthToken(): String = oauthToken!! @@ -46,7 +53,11 @@ data class SocialLoginRequest private constructor( return when (provider) { ProviderType.KAKAO -> KakaoAuthCredentials(request.validOauthToken()) - ProviderType.APPLE -> AppleAuthCredentials(request.validOauthToken()) + ProviderType.APPLE -> { + val authCode = request.authorizationCode + ?: throw AuthException(AuthErrorCode.INVALID_REQUEST, "Apple login requires an authorization code.") + AppleAuthCredentials(request.validOauthToken(), authCode) + } } } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/TermsAgreementRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/TermsAgreementRequest.kt index 766adf15..cd8197f2 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/TermsAgreementRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/TermsAgreementRequest.kt @@ -6,9 +6,8 @@ import jakarta.validation.constraints.NotNull @Schema(description = "Request to update terms agreement status") data class TermsAgreementRequest private constructor( @Schema(description = "Whether the user agrees to the terms of service", example = "true", required = true) + @field:NotNull(message = "termsAgreed must not be null") val termsAgreed: Boolean? = null - - ) { fun validTermsAgreed(): Boolean = termsAgreed!! } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/CreateUserResponse.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/CreateUserResponse.kt index a984ba39..687f177e 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/CreateUserResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/CreateUserResponse.kt @@ -1,9 +1,9 @@ package org.yapp.apis.auth.dto.response import io.swagger.v3.oas.annotations.media.Schema -import org.yapp.domain.user.vo.UserIdentityVO +import org.yapp.domain.user.vo.UserAuthVO import org.yapp.globalutils.auth.Role -import java.util.UUID +import java.util.* @Schema( name = "CreateUserResponse", @@ -20,13 +20,20 @@ data class CreateUserResponse private constructor( description = "사용자 역할", example = "USER" ) - val role: Role + val role: Role, + + @Schema( + description = "Apple Refresh Token (Apple 유저인 경우에만 존재)", + nullable = true + ) + val appleRefreshToken: String? ) { companion object { - fun from(identity: UserIdentityVO): CreateUserResponse { + fun from(auth: UserAuthVO): CreateUserResponse { return CreateUserResponse( - id = identity.id.value, - role = identity.role + id = auth.id.value, + role = auth.role, + appleRefreshToken = auth.appleRefreshToken ) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt index 1e98a1d2..45bebdc4 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt @@ -3,9 +3,6 @@ package org.yapp.apis.auth.exception import org.springframework.http.HttpStatus import org.yapp.globalutils.exception.BaseErrorCode -/** - * Error codes for authentication-related errors. - */ enum class AuthErrorCode( private val httpStatus: HttpStatus, private val code: String, @@ -13,23 +10,44 @@ enum class AuthErrorCode( ) : BaseErrorCode { /* 400 BAD_REQUEST */ - UNSUPPORTED_PROVIDER_TYPE(HttpStatus.BAD_REQUEST, "AUTH_005", "Unsupported provider type."), - INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "AUTH_006", "Invalid credentials."), - EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_007", "Email not found."), - INVALID_ID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "AUTH_008", "Invalid ID token format."), - SUBJECT_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_009", "Subject not found in ID token."), - FAILED_TO_PARSE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_010", "Failed to parse ID token."), - FAILED_TO_GET_USER_INFO(HttpStatus.BAD_REQUEST, "AUTH_011", "Failed to get user info from provider."), - USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_012", "User not found."), + UNSUPPORTED_PROVIDER_TYPE(HttpStatus.BAD_REQUEST, "AUTH_400_01", "지원되지 않는 공급자 타입입니다."), + INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "AUTH_400_02", "잘못된 인증 정보입니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "AUTH_400_03", "잘못된 요청입니다."), + INVALID_ID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "AUTH_400_04", "잘못된 ID 토큰 형식입니다."), + SUBJECT_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_05", "ID 토큰에서 주체를 찾을 수 없습니다."), + FAILED_TO_PARSE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_06", "ID 토큰 파싱에 실패했습니다."), + FAILED_TO_GET_USER_INFO(HttpStatus.BAD_REQUEST, "AUTH_400_07", "공급자로부터 사용자 정보를 가져오는데 실패했습니다."), + USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_08", "사용자를 찾을 수 없습니다."), + EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_09", "이메일을 찾을 수 없습니다."), + INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_10", "유효하지 않은 Apple ID 토큰입니다."), /* 401 UNAUTHORIZED */ - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_002", "Invalid refresh token."), - REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH_003", "Refresh token not found."), - INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_004", "Invalid access token."), + INVALID_OAUTH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_01", "잘못된 소셜 OAuth 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_02", "잘못된 리프레시 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH_401_03", "리프레시 토큰을 찾을 수 없습니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_04", "잘못된 액세스 토큰입니다."), + + /* 403 FORBIDDEN */ + INSUFFICIENT_PERMISSIONS(HttpStatus.FORBIDDEN, "AUTH_403_01", "요청된 리소스에 대한 권한이 부족합니다."), /* 409 CONFLICT */ - EMAIL_ALREADY_IN_USE(HttpStatus.CONFLICT, "AUTH_001", "Email already in use with a different account."); + EMAIL_ALREADY_IN_USE(HttpStatus.CONFLICT, "AUTH_409_01", "이미 다른 계정에서 사용 중인 이메일입니다."), + /* 500 INTERNAL_SERVER_ERROR */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_01", "내부 서버 오류입니다."), + FAILED_TO_LOAD_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_02", "Apple 개인키 로드에 실패했습니다."), + INVALID_PRIVATE_KEY_FORMAT(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_03", "잘못된 개인키 형식입니다."), + FAILED_TO_COMMUNICATE_WITH_PROVIDER( + HttpStatus.INTERNAL_SERVER_ERROR, + "AUTH_500_04", + "외부 공급자와의 통신에 실패했습니다." + ), + OAUTH_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_05", "소셜 OAuth 서버 오류입니다."), + MISSING_APPLE_REFRESH_TOKEN( + HttpStatus.INTERNAL_SERVER_ERROR, + "AUTH_500_06", + "Apple에서 초기 로그인 시 리프레시 토큰을 제공하지 않았습니다." + ); override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/AppleJwtHelper.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/AppleJwtHelper.kt deleted file mode 100644 index 4c3eeacd..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/auth/helper/AppleJwtHelper.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.yapp.apis.auth.helper - -import com.fasterxml.jackson.core.JsonProcessingException -import com.fasterxml.jackson.databind.ObjectMapper -import mu.KotlinLogging -import org.yapp.apis.auth.exception.AuthErrorCode -import org.yapp.apis.auth.exception.AuthException -import org.yapp.globalutils.annotation.Helper -import java.util.* - -@Helper -class AppleJwtHelper( - private val objectMapper: ObjectMapper -) { - private val log = KotlinLogging.logger {} - - companion object { - private const val JWT_PARTS_COUNT = 3 - private const val JWT_PAYLOAD_INDEX = 1 - } - - fun parseIdToken(idToken: String): AppleIdTokenPayload { - return try { - val parts = idToken.split(".") - require(parts.size == JWT_PARTS_COUNT) { - "Invalid JWT format: expected $JWT_PARTS_COUNT parts but got ${parts.size}" - } - - val decodedPayload = decodeBase64UrlSafe(parts[JWT_PAYLOAD_INDEX]) - val payloadJson = String(decodedPayload, Charsets.UTF_8) - - objectMapper.readValue(payloadJson, AppleIdTokenPayload::class.java) - - } catch (e: IllegalArgumentException) { - log.error("Invalid Apple ID token format", e) - throw AuthException( - AuthErrorCode.INVALID_ID_TOKEN_FORMAT, - "Invalid token format: ${e.message}" - ) - } catch (e: JsonProcessingException) { - log.error("Failed to parse Apple ID token JSON", e) - throw AuthException( - AuthErrorCode.FAILED_TO_PARSE_ID_TOKEN, - "Failed to parse JSON: ${e.message}" - ) - } catch (e: Exception) { - log.error("Failed to parse Apple ID token", e) - throw AuthException( - AuthErrorCode.FAILED_TO_PARSE_ID_TOKEN, - "Failed to parse token: ${e.message}" - ) - } - } - - private fun decodeBase64UrlSafe(encoded: String): ByteArray { - return try { - Base64.getUrlDecoder().decode(encoded) - } catch (e: IllegalArgumentException) { - throw AuthException( - AuthErrorCode.INVALID_ID_TOKEN_FORMAT, - "Invalid Base64 encoding: ${e.message}" - ) - } - } - - data class AppleIdTokenPayload( - val sub: String, - val email: String?, - val name: String? - ) { - init { - require(sub.isNotBlank()) { "Subject cannot be blank" } - } - } -} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/AuthTokenHelper.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/AuthTokenHelper.kt deleted file mode 100644 index 8c3d96f4..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/auth/helper/AuthTokenHelper.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.yapp.apis.auth.helper - -import org.yapp.apis.auth.dto.request.DeleteTokenRequest -import org.yapp.apis.auth.dto.request.GenerateTokenPairRequest -import org.yapp.apis.auth.dto.request.TokenGenerateRequest -import org.yapp.apis.auth.dto.request.TokenRefreshRequest -import org.yapp.apis.auth.dto.response.TokenPairResponse -import org.yapp.apis.auth.dto.response.UserIdResponse -import org.yapp.apis.auth.service.TokenService -import org.yapp.gateway.jwt.JwtTokenService -import org.yapp.globalutils.annotation.Helper - -@Helper -class AuthTokenHelper( - private val tokenService: TokenService, - private val jwtTokenService: JwtTokenService -) { - fun generateTokenPair(generateTokenPairRequest: GenerateTokenPairRequest): TokenPairResponse { - val userId = generateTokenPairRequest.validUserId() - val role = generateTokenPairRequest.validRole() - - val accessToken = jwtTokenService.generateAccessToken(userId, role) - val refreshToken = jwtTokenService.generateRefreshToken(userId) - val expiration = jwtTokenService.getRefreshTokenExpiration() - - val refreshTokenResponse = tokenService.saveRefreshToken( - TokenGenerateRequest.of(userId, refreshToken, expiration) - ) - - return TokenPairResponse.of(accessToken, refreshTokenResponse.refreshToken) - } - - fun validateAndGetUserIdFromRefreshToken(tokenRefreshRequest: TokenRefreshRequest): UserIdResponse { - tokenService.validateRefreshToken(tokenRefreshRequest.validRefreshToken()) - return tokenService.getUserIdByToken(tokenRefreshRequest) - } - - fun deleteTokenForReissue(tokenRefreshRequest: TokenRefreshRequest) { - tokenService.deleteRefreshTokenByToken(tokenRefreshRequest.validRefreshToken()) - } - - fun deleteTokenForSignOut(deleteTokenRequest: DeleteTokenRequest) { - tokenService.deleteRefreshTokenByToken(deleteTokenRequest.validRefreshToken()) - } -} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/KakaoApiHelper.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/KakaoApiHelper.kt deleted file mode 100644 index ce17f076..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/auth/helper/KakaoApiHelper.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.yapp.apis.auth.helper - -import mu.KotlinLogging -import org.yapp.apis.auth.exception.AuthErrorCode -import org.yapp.apis.auth.exception.AuthException -import org.yapp.globalutils.annotation.Helper -import org.yapp.infra.external.oauth.kakao.KakaoApi -import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo - -@Helper -class KakaoApiHelper( - private val kakaoApi: KakaoApi -) { - private val log = KotlinLogging.logger {} - - fun getUserInfo(accessToken: String): KakaoUserInfo { - return kakaoApi.fetchUserInfo(accessToken) - .onSuccess { userInfo -> - log.info("Successfully fetched Kakao user info for userId: ${userInfo.id}") - } - .getOrElse { exception -> - log.error("Failed to fetch Kakao user info", exception) - throw AuthException( - AuthErrorCode.FAILED_TO_GET_USER_INFO, - "Failed to call Kakao API: ${exception.message}" - ) - } - } -} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/AppleClientSecretGenerator.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/AppleClientSecretGenerator.kt new file mode 100644 index 00000000..e4f2dd48 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/AppleClientSecretGenerator.kt @@ -0,0 +1,37 @@ +package org.yapp.apis.auth.helper.apple + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm +import org.springframework.security.oauth2.jwt.JwsHeader +import org.springframework.security.oauth2.jwt.JwtClaimsSet +import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.JwtEncoderParameters +import org.yapp.apis.config.AppleOauthProperties +import org.yapp.globalutils.annotation.Helper +import java.time.Instant +import java.time.temporal.ChronoUnit + +@Helper +class AppleClientSecretGenerator( + private val appleProperties: AppleOauthProperties, + @Qualifier("appleJwtEncoder") + private val jwtEncoder: JwtEncoder, +) { + fun generateClientSecret(): String { + val header = JwsHeader.with(SignatureAlgorithm.ES256) + .keyId(appleProperties.keyId) + .build() + + val claims = JwtClaimsSet.builder() + .issuer(appleProperties.teamId) + .subject(appleProperties.clientId) + .audience(listOf(appleProperties.audience)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plus(5, ChronoUnit.MINUTES)) + .build() + + val encoderParameters = JwtEncoderParameters.from(header, claims) + + return jwtEncoder.encode(encoderParameters).tokenValue + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/AppleIdTokenProcessor.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/AppleIdTokenProcessor.kt new file mode 100644 index 00000000..5ddf3f20 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/AppleIdTokenProcessor.kt @@ -0,0 +1,32 @@ +package org.yapp.apis.auth.helper.apple + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.globalutils.annotation.Helper + +@Helper +class AppleIdTokenProcessor( + @Qualifier("appleIdTokenDecoder") + private val jwtDecoder: JwtDecoder, + private val objectMapper: ObjectMapper +) { + fun parseAndValidate(idToken: String): AppleIdTokenPayload { + try { + val decodedJwt: Jwt = jwtDecoder.decode(idToken) + val claims = decodedJwt.claims + + return objectMapper.convertValue(claims, AppleIdTokenPayload::class.java) + } catch (e: Exception) { + throw AuthException(AuthErrorCode.INVALID_APPLE_ID_TOKEN, e.message) + } + } + + data class AppleIdTokenPayload( + val sub: String, + val email: String? + ) +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/ApplePrivateKeyLoader.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/ApplePrivateKeyLoader.kt new file mode 100644 index 00000000..94221b1b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/helper/apple/ApplePrivateKeyLoader.kt @@ -0,0 +1,39 @@ +package org.yapp.apis.auth.helper.apple + +import org.springframework.context.annotation.Profile +import org.springframework.core.io.ResourceLoader +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.auth.util.AppleKeyParser +import org.yapp.apis.config.AppleOauthProperties +import org.yapp.globalutils.annotation.Helper +import java.security.KeyPair + +@Helper +@Profile("!test") +class ApplePrivateKeyLoader( + private val appleProperties: AppleOauthProperties, + private val resourceLoader: ResourceLoader, +) { + val keyPair: KeyPair = loadKeyPair() + + private fun loadKeyPair(): KeyPair { + val resource = resourceLoader.getResource(appleProperties.keyPath) + if (!resource.exists()) { + throw AuthException( + AuthErrorCode.FAILED_TO_LOAD_PRIVATE_KEY, + "Apple private key file not found at path: ${appleProperties.keyPath}" + ) + } + + return try { + val pemString = resource.inputStream.bufferedReader().use { it.readText() } + AppleKeyParser.parseKeyPair(pemString) + } catch (e: IllegalArgumentException) { + throw AuthException( + AuthErrorCode.INVALID_PRIVATE_KEY_FORMAT, + "Failed to parse Apple private key: ${e.message}", + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/manager/AppleApiManager.kt b/apis/src/main/kotlin/org/yapp/apis/auth/manager/AppleApiManager.kt new file mode 100644 index 00000000..1612c7f7 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/manager/AppleApiManager.kt @@ -0,0 +1,38 @@ +package org.yapp.apis.auth.manager + +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.auth.helper.apple.AppleClientSecretGenerator +import org.yapp.apis.config.AppleOauthProperties +import org.yapp.infra.external.oauth.apple.AppleApi +import org.yapp.infra.external.oauth.apple.response.AppleTokenResponse + +@Component +class AppleApiManager( + private val appleApi: AppleApi, + private val properties: AppleOauthProperties, + private val appleClientSecretGenerator: AppleClientSecretGenerator +) { + private val log = KotlinLogging.logger {} + + fun fetchAppleOauthTokens(authorizationCode: String): AppleTokenResponse { + val clientSecret = appleClientSecretGenerator.generateClientSecret() + + return appleApi.getOauthTokens( + clientId = properties.clientId, + clientSecret = clientSecret, + code = authorizationCode + ).onSuccess { response -> + log.info { + "Successfully fetched Apple OAuth tokens. user_id=${response.idToken.substring(0, 20)}..." + } + }.getOrElse { originalError -> + log.error(originalError) { "Failed to fetch Apple OAuth tokens." } + throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, "Failed to communicate with Apple OAuth server." + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt b/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt new file mode 100644 index 00000000..b93a5acd --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/manager/KakaoApiManager.kt @@ -0,0 +1,45 @@ +package org.yapp.apis.auth.manager + +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.HttpServerErrorException +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.infra.external.oauth.kakao.KakaoApi +import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo + +@Component +class KakaoApiManager( + private val kakaoApi: KakaoApi +) { + private val log = KotlinLogging.logger {} + + fun getUserInfo(accessToken: String): KakaoUserInfo { + return kakaoApi.fetchUserInfo(accessToken) + .onSuccess { userInfo -> + log.info("Successfully fetched Kakao user info for userId: ${userInfo.id}") + } + .getOrElse { exception -> + log.error("Failed to fetch Kakao user info", exception) + + when (exception) { + is HttpClientErrorException -> { + throw AuthException( + AuthErrorCode.INVALID_OAUTH_TOKEN, + "Invalid Kakao OAuth token." + ) + } + + is HttpServerErrorException, is Exception -> { + throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Failed to communicate with Kakao server." + ) + } + + else -> throw exception + } + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/AppleAuthService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/AppleAuthService.kt new file mode 100644 index 00000000..b147b179 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/AppleAuthService.kt @@ -0,0 +1,28 @@ +package org.yapp.apis.auth.service + +import jakarta.validation.Valid +import org.springframework.stereotype.Service +import org.springframework.validation.annotation.Validated +import org.yapp.apis.auth.dto.request.SaveAppleRefreshTokenRequest +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.auth.manager.AppleApiManager +import org.yapp.domain.user.UserDomainService + +@Service +@Validated +class AppleAuthService( + private val appleApiManager: AppleApiManager, + private val userDomainService: UserDomainService +) { + fun saveAppleRefreshTokenIfMissing(@Valid request: SaveAppleRefreshTokenRequest) { + if (request.appleRefreshToken == null) { + val tokenResponse = appleApiManager.fetchAppleOauthTokens(request.validAuthorizationCode()) + + val refreshToken = tokenResponse.refreshToken + ?: throw AuthException(AuthErrorCode.MISSING_APPLE_REFRESH_TOKEN) + + userDomainService.updateAppleRefreshToken(request.validUserId(), refreshToken) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/AuthTokenService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/AuthTokenService.kt new file mode 100644 index 00000000..4ee67f36 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/AuthTokenService.kt @@ -0,0 +1,47 @@ +package org.yapp.apis.auth.service + +import jakarta.validation.Valid +import org.springframework.stereotype.Service +import org.springframework.validation.annotation.Validated +import org.yapp.apis.auth.dto.request.DeleteTokenRequest +import org.yapp.apis.auth.dto.request.GenerateTokenPairRequest +import org.yapp.apis.auth.dto.request.TokenGenerateRequest +import org.yapp.apis.auth.dto.request.TokenRefreshRequest +import org.yapp.apis.auth.dto.response.TokenPairResponse +import org.yapp.apis.auth.dto.response.UserIdResponse +import org.yapp.gateway.jwt.JwtTokenService + +@Service +@Validated +class AuthTokenService( + private val refreshTokenService: RefreshTokenService, + private val jwtTokenService: JwtTokenService +) { + fun generateTokenPair(@Valid generateTokenPairRequest: GenerateTokenPairRequest): TokenPairResponse { + val userId = generateTokenPairRequest.validUserId() + val role = generateTokenPairRequest.validRole() + + val accessToken = jwtTokenService.generateAccessToken(userId, role) + val refreshToken = jwtTokenService.generateRefreshToken(userId) + val expiration = jwtTokenService.getRefreshTokenExpiration() + + val refreshTokenResponse = refreshTokenService.saveRefreshToken( + TokenGenerateRequest.of(userId, refreshToken, expiration) + ) + + return TokenPairResponse.of(accessToken, refreshTokenResponse.refreshToken) + } + + fun validateAndGetUserIdFromRefreshToken(@Valid tokenRefreshRequest: TokenRefreshRequest): UserIdResponse { + refreshTokenService.validateRefreshToken(tokenRefreshRequest.validRefreshToken()) + return refreshTokenService.getUserIdByToken(tokenRefreshRequest) + } + + fun deleteTokenForReissue(@Valid tokenRefreshRequest: TokenRefreshRequest) { + refreshTokenService.deleteRefreshTokenByToken(tokenRefreshRequest.validRefreshToken()) + } + + fun deleteTokenForSignOut(@Valid deleteTokenRequest: DeleteTokenRequest) { + refreshTokenService.deleteRefreshTokenByToken(deleteTokenRequest.validRefreshToken()) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/RefreshTokenService.kt similarity index 92% rename from apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt rename to apis/src/main/kotlin/org/yapp/apis/auth/service/RefreshTokenService.kt index 93b49fde..fee4ef7b 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/RefreshTokenService.kt @@ -12,7 +12,7 @@ import java.util.* @Service @Validated -class TokenService( +class RefreshTokenService( private val tokenDomainRedisService: TokenDomainRedisService, ) { fun deleteRefreshTokenByToken(token: String) { @@ -37,7 +37,7 @@ class TokenService( tokenDomainRedisService.validateRefreshTokenByToken(refreshToken) } - fun getUserIdByToken(tokenRefreshRequest: TokenRefreshRequest): UserIdResponse { + fun getUserIdByToken(@Valid tokenRefreshRequest: TokenRefreshRequest): UserIdResponse { val userId = tokenDomainRedisService.getUserIdByToken(tokenRefreshRequest.validRefreshToken()) return UserIdResponse.from(userId) } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt index 6e9160bc..190ecf9f 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt @@ -11,7 +11,7 @@ import org.yapp.apis.auth.dto.response.UserProfileResponse import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.domain.user.UserDomainService -import org.yapp.domain.user.vo.UserIdentityVO +import org.yapp.domain.user.vo.UserAuthVO import java.util.* @Service @@ -45,22 +45,23 @@ class UserAuthService( userDomainService.findUserByProviderTypeAndProviderId( findOrCreateUserRequest.validProviderType(), findOrCreateUserRequest.validProviderId() - )?.let { return CreateUserResponse.from(it) } + )?.let { + return CreateUserResponse.from(it) + } userDomainService.findUserByProviderTypeAndProviderIdIncludingDeleted( findOrCreateUserRequest.validProviderType(), findOrCreateUserRequest.validProviderId() - )?.let { deletedUserIdentity -> - return CreateUserResponse.from( - userDomainService.restoreDeletedUser(deletedUserIdentity.id.value) - ) + )?.let { deletedUserAuth -> + val restoredUser = userDomainService.restoreDeletedUser(deletedUserAuth.id.value) + return CreateUserResponse.from(restoredUser) } val createdUser = createNewUser(findOrCreateUserRequest) return CreateUserResponse.from(createdUser) } - private fun createNewUser(findOrCreateUserRequest: FindOrCreateUserRequest): UserIdentityVO { + private fun createNewUser(@Valid findOrCreateUserRequest: FindOrCreateUserRequest): UserAuthVO { val email = findOrCreateUserRequest.getOrDefaultEmail() val nickname = findOrCreateUserRequest.getOrDefaultNickname() @@ -68,7 +69,7 @@ class UserAuthService( throw AuthException(AuthErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use") } - return userDomainService.createNewUser( + return userDomainService.createUser( email = email, nickname = nickname, profileImageUrl = findOrCreateUserRequest.profileImageUrl, diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt index 894452ee..b094550f 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt @@ -5,34 +5,26 @@ import org.springframework.stereotype.Component import org.yapp.apis.auth.dto.response.UserCreateInfoResponse import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.apis.auth.helper.AppleJwtHelper -import org.yapp.apis.util.NicknameGenerator +import org.yapp.apis.auth.helper.apple.AppleIdTokenProcessor +import org.yapp.apis.auth.util.NicknameGenerator import org.yapp.domain.user.ProviderType -/** - * Implementation of AuthStrategy for Apple authentication. - */ @Component class AppleAuthStrategy( - private val appleJwtHelper: AppleJwtHelper + private val appleIdTokenProcessor: AppleIdTokenProcessor ) : AuthStrategy { - private val log = KotlinLogging.logger {} override fun getProviderType(): ProviderType = ProviderType.APPLE override fun authenticate(credentials: AuthCredentials): UserCreateInfoResponse { - return try { - val appleCredentials = validateCredentials(credentials) - val payload = appleJwtHelper.parseIdToken(appleCredentials.idToken) - createUserInfo(payload) - } catch (exception: Exception) { - log.error("Apple authentication failed", exception) - when (exception) { - is AuthException -> throw exception - else -> throw AuthException(AuthErrorCode.FAILED_TO_GET_USER_INFO, exception.message) - } - } + val appleCredentials = validateCredentials(credentials) + + val payload = appleIdTokenProcessor.parseAndValidate(appleCredentials.idToken) + + log.info { "Apple ID Token validated successfully. sub=${payload.sub}, email=${payload.email}" } + + return createUserInfo(payload) } private fun validateCredentials(credentials: AuthCredentials): AppleAuthCredentials { @@ -43,7 +35,7 @@ class AppleAuthStrategy( ) } - private fun createUserInfo(payload: AppleJwtHelper.AppleIdTokenPayload): UserCreateInfoResponse { + private fun createUserInfo(payload: AppleIdTokenProcessor.AppleIdTokenPayload): UserCreateInfoResponse { return UserCreateInfoResponse.of( email = payload.email, nickname = NicknameGenerator.generate(), diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthCredentials.kt index fd89cb96..8ff9fc55 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthCredentials.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthCredentials.kt @@ -13,7 +13,8 @@ data class KakaoAuthCredentials( } data class AppleAuthCredentials( - val idToken: String + val idToken: String, + val authorizationCode: String ) : AuthCredentials() { override fun getProviderType(): ProviderType = ProviderType.APPLE } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/SocialAuthService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategyResolver.kt similarity index 77% rename from apis/src/main/kotlin/org/yapp/apis/auth/service/SocialAuthService.kt rename to apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategyResolver.kt index 6e6c1fa6..97f08b62 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/service/SocialAuthService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategyResolver.kt @@ -1,16 +1,13 @@ -package org.yapp.apis.auth.service +package org.yapp.apis.auth.strategy import org.springframework.stereotype.Service -import org.yapp.apis.auth.strategy.AuthCredentials import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.apis.auth.strategy.AuthStrategy @Service -class SocialAuthService( +class AuthStrategyResolver( private val strategies: List ) { - fun resolve(credentials: AuthCredentials): AuthStrategy { return strategies.find { it.getProviderType() == credentials.getProviderType() } ?: throw AuthException( diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt index f6f98b94..3d82ef93 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt @@ -5,17 +5,14 @@ import org.springframework.stereotype.Component import org.yapp.apis.auth.dto.response.UserCreateInfoResponse import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.apis.auth.helper.KakaoApiHelper -import org.yapp.apis.util.NicknameGenerator +import org.yapp.apis.auth.manager.KakaoApiManager +import org.yapp.apis.auth.util.NicknameGenerator import org.yapp.domain.user.ProviderType import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo -/** - * Implementation of AuthStrategy for Kakao authentication. - */ @Component class KakaoAuthStrategy( - private val kakaoApiHelper: KakaoApiHelper + private val kakaoApiManager: KakaoApiManager ) : AuthStrategy { private val log = KotlinLogging.logger {} @@ -25,7 +22,7 @@ class KakaoAuthStrategy( override fun authenticate(credentials: AuthCredentials): UserCreateInfoResponse { return try { val kakaoCredentials = validateCredentials(credentials) - val kakaoUser = kakaoApiHelper.getUserInfo(kakaoCredentials.accessToken) + val kakaoUser = kakaoApiManager.getUserInfo(kakaoCredentials.accessToken) createUserInfo(kakaoUser) } catch (exception: Exception) { log.error("Kakao authentication failed", exception) diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt index d959ddfb..f572731f 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt @@ -4,51 +4,61 @@ import org.springframework.transaction.annotation.Transactional import org.yapp.apis.auth.dto.request.* import org.yapp.apis.auth.dto.response.TokenPairResponse import org.yapp.apis.auth.dto.response.UserProfileResponse -import org.yapp.apis.auth.helper.AuthTokenHelper -import org.yapp.apis.auth.service.SocialAuthService -import org.yapp.apis.auth.service.TokenService +import org.yapp.apis.auth.service.AppleAuthService +import org.yapp.apis.auth.service.AuthTokenService +import org.yapp.apis.auth.service.RefreshTokenService import org.yapp.apis.auth.service.UserAuthService +import org.yapp.apis.auth.strategy.AppleAuthCredentials +import org.yapp.apis.auth.strategy.AuthStrategyResolver import org.yapp.globalutils.annotation.UseCase import java.util.* @UseCase @Transactional(readOnly = true) class AuthUseCase( - private val socialAuthService: SocialAuthService, + private val authStrategyResolver: AuthStrategyResolver, private val userAuthService: UserAuthService, - private val tokenService: TokenService, - private val authTokenHelper: AuthTokenHelper + private val refreshTokenService: RefreshTokenService, + private val authTokenService: AuthTokenService, + private val appleAuthService: AppleAuthService ) { @Transactional fun signIn(socialLoginRequest: SocialLoginRequest): TokenPairResponse { val credentials = SocialLoginRequest.toCredentials(socialLoginRequest) - val strategy = socialAuthService.resolve(credentials) - + val strategy = authStrategyResolver.resolve(credentials) val userCreateInfoResponse = strategy.authenticate(credentials) - val findOrCreateUserRequest = FindOrCreateUserRequest.from(userCreateInfoResponse) - val createUserResponse = userAuthService.findOrCreateUser(findOrCreateUserRequest) - val generateTokenPairRequest = GenerateTokenPairRequest.from(createUserResponse) - return authTokenHelper.generateTokenPair(generateTokenPairRequest) + val createUserResponse = userAuthService.findOrCreateUser(FindOrCreateUserRequest.from(userCreateInfoResponse)) + + if (credentials is AppleAuthCredentials) { + appleAuthService.saveAppleRefreshTokenIfMissing( + SaveAppleRefreshTokenRequest.of( + createUserResponse, + credentials + ) + ) + } + + return authTokenService.generateTokenPair(GenerateTokenPairRequest.from(createUserResponse)) } @Transactional fun reissueTokenPair(tokenRefreshRequest: TokenRefreshRequest): TokenPairResponse { - val userIdResponse = authTokenHelper.validateAndGetUserIdFromRefreshToken(tokenRefreshRequest) - authTokenHelper.deleteTokenForReissue(tokenRefreshRequest) + val userIdResponse = authTokenService.validateAndGetUserIdFromRefreshToken(tokenRefreshRequest) + authTokenService.deleteTokenForReissue(tokenRefreshRequest) val findUserIdentityRequest = FindUserIdentityRequest.from(userIdResponse) val userAuthInfoResponse = userAuthService.findUserIdentityByUserId(findUserIdentityRequest) val generateTokenPairRequest = GenerateTokenPairRequest.from(userAuthInfoResponse) - return authTokenHelper.generateTokenPair(generateTokenPairRequest) + return authTokenService.generateTokenPair(generateTokenPairRequest) } @Transactional fun signOut(userId: UUID) { - val refreshTokenResponse = tokenService.getRefreshTokenByUserId(userId) + val refreshTokenResponse = refreshTokenService.getRefreshTokenByUserId(userId) val deleteTokenRequest = DeleteTokenRequest.from(refreshTokenResponse) - authTokenHelper.deleteTokenForSignOut(deleteTokenRequest) + authTokenService.deleteTokenForSignOut(deleteTokenRequest) } fun getUserProfile(userId: UUID): UserProfileResponse { diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/util/AppleKeyParser.kt b/apis/src/main/kotlin/org/yapp/apis/auth/util/AppleKeyParser.kt new file mode 100644 index 00000000..0c3eadba --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/util/AppleKeyParser.kt @@ -0,0 +1,133 @@ +package org.yapp.apis.auth.util + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.openssl.PEMKeyPair +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import java.io.StringReader +import java.security.KeyFactory +import java.security.KeyPair +import java.security.Security +import java.security.interfaces.ECPrivateKey + +/** + * Apple JWT 인증에 사용되는 .p8 키 파일 파서 + * + * Apple Developer Console에서 다운로드한 .p8 키 파일(PKCS#8 형식)을 + * JWT 서명 및 검증용 Java KeyPair 객체로 변환합니다. + * + * 지원하는 PEM 형식: + * - PKCS#8 (-----BEGIN PRIVATE KEY-----) + * - SEC1 (-----BEGIN EC PRIVATE KEY-----) + * - OpenSSL KeyPair 형식 + * + * @since 1.0 + */ +object AppleKeyParser { + + /** + * Apple .p8 키 파일을 파싱하여 KeyPair를 생성합니다. + * + * @param pemString PEM 형식의 키 문자열 + * @return EC KeyPair (P-256 기반) + * @throws IllegalArgumentException 키 파싱 실패 시 + */ + fun parseKeyPair(pemString: String): KeyPair { + ensureBouncyCastleProvider() + + return try { + parsePemKeyPair(pemString.trim()) + } catch (e: Exception) { + throw IllegalArgumentException("Failed to parse Apple .p8 key file: ${e.message}", e) + } + } + + /** + * BouncyCastle 보안 제공자가 등록되어 있는지 확인하고 필요시 등록합니다. + */ + private fun ensureBouncyCastleProvider() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(BouncyCastleProvider()) + } + } + + /** + * PEM 문자열을 파싱하여 형식에 맞는 KeyPair를 반환합니다. + * + * @param pemString 공백이 제거된 PEM 문자열 + * @return 파싱된 KeyPair + */ + private fun parsePemKeyPair(pemString: String): KeyPair { + PEMParser(StringReader(pemString)).use { pemParser -> + val pemObject = pemParser.readObject() + ?: throw IllegalArgumentException("No PEM objects found in the provided string") + + val converter = JcaPEMKeyConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME) + + return when (pemObject) { + is PEMKeyPair -> { + // OpenSSL 형식 - 개인키와 공개키가 함께 포함된 경우 + converter.getKeyPair(pemObject) + } + + is PrivateKeyInfo -> { + // PKCS#8 형식 - Apple .p8 파일의 표준 형식 + val privateKey = converter.getPrivateKey(pemObject) as ECPrivateKey + deriveKeyPairFromPrivateKey(privateKey) + } + + is KeyPair -> { + // 이미 KeyPair 객체인 경우 + pemObject + } + + else -> { + throw IllegalArgumentException( + "Unsupported PEM object type: ${pemObject::class.qualifiedName}. " + + "Expected PKCS#8 private key format." + ) + } + } + } + } + + /** + * EC 개인키로부터 BouncyCastle 표준 방식을 사용하여 안전하게 KeyPair를 유도합니다. + * + * @param privateKey EC 개인키 + * @return 공개키가 유도된 완전한 KeyPair + */ + private fun deriveKeyPairFromPrivateKey(privateKey: ECPrivateKey): KeyPair { + val keyFactory = KeyFactory.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) + val publicKey = generatePublicKeyFromPrivateKey(privateKey, keyFactory) + + return KeyPair(publicKey, privateKey) + } + + /** + * BouncyCastle EC 수학을 사용하여 개인키로부터 공개키를 생성합니다. + * + * @param privateKey EC 개인키 + * @param keyFactory BouncyCastle EC 키 팩토리 + * @return 유도된 공개키 + */ + private fun generatePublicKeyFromPrivateKey( + privateKey: ECPrivateKey, + keyFactory: KeyFactory + ): java.security.PublicKey { + + val bcPrivateKey = privateKey as org.bouncycastle.jce.interfaces.ECPrivateKey + val bcEcParams = bcPrivateKey.parameters + + val publicPoint = bcEcParams.g.multiply(bcPrivateKey.d) + publicPoint.normalize() + + require(!publicPoint.isInfinity) { + "Invalid private key: derived public key point is at infinity" + } + + val bcPublicKeySpec = org.bouncycastle.jce.spec.ECPublicKeySpec(publicPoint, bcEcParams) + return keyFactory.generatePublic(bcPublicKeySpec) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/util/AuthorExtractor.kt b/apis/src/main/kotlin/org/yapp/apis/auth/util/AuthorExtractor.kt similarity index 100% rename from apis/src/main/kotlin/org/yapp/apis/util/AuthorExtractor.kt rename to apis/src/main/kotlin/org/yapp/apis/auth/util/AuthorExtractor.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/util/IsbnConverter.kt b/apis/src/main/kotlin/org/yapp/apis/auth/util/IsbnConverter.kt similarity index 100% rename from apis/src/main/kotlin/org/yapp/apis/util/IsbnConverter.kt rename to apis/src/main/kotlin/org/yapp/apis/auth/util/IsbnConverter.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/util/NicknameGenerator.kt b/apis/src/main/kotlin/org/yapp/apis/auth/util/NicknameGenerator.kt similarity index 97% rename from apis/src/main/kotlin/org/yapp/apis/util/NicknameGenerator.kt rename to apis/src/main/kotlin/org/yapp/apis/auth/util/NicknameGenerator.kt index 6a5ff6a1..4441d80b 100644 --- a/apis/src/main/kotlin/org/yapp/apis/util/NicknameGenerator.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/util/NicknameGenerator.kt @@ -1,4 +1,4 @@ -package org.yapp.apis.util +package org.yapp.apis.auth.util object NicknameGenerator { @@ -23,4 +23,4 @@ object NicknameGenerator { val noun = nouns.random() return "$adj $noun" } -} \ No newline at end of file +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/exception/BookErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/book/exception/BookErrorCode.kt index 2034b7ff..7e956c57 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/exception/BookErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/exception/BookErrorCode.kt @@ -3,15 +3,15 @@ package org.yapp.apis.book.exception import org.springframework.http.HttpStatus import org.yapp.globalutils.exception.BaseErrorCode - enum class BookErrorCode( private val httpStatus: HttpStatus, private val code: String, private val message: String ) : BaseErrorCode { + /* 500 INTERNAL_SERVER_ERROR */ - ALADIN_API_SEARCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BOOK_001", "알라딘 도서 검색 API 호출에 실패했습니다."), - ALADIN_API_LOOKUP_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BOOK_002", "알라딘 도서 상세 조회 API 호출에 실패했습니다."); + ALADIN_API_SEARCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BOOK_500_01", "알라딘 도서 검색 API 호출에 실패했습니다."), + ALADIN_API_LOOKUP_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BOOK_500_02", "알라딘 도서 상세 조회 API 호출에 실패했습니다."); override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code diff --git a/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt index 46e23880..4530c3ef 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt @@ -4,13 +4,14 @@ import org.springframework.http.HttpStatus import org.yapp.globalutils.exception.BaseErrorCode enum class UserBookErrorCode( - private val status: HttpStatus, + private val httpStatus: HttpStatus, private val code: String, private val message: String ) : BaseErrorCode { - USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_001", "사용자의 책을 찾을 수 없습니다."); - override fun getHttpStatus(): HttpStatus = status + USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_404_01", "사용자의 책을 찾을 수 없습니다."); + + override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code override fun getMessage(): String = message } diff --git a/apis/src/main/kotlin/org/yapp/apis/config/AppleJwtConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/AppleJwtConfig.kt new file mode 100644 index 00000000..0da3bbef --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/AppleJwtConfig.kt @@ -0,0 +1,102 @@ +package org.yapp.apis.config + +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.proc.SecurityContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.core.* +import org.springframework.security.oauth2.jwt.* +import org.yapp.apis.auth.helper.apple.ApplePrivateKeyLoader +import java.security.interfaces.ECPublicKey + +@Configuration +class AppleJwtConfig( + private val applePrivateKeyLoader: ApplePrivateKeyLoader, + private val appleProperties: AppleOauthProperties +) { + companion object { + private const val APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys" + } + + /** + * Apple OAuth 인증을 위한 JWKSource를 생성합니다. + * + * - EC 키 쌍(KeyPair)을 기반으로 JWK(JSON Web Key)를 구성 + * - Nimbus 라이브러리의 JwtEncoder에서 Apple client_secret 생성 시 사용 + * + * @return Apple 인증용 JWKSource (ImmutableJWKSet 형태) + */ + @Bean + @Qualifier("appleJwkSource") + fun appleJwkSource(): JWKSource { + val keyPair = applePrivateKeyLoader.keyPair + val publicKey = keyPair.public as ECPublicKey + val privateKey = keyPair.private + + val ecKey = ECKey.Builder(Curve.P_256, publicKey) + .privateKey(privateKey) + .keyID(appleProperties.keyId) + .build() + + return ImmutableJWKSet(JWKSet(ecKey)) + } + + /** + * Apple 전용 JWK 소스를 사용하여 client_secret 생성에 사용될 JwtEncoder를 생성합니다. + * + * - NimbusJwtEncoder는 JWKSource 기반으로 JWT 서명을 처리 + * + * @param jwkSource Apple용 JWKSource Bean + * @return Apple client_secret 생성용 JwtEncoder 객체 + */ + @Bean + @Qualifier("appleJwtEncoder") + fun appleJwtEncoder( + @Qualifier("appleJwkSource") jwkSource: JWKSource + ): JwtEncoder { + return NimbusJwtEncoder(jwkSource) + } + + /** + * Apple에서 발급한 ID Token을 검증하기 위한 JwtDecoder를 생성합니다. + * + * - NimbusJwtDecoder를 사용하여 Apple 공개키(JWKS) 기반 검증 수행 + * - APPLE_JWKS_URI("https://appleid.apple.com/auth/keys")에서 공개키(JWK Set) 가져오기 + * + * 검증 로직 + * 1. 서명(Signature) 유효성 검증: Apple의 공개키와 토큰 서명이 일치하는지 확인 + * 2. Issuer 검증: 토큰의 iss 클레임이 예상 값인지 확인 + * 3. Audience 검증: 토큰의 aud 클레임이 애플리케이션의 clientId와 일치하는지 확인 + * + * @return Apple ID Token 검증용 JwtDecoder + */ + @Bean + @Qualifier("appleIdTokenDecoder") + fun appleIdTokenDecoder(): JwtDecoder { + val decoder = NimbusJwtDecoder.withJwkSetUri(APPLE_JWKS_URI).build() + + val issuerValidator: OAuth2TokenValidator = + JwtValidators.createDefaultWithIssuer(appleProperties.audience) + + val audienceValidator = OAuth2TokenValidator { token -> + if (token.audience.contains(appleProperties.clientId)) { + OAuth2TokenValidatorResult.success() + } else { + val error = OAuth2Error( + OAuth2ErrorCodes.INVALID_TOKEN, "The required audience is missing", null + ) + OAuth2TokenValidatorResult.failure(error) + } + } + + val combinedValidator = DelegatingOAuth2TokenValidator(issuerValidator, audienceValidator) + decoder.setJwtValidator(combinedValidator) + + return decoder + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/config/AppleOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/AppleOauthProperties.kt new file mode 100644 index 00000000..d761b860 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/AppleOauthProperties.kt @@ -0,0 +1,12 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.apple") +data class AppleOauthProperties( + val clientId: String, + val keyId: String, + val teamId: String, + val keyPath: String, + val audience: String +) diff --git a/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt new file mode 100644 index 00000000..060a5a87 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/AuthConfig.kt @@ -0,0 +1,8 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(AppleOauthProperties::class) +class AuthConfig diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/exception/ReadingRecordErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/exception/ReadingRecordErrorCode.kt index c1181e57..117bd431 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/exception/ReadingRecordErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/exception/ReadingRecordErrorCode.kt @@ -8,7 +8,7 @@ enum class ReadingRecordErrorCode( private val code: String, private val message: String ) : BaseErrorCode { - READING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "READING_RECORD_001", "독서 기록을 찾을 수 없습니다."); + READING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "READING_RECORD_404_01", "독서 기록을 찾을 수 없습니다."); override fun getHttpStatus(): HttpStatus = status override fun getCode(): String = code diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index aaf88d7c..1be2c434 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -73,3 +73,16 @@ springdoc: enabled: false api-docs: enabled: false + +aladin: + api: + ttb-key: dummy-aladin-key + +oauth: + apple: + client-id: dummy.client.id + key-id: DUMMYKEYID + team-id: DUMMYTEAMID + key-path: "path-ignored-by-mock" + audience: https://appleid.apple.com + diff --git a/apis/src/main/resources/static/kakao-login.html b/apis/src/main/resources/static/kakao-login.html index b3edd49b..1c63c4d9 100644 --- a/apis/src/main/resources/static/kakao-login.html +++ b/apis/src/main/resources/static/kakao-login.html @@ -121,7 +121,10 @@

소셜 로그인 테스트

fetch(`${API_SERVER}/api/v1/auth/signin`, { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({providerType: "KAKAO", oauthToken: accessToken}) + body: JSON.stringify({ + providerType: "KAKAO", + oauthToken: accessToken + }) }) .then(res => res.json()) .then(data => { @@ -137,36 +140,104 @@

소셜 로그인 테스트

}); }); - // Apple 로그인 + // Apple 로그인 - popup 모드 document.getElementById('apple-login-btn').addEventListener('click', () => { AppleID.auth.init({ clientId: '', scope: 'name email', - redirectURI: 'http://localhost:8080/kakao-login.html', + redirectURI: `${window.location.origin}/kakao-login.html`, + state: 'apple-login-' + Date.now(), + responseType: 'code', // authorization code만 요청 (popup 모드에서 안정적) + responseMode: 'query', // URL query parameter로 응답 받기 usePopup: true }); + // Apple Sign In 실행 AppleID.auth.signIn() .then(response => { - // id_token을 서버로 전송 - const id_token = response.authorization.id_token; - fetch(`${API_SERVER}/api/v1/auth/signin`, { + console.log('Apple 로그인 성공:', response); + + const authorizationCode = response.authorization?.code; + + if (!authorizationCode) { + throw new Error('Authorization code not received from Apple'); + } + + const idToken = response.authorization?.id_token || 'popup-mode-token'; + + const requestData = { + providerType: "APPLE", + oauthToken: idToken, + authorizationCode: authorizationCode + }; + + console.log('서버로 전송할 데이터:', requestData); + + return fetch(`${API_SERVER}/api/v1/auth/signin`, { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({providerType: "APPLE", idToken: id_token}) - }) - .then(res => res.json()) - .then(data => { - document.getElementById('result').textContent = 'Apple 로그인 성공\n\nJWT: ' + data.accessToken; - }) - .catch(err => { - document.getElementById('result').textContent = 'Apple 로그인 실패: ' + err.message; - }); + body: JSON.stringify(requestData) + }); + }) + .then(async response => { + const data = await response.json(); + if (!response.ok) { + throw new Error(`서버 에러 (${response.status}): ${data.message || 'Unknown error'}`); + } + return data; + }) + .then(data => { + document.getElementById('result').textContent = + 'Apple 로그인 성공!\n\n' + + 'JWT: ' + data.accessToken + '\n\n' + + '사용자 정보: ' + JSON.stringify(data.user || {}, null, 2); }) .catch(error => { - document.getElementById('result').textContent = 'Apple 로그인 실패: ' + JSON.stringify(error); + console.error('Apple 로그인 에러:', error); + document.getElementById('result').textContent = 'Apple 로그인 실패: ' + error.message; }); }); + + // Apple 콜백 처리 (페이지 로드 시 URL 파라미터 확인) + window.addEventListener('load', () => { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + + if (code && state && state.startsWith('apple-login-')) { + console.log('Apple 콜백 감지됨:', { code, state }); + + const requestData = { + providerType: "APPLE", + oauthToken: 'callback-id-token', + authorizationCode: code + }; + + fetch(`${API_SERVER}/api/v1/auth/signin`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(requestData) + }) + .then(async response => { + const data = await response.json(); + if (!response.ok) { + throw new Error(`서버 에러 (${response.status}): ${data.message || 'Unknown error'}`); + } + return data; + }) + .then(data => { + document.getElementById('result').textContent = + 'Apple 로그인 성공 (콜백)!\n\n' + + 'JWT: ' + data.accessToken; + + history.replaceState({}, document.title, window.location.pathname); + }) + .catch(error => { + console.error('Apple 콜백 처리 에러:', error); + document.getElementById('result').textContent = 'Apple 로그인 실패 (콜백): ' + error.message; + }); + } + }); diff --git a/apis/src/test/kotlin/org/yapp/apis/ApisApplicationTests.kt b/apis/src/test/kotlin/org/yapp/apis/ApisApplicationTests.kt index e4ed9bb7..1501662c 100644 --- a/apis/src/test/kotlin/org/yapp/apis/ApisApplicationTests.kt +++ b/apis/src/test/kotlin/org/yapp/apis/ApisApplicationTests.kt @@ -6,7 +6,7 @@ import org.springframework.test.context.ActiveProfiles @SpringBootTest @ActiveProfiles("test") -class ApisApplicationTests { +class ApisApplicationTests : BaseIntegrationTest() { @Test fun contextLoads() { diff --git a/apis/src/test/kotlin/org/yapp/apis/BaseIntegrationTest.kt b/apis/src/test/kotlin/org/yapp/apis/BaseIntegrationTest.kt new file mode 100644 index 00000000..debb32a8 --- /dev/null +++ b/apis/src/test/kotlin/org/yapp/apis/BaseIntegrationTest.kt @@ -0,0 +1,9 @@ +package org.yapp.apis + +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.yapp.apis.config.MockTestConfiguration + +@SpringBootTest +@Import(MockTestConfiguration::class) +abstract class BaseIntegrationTest diff --git a/apis/src/test/kotlin/org/yapp/apis/config/MockTestConfiguration.kt b/apis/src/test/kotlin/org/yapp/apis/config/MockTestConfiguration.kt new file mode 100644 index 00000000..07927f1b --- /dev/null +++ b/apis/src/test/kotlin/org/yapp/apis/config/MockTestConfiguration.kt @@ -0,0 +1,25 @@ +package org.yapp.apis.config + +import org.mockito.Mockito +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.yapp.apis.auth.helper.apple.ApplePrivateKeyLoader +import java.security.KeyPairGenerator + +@TestConfiguration +class MockTestConfiguration { + + @Bean + @Primary + fun mockApplePrivateKeyLoader(): ApplePrivateKeyLoader { + val mockLoader = Mockito.mock(ApplePrivateKeyLoader::class.java) + + val keyPairGenerator = KeyPairGenerator.getInstance("EC") + keyPairGenerator.initialize(256) + val keyPair = keyPairGenerator.generateKeyPair() + Mockito.`when`(mockLoader.keyPair).thenReturn(keyPair) + + return mockLoader + } +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index ad06fb0e..b4d5270f 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -8,9 +8,10 @@ object Dependencies { const val BOOT_STARTER_ACTUATOR = "org.springframework.boot:spring-boot-starter-actuator" const val BOOT_STARTER_TEST = "org.springframework.boot:spring-boot-starter-test" const val BOOT_STARTER_DATA_REDIS = "org.springframework.boot:spring-boot-starter-data-redis" - const val BOOT_STARTER_OAUTH2_RESOURCE_SERVER = - "org.springframework.boot:spring-boot-starter-oauth2-resource-server" + const val BOOT_STARTER_OAUTH2_RESOURCE_SERVER = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" + const val BOOT_STARTER_OAUTH2_CLIENT = "org.springframework.boot:spring-boot-starter-oauth2-client" const val KOTLIN_REFLECT = "org.jetbrains.kotlin:kotlin-reflect" + const val CONFIGURATION_PROCESSOR = "org.springframework.boot:spring-boot-configuration-processor" } object Database { @@ -57,4 +58,9 @@ object Dependencies { const val JPA = "com.querydsl:querydsl-jpa:5.0.0:jakarta" const val APT = "com.querydsl:querydsl-apt:5.0.0:jakarta" } + + object BouncyCastle { + const val BC_PROV = "org.bouncycastle:bcprov-jdk18on:1.78.1" + const val BC_PKIX = "org.bouncycastle:bcpkix-jdk18on:1.78.1" + } } diff --git a/domain/src/main/kotlin/org/yapp/domain/book/exception/BookErrorCode.kt b/domain/src/main/kotlin/org/yapp/domain/book/exception/BookErrorCode.kt index b5302256..912b52e6 100644 --- a/domain/src/main/kotlin/org/yapp/domain/book/exception/BookErrorCode.kt +++ b/domain/src/main/kotlin/org/yapp/domain/book/exception/BookErrorCode.kt @@ -4,14 +4,15 @@ import org.springframework.http.HttpStatus import org.yapp.globalutils.exception.BaseErrorCode enum class BookErrorCode( - private val status: HttpStatus, + private val httpStatus: HttpStatus, private val code: String, private val message: String ) : BaseErrorCode { - BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOK_001", "도서 정보를 찾을 수 없습니다."), - BOOK_ALREADY_EXISTS(HttpStatus.CONFLICT, "BOOK_002", "이미 존재하는 도서입니다."); - override fun getHttpStatus(): HttpStatus = status + BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOK_404_01", "도서 정보를 찾을 수 없습니다."), + BOOK_ALREADY_EXISTS(HttpStatus.CONFLICT, "BOOK_409_01", "이미 존재하는 도서입니다."); + + override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code override fun getMessage(): String = message } diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/exception/ReadingRecordErrorCode.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/exception/ReadingRecordErrorCode.kt index 9ec94af4..8170d8f2 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/exception/ReadingRecordErrorCode.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/exception/ReadingRecordErrorCode.kt @@ -8,7 +8,7 @@ enum class ReadingRecordErrorCode( private val code: String, private val message: String ) : BaseErrorCode { - READING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "READING_RECORD_001", "독서 기록을 찾을 수 없습니다."); + READING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "READING_RECORD_404_01", "독서 기록을 찾을 수 없습니다."); override fun getHttpStatus(): HttpStatus = status override fun getCode(): String = code diff --git a/domain/src/main/kotlin/org/yapp/domain/token/exception/TokenErrorCode.kt b/domain/src/main/kotlin/org/yapp/domain/token/exception/TokenErrorCode.kt index 934ec4d0..cbdd7524 100644 --- a/domain/src/main/kotlin/org/yapp/domain/token/exception/TokenErrorCode.kt +++ b/domain/src/main/kotlin/org/yapp/domain/token/exception/TokenErrorCode.kt @@ -4,15 +4,16 @@ import org.springframework.http.HttpStatus import org.yapp.globalutils.exception.BaseErrorCode enum class TokenErrorCode( - private val status: HttpStatus, + private val httpStatus: HttpStatus, private val code: String, private val message: String ) : BaseErrorCode { - TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "TOKEN_001", "토큰 정보를 찾을 수 없습니다."), - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN_002", "유효하지 않은 리프레시 토큰입니다."), - EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN_003", "리프레시 토큰이 만료되었습니다."); - override fun getHttpStatus(): HttpStatus = status + TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "TOKEN_404_01", "토큰 정보를 찾을 수 없습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN_401_01", "유효하지 않은 리프레시 토큰입니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN_401_02", "리프레시 토큰이 만료되었습니다."); + + override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code override fun getMessage(): String = message } diff --git a/domain/src/main/kotlin/org/yapp/domain/user/User.kt b/domain/src/main/kotlin/org/yapp/domain/user/User.kt index 6b342a74..47c67e58 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/User.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/User.kt @@ -6,20 +6,6 @@ import org.yapp.globalutils.validator.EmailValidator import java.time.LocalDateTime import java.util.* -/** - * User domain model. - * - * @property id The unique identifier of the user. - * @property email The email of the user. - * @property nickname The nickname of the user. - * @property profileImageUrl The URL of the user's profile image. - * @property providerType The type of authentication provider. - * @property providerId The ID from the authentication provider. - * @property role The roles of the user (e.g., USER, ADMIN). - * @property createdAt The timestamp when the user was created. - * @property updatedAt The timestamp when the user was last updated. - * @property deletedAt The timestamp when the user was soft-deleted, or null if the user is not deleted. - */ data class User private constructor( val id: Id, val email: Email, @@ -29,6 +15,7 @@ data class User private constructor( val providerId: ProviderId, val role: Role, val termsAgreed: Boolean = false, + val appleRefreshToken: String? = null, val createdAt: LocalDateTime? = null, val updatedAt: LocalDateTime? = null, val deletedAt: LocalDateTime? = null @@ -47,6 +34,12 @@ data class User private constructor( ) } + fun updateAppleRefreshToken(token: String): User { + return this.copy( + appleRefreshToken = token + ) + } + companion object { fun create( email: String, @@ -64,7 +57,8 @@ data class User private constructor( providerType = providerType, providerId = ProviderId.newInstance(providerId), role = Role.USER, - termsAgreed = termsAgreed + termsAgreed = termsAgreed, + appleRefreshToken = null ) } @@ -86,7 +80,8 @@ data class User private constructor( providerType = providerType, providerId = ProviderId.newInstance(providerId), role = role, - termsAgreed = termsAgreed + termsAgreed = termsAgreed, + appleRefreshToken = null ) } @@ -99,6 +94,7 @@ data class User private constructor( providerId: ProviderId, role: Role, termsAgreed: Boolean = false, + appleRefreshToken: String? = null, createdAt: LocalDateTime? = null, updatedAt: LocalDateTime? = null, deletedAt: LocalDateTime? = null @@ -112,6 +108,7 @@ data class User private constructor( providerId = providerId, role = role, termsAgreed = termsAgreed, + appleRefreshToken = appleRefreshToken, createdAt = createdAt, updatedAt = updatedAt, deletedAt = deletedAt diff --git a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt index 34a66c48..e0fe64f3 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt @@ -2,16 +2,16 @@ package org.yapp.domain.user import org.yapp.domain.user.exception.UserErrorCode import org.yapp.domain.user.exception.UserNotFoundException +import org.yapp.domain.user.vo.UserAuthVO import org.yapp.domain.user.vo.UserIdentityVO import org.yapp.domain.user.vo.UserProfileVO import org.yapp.globalutils.annotation.DomainService -import org.yapp.globalutils.util.TimeProvider -import java.util.UUID + +import java.util.* @DomainService class UserDomainService( private val userRepository: UserRepository, - private val timeProvider: TimeProvider ) { fun findUserProfileById(id: UUID): UserProfileVO { val user = userRepository.findById(id) ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND) @@ -23,17 +23,17 @@ class UserDomainService( return UserIdentityVO.newInstance(user) } - fun findUserByProviderTypeAndProviderId(providerType: ProviderType, providerId: String): UserIdentityVO? { + fun findUserByProviderTypeAndProviderId(providerType: ProviderType, providerId: String): UserAuthVO? { return userRepository.findByProviderTypeAndProviderId(providerType, providerId) - ?.let { UserIdentityVO.newInstance(it) } + ?.let { UserAuthVO.newInstance(it) } } fun findUserByProviderTypeAndProviderIdIncludingDeleted( providerType: ProviderType, providerId: String - ): UserIdentityVO? { + ): UserAuthVO? { return userRepository.findByProviderTypeAndProviderIdIncludingDeleted(providerType, providerId) - ?.let { UserIdentityVO.newInstance(it) } + ?.let { UserAuthVO.newInstance(it) } } fun existsActiveUserByIdAndDeletedAtIsNull(id: UUID): Boolean { @@ -44,13 +44,13 @@ class UserDomainService( return userRepository.existsByEmail(email) } - fun createNewUser( + fun createUser( email: String, nickname: String, profileImageUrl: String?, providerType: ProviderType, providerId: String - ): UserIdentityVO { + ): UserAuthVO { val user = User.create( email = email, nickname = nickname, @@ -59,15 +59,15 @@ class UserDomainService( providerId = providerId ) val savedUser = userRepository.save(user) - return UserIdentityVO.newInstance(savedUser) + return UserAuthVO.newInstance(savedUser) } - fun restoreDeletedUser(userId: UUID): UserIdentityVO { + fun restoreDeletedUser(userId: UUID): UserAuthVO { val deletedUser = userRepository.findById(userId) ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND) val restoredUser = userRepository.save(deletedUser.restore()) - return UserIdentityVO.newInstance(restoredUser) + return UserAuthVO.newInstance(restoredUser) } fun updateTermsAgreement(userId: UUID, termsAgreed: Boolean): UserProfileVO { @@ -77,4 +77,13 @@ class UserDomainService( val updatedUser = userRepository.save(user.updateTermsAgreement(termsAgreed)) return UserProfileVO.newInstance(updatedUser) } + + fun updateAppleRefreshToken(userId: UUID, refreshToken: String): UserIdentityVO { + val user = userRepository.findById(userId) + ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND) + + val updatedUser = userRepository.save(user.updateAppleRefreshToken(refreshToken)) + + return UserIdentityVO.newInstance(updatedUser) + } } diff --git a/domain/src/main/kotlin/org/yapp/domain/user/exception/UserErrorCode.kt b/domain/src/main/kotlin/org/yapp/domain/user/exception/UserErrorCode.kt index 3ee966e6..424b6967 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/exception/UserErrorCode.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/exception/UserErrorCode.kt @@ -4,13 +4,14 @@ import org.springframework.http.HttpStatus import org.yapp.globalutils.exception.BaseErrorCode enum class UserErrorCode( - private val status: HttpStatus, + private val httpStatus: HttpStatus, private val code: String, private val message: String ) : BaseErrorCode { - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "유저 정보를 찾을 수 없습니다."); - override fun getHttpStatus(): HttpStatus = status + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "유저 정보를 찾을 수 없습니다."); + + override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code override fun getMessage(): String = message } diff --git a/domain/src/main/kotlin/org/yapp/domain/user/vo/UserAuthVO.kt b/domain/src/main/kotlin/org/yapp/domain/user/vo/UserAuthVO.kt new file mode 100644 index 00000000..9d8c6675 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/user/vo/UserAuthVO.kt @@ -0,0 +1,20 @@ +package org.yapp.domain.user.vo + +import org.yapp.domain.user.User +import org.yapp.globalutils.auth.Role + +data class UserAuthVO( + val id: User.Id, + val role: Role, + val appleRefreshToken: String? +) { + companion object { + fun newInstance(user: User): UserAuthVO { + return UserAuthVO( + id = user.id, + role = user.role, + appleRefreshToken = user.appleRefreshToken + ) + } + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/exception/UserBookErrorCode.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/exception/UserBookErrorCode.kt index bfadebdd..72af880d 100644 --- a/domain/src/main/kotlin/org/yapp/domain/userbook/exception/UserBookErrorCode.kt +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/exception/UserBookErrorCode.kt @@ -8,7 +8,7 @@ enum class UserBookErrorCode( private val code: String, private val message: String ) : BaseErrorCode { - USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_001", "유저의 책 정보를 찾을 수 없습니다."); + USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_404_01", "유저의 책 정보를 찾을 수 없습니다."); override fun getHttpStatus(): HttpStatus = status override fun getCode(): String = code diff --git a/gateway/src/main/kotlin/org/yapp/gateway/config/JwtConfig.kt b/gateway/src/main/kotlin/org/yapp/gateway/config/JwtConfig.kt index 769e01d0..863d7ebd 100644 --- a/gateway/src/main/kotlin/org/yapp/gateway/config/JwtConfig.kt +++ b/gateway/src/main/kotlin/org/yapp/gateway/config/JwtConfig.kt @@ -8,6 +8,7 @@ import com.nimbusds.jose.proc.SecurityContext import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary import org.springframework.core.convert.converter.Converter import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -49,6 +50,7 @@ class JwtConfig( * @return 생성된 `JwtEncoder` 객체 */ @Bean + @Primary fun jwtEncoder(jwkSource: JWKSource): JwtEncoder { return NimbusJwtEncoder(jwkSource) } @@ -60,6 +62,7 @@ class JwtConfig( * @return 생성된 `JwtDecoder` 객체 */ @Bean + @Primary fun jwtDecoder(): JwtDecoder { val secretKeySpec = SecretKeySpec(secretKey.toByteArray(), SIGNATURE_ALGORITHM.name) val decoder = NimbusJwtDecoder.withSecretKey(secretKeySpec).build() diff --git a/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt b/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt index 69be2a5e..eb140e68 100644 --- a/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt +++ b/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt @@ -10,9 +10,6 @@ import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.web.SecurityFilterChain -/** - * Security configuration for the gateway. - */ @Configuration @EnableWebSecurity class SecurityConfig( diff --git a/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt b/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt index cd684b3f..0a5ec36b 100644 --- a/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt +++ b/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt @@ -11,16 +11,16 @@ enum class CommonErrorCode( private val message: String ) : BaseErrorCode { - CONFLICT(HttpStatus.CONFLICT, "COMMON_001", "Duplicated Value"), - REQUEST_PARAMETER_BIND_FAILED(HttpStatus.BAD_REQUEST, "COMMON_002", "PARAMETER_BIND_FAILED"), - BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_003", "BAD REQUEST"), - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_004", "INVALID REQUEST"), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_005", "INTERNAL SERVER ERROR"), - MALFORMED_JSON(HttpStatus.BAD_REQUEST, "COMMON_006", "MALFORMED JSON"), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_007", "METHOD NOT ALLOWED"), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_008", "UNAUTHORIZED"), - FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_009", "FORBIDDEN"), - NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_010", "NOT FOUND"), + CONFLICT(HttpStatus.CONFLICT, "COMMON_409_01", "Duplicated Value"), + REQUEST_PARAMETER_BIND_FAILED(HttpStatus.BAD_REQUEST, "COMMON_400_01", "PARAMETER_BIND_FAILED"), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400_02", "BAD REQUEST"), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400_03", "INVALID REQUEST"), + MALFORMED_JSON(HttpStatus.BAD_REQUEST, "COMMON_400_04", "MALFORMED JSON"), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401_01", "UNAUTHORIZED"), + FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403_01", "FORBIDDEN"), + NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404_01", "NOT FOUND"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_405_01", "METHOD NOT ALLOWED"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500_01", "INTERNAL SERVER ERROR"), ; diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt index f58fe191..1d8eca40 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt @@ -10,8 +10,8 @@ import org.yapp.infra.external.aladin.response.AladinSearchResponse @Component class AladinApi( private val aladinRestClient: AladinRestClient, - @Value("\${aladin.api.ttbkey:#{null}}") - private var ttbKey: String? = null + @Value("\${aladin.api.ttb-key}") + private var ttbKey: String ) { fun searchBooks(request: AladinBookSearchRequest): Result { return runCatching { diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt index ffb93338..0b1d7b1c 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt @@ -16,7 +16,7 @@ class AladinRestClient( private val DEFAULT_OUTPUT_FORMAT = "JS" fun itemSearch( - ttbKey: String?, + ttbKey: String, params: Map ): AladinSearchResponse { val uriBuilder = UriComponentsBuilder.fromUriString("/ItemSearch.aspx") @@ -32,7 +32,7 @@ class AladinRestClient( } fun itemLookUp( - ttbKey: String?, + ttbKey: String, params: Map = emptyMap() ): AladinBookDetailResponse { val uriBuilder = UriComponentsBuilder.fromUriString("/ItemLookUp.aspx") diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/AppleApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/AppleApi.kt new file mode 100644 index 00000000..7a8dfc40 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/AppleApi.kt @@ -0,0 +1,56 @@ +package org.yapp.infra.external.oauth.apple + +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.yapp.infra.external.oauth.apple.response.AppleTokenResponse + +@Component +class AppleApi( + private val appleRestClient: AppleRestClient +) { + companion object { + private const val CLIENT_ID = "client_id" + private const val CLIENT_SECRET = "client_secret" + private const val CODE = "code" + private const val GRANT_TYPE = "grant_type" + private const val TOKEN = "token" + private const val TOKEN_TYPE_HINT = "token_type_hint" + private const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" + } + + fun getOauthTokens( + clientId: String, + clientSecret: String, + code: String, + grantType: String = GRANT_TYPE_AUTHORIZATION_CODE + ): Result { + val requestBody = LinkedMultiValueMap().apply { + add(CLIENT_ID, clientId) + add(CLIENT_SECRET, clientSecret) + add(CODE, code) + add(GRANT_TYPE, grantType) + } + + return runCatching { + appleRestClient.getTokens(requestBody) + } + } + + fun revokeToken( + clientId: String, + clientSecret: String, + token: String, + tokenTypeHint: String + ): Result { + val requestBody = LinkedMultiValueMap().apply { + add(CLIENT_ID, clientId) + add(CLIENT_SECRET, clientSecret) + add(TOKEN, token) + add(TOKEN_TYPE_HINT, tokenTypeHint) + } + + return runCatching { + appleRestClient.revoke(requestBody) + } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/AppleRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/AppleRestClient.kt new file mode 100644 index 00000000..ea5ad735 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/AppleRestClient.kt @@ -0,0 +1,40 @@ +package org.yapp.infra.external.oauth.apple + +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClient +import org.yapp.infra.external.oauth.apple.response.AppleTokenResponse + +@Component +class AppleRestClient( + builder: RestClient.Builder +) { + companion object { + private const val BASE_URL = "https://appleid.apple.com/auth" + private const val CONTENT_TYPE = "application/x-www-form-urlencoded" + } + + private val client = builder + .baseUrl(BASE_URL) + .build() + + fun getTokens(requestBody: MultiValueMap): AppleTokenResponse { + return client.post() + .uri("/token") + .contentType(MediaType.valueOf(CONTENT_TYPE)) + .body(requestBody) + .retrieve() + .body(AppleTokenResponse::class.java) + ?: throw IllegalStateException("Apple token response body is null") + } + + fun revoke(requestBody: MultiValueMap) { + client.post() + .uri("/revoke") + .contentType(MediaType.valueOf(CONTENT_TYPE)) + .body(requestBody) + .retrieve() + .toBodilessEntity() + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/response/AppleTokenResponse.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/response/AppleTokenResponse.kt new file mode 100644 index 00000000..0de3714f --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/apple/response/AppleTokenResponse.kt @@ -0,0 +1,13 @@ +package org.yapp.infra.external.oauth.apple.response + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class AppleTokenResponse( + val accessToken: String, + val expiresIn: Int, + val idToken: String, + val refreshToken: String?, + val tokenType: String +) diff --git a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt index 64546072..34f86f90 100644 --- a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt @@ -4,10 +4,10 @@ import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete import org.hibernate.annotations.SQLRestriction -import org.yapp.infra.common.BaseTimeEntity import org.yapp.domain.user.ProviderType import org.yapp.domain.user.User import org.yapp.globalutils.auth.Role +import org.yapp.infra.common.BaseTimeEntity import java.sql.Types import java.util.* @@ -37,7 +37,9 @@ class UserEntity private constructor( role: Role, - termsAgreed: Boolean = false + termsAgreed: Boolean = false, + + appleRefreshToken: String? = null ) : BaseTimeEntity() { @Column(nullable = false, length = 100) @@ -57,6 +59,10 @@ class UserEntity private constructor( var termsAgreed: Boolean = termsAgreed protected set + @Column(name = "apple_refresh_token", length = 1024) + var appleRefreshToken: String? = appleRefreshToken + protected set + fun toDomain(): User = User.reconstruct( id = User.Id.newInstance(this.id), email = User.Email.newInstance(this.email), @@ -66,6 +72,7 @@ class UserEntity private constructor( providerId = User.ProviderId.newInstance(this.providerId), role = role, termsAgreed = termsAgreed, + appleRefreshToken = appleRefreshToken, createdAt = createdAt, updatedAt = updatedAt, deletedAt = deletedAt @@ -80,7 +87,8 @@ class UserEntity private constructor( providerType = user.providerType, providerId = user.providerId.value, role = user.role, - termsAgreed = user.termsAgreed + termsAgreed = user.termsAgreed, + appleRefreshToken = user.appleRefreshToken ) } diff --git a/infra/src/main/resources/application-external.yml b/infra/src/main/resources/application-external.yml index 63c84a30..91ac5a41 100644 --- a/infra/src/main/resources/application-external.yml +++ b/infra/src/main/resources/application-external.yml @@ -1,3 +1,13 @@ aladin: api: - ttbkey: ${ALADIN_API_KEY} + ttb-key: ${ALADIN_API_KEY} + +oauth: + kakao: + admin-key: ${KAKAO_ADMIN_KEY} + apple: + client-id: ${APPLE_CLIENT_ID} + key-id: ${APPLE_KEY_ID} + team-id: ${APPLE_TEAM_ID} + key-path: ${APPLE_KEY_PATH} + audience: https://appleid.apple.com