Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apis/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ dependencies {
implementation(Dependencies.BouncyCastle.BC_PROV)
implementation(Dependencies.BouncyCastle.BC_PKIX)

implementation(Dependencies.Google.API_CLIENT)
implementation(Dependencies.Google.HTTP_CLIENT_APACHE)
implementation(Dependencies.Google.HTTP_CLIENT_GSON)

kapt(Dependencies.Spring.CONFIGURATION_PROCESSOR)

testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank
import org.yapp.apis.auth.exception.AuthErrorCode
import org.yapp.apis.auth.exception.AuthException
import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials
import org.yapp.apis.auth.strategy.signin.GoogleAuthCredentials
import org.yapp.apis.auth.strategy.signin.KakaoAuthCredentials
import org.yapp.apis.auth.strategy.signin.SignInCredentials
import org.yapp.domain.user.ProviderType
Expand Down Expand Up @@ -61,6 +62,8 @@ data class SocialLoginRequest private constructor(
)
AppleAuthCredentials(request.validOauthToken(), authCode)
}

ProviderType.GOOGLE -> GoogleAuthCredentials(request.validOauthToken())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ enum class AuthErrorCode(
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 토큰입니다."),
INVALID_GOOGLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_13", "유효하지 않은 Google ID 토큰입니다."),
PROVIDER_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH_400_11", "요청된 공급자 타입이 실제 사용자의 공급자 타입과 일치하지 않습니다."),
APPLE_REFRESH_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH_400_12", "Apple 사용자 탈퇴 시 리프레시 토큰이 누락되었습니다."),
KAKAO_UNLINK_FAILED(HttpStatus.BAD_REQUEST, "AUTH_400_15", "카카오 회원탈퇴 처리에 실패했습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.yapp.apis.auth.helper.google

import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
import com.google.api.client.http.apache.v2.ApacheHttpTransport
import com.google.api.client.json.gson.GsonFactory
import org.yapp.apis.auth.exception.AuthErrorCode
import org.yapp.apis.auth.exception.AuthException
import org.yapp.apis.config.GoogleOauthProperties
import org.yapp.globalutils.annotation.Helper
import java.util.Collections

@Helper
class GoogleIdTokenProcessor(
private val googleOauthProperties: GoogleOauthProperties,
) {
private val verifier: GoogleIdTokenVerifier = GoogleIdTokenVerifier.Builder(
ApacheHttpTransport(),
GsonFactory.getDefaultInstance()
)
.setAudience(Collections.singletonList(googleOauthProperties.clientId))
.build()

fun parseAndValidate(idToken: String): GoogleIdToken.Payload {
try {
val googleIdToken = verifier.verify(idToken)
?: throw AuthException(AuthErrorCode.INVALID_GOOGLE_ID_TOKEN, "Invalid ID token")

return googleIdToken.payload
} catch (e: Exception) {
throw AuthException(AuthErrorCode.INVALID_GOOGLE_ID_TOKEN, e.message)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.yapp.apis.auth.manager

import mu.KotlinLogging
import org.springframework.stereotype.Component
import org.springframework.web.client.HttpClientErrorException
import org.yapp.apis.auth.exception.AuthErrorCode
import org.yapp.apis.auth.exception.AuthException
import org.yapp.apis.config.GoogleOauthProperties
import org.yapp.infra.external.oauth.google.GoogleApi
import org.yapp.infra.external.oauth.google.response.GoogleUserInfo

@Component
class GoogleApiManager(
private val googleApi: GoogleApi,
private val googleOauthProperties: GoogleOauthProperties,
) {
private val log = KotlinLogging.logger {}

fun getUserInfo(accessToken: String): GoogleUserInfo {
return googleApi.fetchUserInfo(accessToken, googleOauthProperties.url.userInfo)
.onSuccess { userInfo ->
log.info { "Successfully fetched Google user info for userId: ${userInfo.id}" }
}
.getOrElse { exception ->
log.error(exception) { "Failed to fetch Google user info" }

when (exception) {
is HttpClientErrorException -> throw AuthException(
AuthErrorCode.INVALID_OAUTH_TOKEN,
"Invalid Google Access Token.",
)

else -> throw AuthException(
AuthErrorCode.OAUTH_SERVER_ERROR,
"Failed to communicate with Google server.",
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.yapp.apis.auth.strategy.signin

import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken
import mu.KotlinLogging
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.google.GoogleIdTokenProcessor
import org.yapp.apis.auth.util.NicknameGenerator
import org.yapp.domain.user.ProviderType

@Component
class GoogleSignInStrategy(
private val googleIdTokenProcessor: GoogleIdTokenProcessor,
) : SignInStrategy {

private val log = KotlinLogging.logger {}

override fun getProviderType(): ProviderType = ProviderType.GOOGLE

override fun authenticate(credentials: SignInCredentials): UserCreateInfoResponse {
return try {
val googleCredentials = validateCredentials(credentials)
val googleUserPayload = googleIdTokenProcessor.parseAndValidate(googleCredentials.idToken)
createUserInfo(googleUserPayload)
} catch (exception: Exception) {
log.error("Google authentication failed", exception)
when (exception) {
is AuthException -> throw exception
else -> throw AuthException(AuthErrorCode.FAILED_TO_GET_USER_INFO, exception.message)
}
}
}

private fun validateCredentials(credentials: SignInCredentials): GoogleAuthCredentials {
return credentials as? GoogleAuthCredentials
?: throw AuthException(
AuthErrorCode.INVALID_CREDENTIALS,
"Credentials must be GoogleAuthCredentials"
)
}

private fun createUserInfo(googleUser: GoogleIdToken.Payload): UserCreateInfoResponse {
return UserCreateInfoResponse.of(
email = googleUser.email ?: ("google_${googleUser.subject}@google.com"),
nickname = NicknameGenerator.generate(),
profileImageUrl = googleUser["picture"] as? String,
providerType = ProviderType.GOOGLE,
providerId = googleUser.subject
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,23 @@ sealed class SignInCredentials {
}

data class KakaoAuthCredentials(
val accessToken: String
val accessToken: String,
) : SignInCredentials() {
override fun getProviderType(): ProviderType = ProviderType.KAKAO
}

data class AppleAuthCredentials(
val idToken: String,
val authorizationCode: String
val authorizationCode: String,
) : SignInCredentials() {
override fun getProviderType(): ProviderType = ProviderType.APPLE
}

data class GoogleAuthCredentials(
val idToken: String,
) : SignInCredentials() {
override fun getProviderType(): ProviderType {
return ProviderType.GOOGLE
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.yapp.apis.auth.strategy.withdraw

import jakarta.validation.Valid
import mu.KotlinLogging
import org.springframework.stereotype.Component
import org.springframework.validation.annotation.Validated
import org.yapp.apis.auth.dto.request.WithdrawStrategyRequest
import org.yapp.domain.user.ProviderType

@Component
@Validated
class GoogleWithdrawStrategy : WithdrawStrategy {
private val log = KotlinLogging.logger {}

override fun getProviderType() = ProviderType.GOOGLE

override fun withdraw(@Valid request: WithdrawStrategyRequest) {
log.info("Starting Google withdrawal for user: ${request.userId}, providerId: ${request.providerId}")
log.info("Successfully processed Google withdrawal for user: ${request.userId}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ enum class UserBookErrorCode(
private val code: String,
private val message: String
) : BaseErrorCode {

USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_404_01", "사용자의 책을 찾을 수 없습니다.");
USER_BOOK_ACCESS_DENIED(HttpStatus.FORBIDDEN, "USER_BOOK_403_01", "해당 책에 대한 접근 권한이 없습니다.");

override fun getHttpStatus(): HttpStatus = httpStatus
override fun getCode(): String = code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ class UserBookService(
fun validateUserBookExists(userBookId: UUID, userId: UUID) {
if (!userBookDomainService.existsByUserBookIdAndUserId(userBookId, userId)) {
throw UserBookException(
UserBookErrorCode.USER_BOOK_NOT_FOUND,
"UserBook not found or access denied: $userBookId"
UserBookErrorCode.USER_BOOK_ACCESS_DENIED,
"UserBook access denied: $userBookId"
)
}
}
Expand Down
13 changes: 13 additions & 0 deletions apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.yapp.apis.config

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "oauth.google")
data class GoogleOauthProperties(
val url: Url,
val clientId: String
)

data class Url(
val userInfo: String
)
8 changes: 8 additions & 0 deletions apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.yapp.apis.config

import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration

@Configuration
@EnableConfigurationProperties(GoogleOauthProperties::class)
class PropertiesConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.yapp.apis.emotion.controller

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.yapp.apis.emotion.dto.response.EmotionListResponse
import org.yapp.apis.emotion.service.EmotionService

@RestController
@RequestMapping("/api/v2/emotions")
class EmotionController(
private val emotionService: EmotionService
) : EmotionControllerApi {

@GetMapping
override fun getEmotions(): ResponseEntity<EmotionListResponse> {
val response = emotionService.getEmotionList()
return ResponseEntity.ok(response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.yapp.apis.emotion.controller

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.yapp.apis.emotion.dto.response.EmotionListResponse

@Tag(name = "Emotions", description = "감정 관련 API")
@RequestMapping("/api/v2/emotions")
interface EmotionControllerApi {

Check warning on line 16 in apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this interface functional or replace it with a function type.

See more on https://sonarcloud.io/project/issues?id=YAPP-Github_26th-App-Team-1-BE&issues=AZzQ7att84En22ar6ltv&open=AZzQ7att84En22ar6ltv&pullRequest=157

@Operation(
summary = "감정 목록 조회",
description = "대분류 감정과 세부 감정 목록을 조회합니다."
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "감정 목록 조회 성공",
content = [Content(schema = Schema(implementation = EmotionListResponse::class))]
)
]
)
@GetMapping
fun getEmotions(): ResponseEntity<EmotionListResponse>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.yapp.apis.emotion.dto.response

import io.swagger.v3.oas.annotations.media.Schema
import java.util.UUID

@Schema(name = "EmotionDetailDto", description = "세부 감정")
data class EmotionDetailDto private constructor(
@field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000")
val id: UUID,

@field:Schema(description = "세부 감정 이름", example = "설레는")
val name: String
) {
companion object {
fun of(id: UUID, name: String): EmotionDetailDto {
return EmotionDetailDto(id = id, name = name)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.yapp.apis.emotion.dto.response

import io.swagger.v3.oas.annotations.media.Schema
import org.yapp.domain.detailtag.DetailTag
import org.yapp.domain.readingrecord.PrimaryEmotion
import java.util.UUID

@Schema(name = "EmotionListResponse", description = "감정 목록 응답")
data class EmotionListResponse private constructor(
@field:Schema(description = "감정 그룹 목록")
val emotions: List<EmotionGroupDto>
) {
companion object {
fun from(detailTags: List<DetailTag>): EmotionListResponse {
val grouped = detailTags.groupBy { it.primaryEmotion }

val emotions = PrimaryEmotion.entries.map { primary ->
EmotionGroupDto.of(
code = primary.name,
displayName = primary.displayName,
detailEmotions = grouped[primary]
?.sortedBy { it.displayOrder }
?.map { EmotionDetailDto.of(id = it.id.value, name = it.name) }
?: emptyList()
)
}

return EmotionListResponse(emotions = emotions)
}
}

@Schema(name = "EmotionGroupDto", description = "감정 그룹 (대분류 + 세부감정)")
data class EmotionGroupDto private constructor(
@field:Schema(description = "대분류 코드", example = "JOY")
val code: String,

@field:Schema(description = "대분류 표시 이름", example = "즐거움")
val displayName: String,

@field:Schema(description = "세부 감정 목록")
val detailEmotions: List<EmotionDetailDto>
) {
companion object {
fun of(
code: String,
displayName: String,
detailEmotions: List<EmotionDetailDto>
): EmotionGroupDto {
return EmotionGroupDto(
code = code,
displayName = displayName,
detailEmotions = detailEmotions
)
}
}
}

}
Loading