diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt index ffbbc65b..d44b4a7b 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -26,7 +26,7 @@ class AccountAdminController( private val manageAccountUseCase: ManageAccountUseCase, ) { @PostMapping - @Operation(summary = "회비 총 금액 기입") + @Operation(summary = "회비 총 금액 기입", hidden = true) fun save( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt index 6bc7e7a3..36035ab5 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -25,7 +25,7 @@ class AccountController( private val getAccountQueryService: GetAccountQueryService, ) { @GetMapping("/{cardinal}") - @Operation(summary = "회비 내역 조회") + @Operation(summary = "회비 내역 조회", hidden = true) fun find( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt index 9de17985..86cb2267 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt @@ -32,7 +32,7 @@ class ReceiptAdminController( private val manageReceiptUseCase: ManageReceiptUseCase, ) { @PostMapping - @Operation(summary = "회비 사용 내역 기입") + @Operation(summary = "회비 사용 내역 기입", hidden = true) fun save( @TsidParam @TsidPathVariable clubId: Long, @@ -44,7 +44,7 @@ class ReceiptAdminController( } @DeleteMapping("/{receiptId}") - @Operation(summary = "회비 사용 내역 취소") + @Operation(summary = "회비 사용 내역 취소", hidden = true) fun delete( @TsidParam @TsidPathVariable clubId: Long, @@ -56,7 +56,7 @@ class ReceiptAdminController( } @PatchMapping("/{receiptId}") - @Operation(summary = "회비 사용 내역 수정") + @Operation(summary = "회비 사용 내역 수정", hidden = true) fun update( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 912e045c..8212ff76 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -41,7 +41,7 @@ class AttendanceController( } @GetMapping - @Operation(summary = "출석 메인페이지") + @Operation(summary = "내 출석 요약 조회") fun find( @TsidParam @TsidPathVariable clubId: Long, @@ -53,7 +53,7 @@ class AttendanceController( ) @GetMapping("/detail") - @Operation(summary = "출석 내역 상세조회") + @Operation(summary = "내 출석 상세 내역 조회") fun findAll( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index 75997340..753d6b7f 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -182,7 +182,7 @@ class ClubAdminController( } @PatchMapping("/members/apply-ob") - @Operation(summary = "멤버 OB 기수 등록") + @Operation(summary = "멤버 OB 기수 등록", deprecated = true) fun applyOb( @Parameter(hidden = true) @CurrentUser userId: Long, @TsidParam diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt index a119f5d7..fc6d75d6 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -37,7 +37,7 @@ class PenaltyAdminController( private val getPenaltyQueryService: GetPenaltyQueryService, ) { @PostMapping - @Operation(summary = "패널티 부여") + @Operation(summary = "패널티 부여", hidden = true) fun assignPenalty( @TsidParam @TsidPathVariable clubId: Long, @@ -49,7 +49,7 @@ class PenaltyAdminController( } @PatchMapping - @Operation(summary = "패널티 수정") + @Operation(summary = "패널티 수정", hidden = true) fun update( @TsidParam @TsidPathVariable clubId: Long, @@ -61,7 +61,7 @@ class PenaltyAdminController( } @GetMapping - @Operation(summary = "전체 패널티 조회") + @Operation(summary = "전체 패널티 조회", hidden = true) fun findAll( @TsidParam @TsidPathVariable clubId: Long, @@ -74,7 +74,7 @@ class PenaltyAdminController( ) @DeleteMapping - @Operation(summary = "패널티 삭제") + @Operation(summary = "패널티 삭제", hidden = true) fun delete( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt index 7f2baa53..55898566 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -23,7 +23,7 @@ class PenaltyUserController( private val getPenaltyQueryService: GetPenaltyQueryService, ) { @GetMapping - @Operation(summary = "본인 패널티 조회") + @Operation(summary = "본인 패널티 조회", hidden = true) fun findAllPenalties( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt index 43fcbc3b..91c29170 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt @@ -3,6 +3,8 @@ package com.weeth.domain.user.application.dto.response import io.swagger.v3.oas.annotations.media.Schema data class SocialLoginResponse( + @field:Schema(description = "사용자 이름") + val name: String, @field:Schema(description = "액세스 토큰") val accessToken: String, @field:Schema(description = "리프레시 토큰") diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index deee9cc8..4a317510 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -10,10 +10,12 @@ class UserMapper( private val fileAccessUrlPort: FileAccessUrlPort, ) { fun toSocialLoginResponse( + userName: String, token: JwtDto, registered: Boolean, ): SocialLoginResponse = SocialLoginResponse( + name = userName, accessToken = token.accessToken, refreshToken = token.refreshToken, registered = registered, diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt index c16ce401..deddd8e3 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.application.usecase.command +import com.fasterxml.jackson.databind.ObjectMapper import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.exception.EmailNotFoundException @@ -15,6 +16,7 @@ import com.weeth.domain.user.domain.vo.SocialAuthResult import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase import com.weeth.global.auth.jwt.domain.enums.TokenType +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,7 +27,10 @@ class SocialLoginUseCase( private val socialAuthPortRegistry: SocialAuthPortRegistry, private val jwtManageUseCase: JwtManageUseCase, private val userMapper: UserMapper, + private val objectMapper: ObjectMapper, ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse = socialLogin(SocialProvider.KAKAO, request) @@ -34,18 +39,45 @@ class SocialLoginUseCase( fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse = socialLogin(SocialProvider.APPLE, request) + /** + * Apple form_post 콜백 전용 로그인. + * id_token을 직접 검증하여 code 교환 과정을 생략하고, + * Apple이 최초 인가 시에만 전달하는 user JSON의 이름을 반영한다. + * + * TODO: 탈퇴 기능 구현 시 Apple 계정 연결 해제(revoke)를 위해 + * 콜백의 code를 Apple 토큰 엔드포인트에 교환하여 refresh token을 받고 DB에 저장해야 한다. + * (Apple Revoke Tokens API: POST https://appleid.apple.com/auth/revoke) + */ + @Transactional + fun socialLoginByAppleCallback( + idToken: String, + userJson: String?, + ): SocialLoginResponse { + val authResult = socialAuthPortRegistry.get(SocialProvider.APPLE).authenticateWithIdToken(idToken) + val userName = parseAppleUserName(userJson) + val effectiveResult = + if (!userName.isNullOrBlank() && authResult.name.isNullOrBlank()) { + authResult.copy(name = userName) + } else { + authResult + } + return processLogin(effectiveResult) + } + private fun socialLogin( provider: SocialProvider, request: SocialLoginRequest, - ): SocialLoginResponse { - val user = findOrCreateUser(authResult = socialAuthPortRegistry.get(provider).authenticate(request.authCode)) + ): SocialLoginResponse = processLogin(socialAuthPortRegistry.get(provider).authenticate(request.authCode)) + + private fun processLogin(authResult: SocialAuthResult): SocialLoginResponse { + val user = findOrCreateUser(authResult) if (user.isBannedOrLeft()) throw UserInActiveException() val tokenType = if (user.isRegistered()) TokenType.ACCESS else TokenType.TEMPORARY val token = jwtManageUseCase.create(user.id, user.emailValue, tokenType) - return userMapper.toSocialLoginResponse(token, user.isRegistered()) + return userMapper.toSocialLoginResponse(user.name, token, user.isRegistered()) } // TODO: 실제 서비스 출시 시 이메일 기반 기존 사용자 연동 및 유저 알림 기능 필요 @@ -79,4 +111,18 @@ class SocialLoginUseCase( return user } + + private fun parseAppleUserName(userJson: String?): String? { + if (userJson.isNullOrBlank()) return null + return try { + val node = objectMapper.readTree(userJson) + val nameNode = node["name"] ?: return null + val firstName = nameNode["firstName"]?.asText()?.trim() ?: "" + val lastName = nameNode["lastName"]?.asText()?.trim() ?: "" + "$lastName$firstName".trim().takeIf { it.isNotBlank() } + } catch (e: Exception) { + log.warn("Apple user JSON 파싱 실패: {}", e.message) + null + } + } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt index 40cc5b54..9e0cf052 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt @@ -7,4 +7,7 @@ interface SocialAuthPort { fun provider(): SocialProvider fun authenticate(authCode: String): SocialAuthResult + + fun authenticateWithIdToken(idToken: String): SocialAuthResult = + throw UnsupportedOperationException("${provider()}은(는) ID token 직접 인증을 지원하지 않습니다") } diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt index c2f416b5..318bbfde 100644 --- a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt @@ -4,6 +4,7 @@ import com.weeth.domain.user.domain.enums.SocialProvider import com.weeth.domain.user.domain.port.SocialAuthPort import com.weeth.domain.user.domain.vo.SocialAuthResult import com.weeth.global.auth.apple.AppleAuthService +import com.weeth.global.auth.apple.dto.AppleUserInfo import org.springframework.stereotype.Component @Component @@ -15,15 +16,20 @@ class AppleSocialAuthAdapter( override fun authenticate(authCode: String): SocialAuthResult { val appleToken = appleAuthService.getAppleToken(authCode) val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken) - val email = userInfo.email?.trim()?.lowercase() ?: "" - val providerName = userInfo.name?.trim()?.takeIf { it.isNotBlank() } + return toSocialAuthResult(userInfo) + } + + override fun authenticateWithIdToken(idToken: String): SocialAuthResult { + val userInfo = appleAuthService.verifyAndDecodeIdToken(idToken) + return toSocialAuthResult(userInfo) + } - return SocialAuthResult( + private fun toSocialAuthResult(userInfo: AppleUserInfo): SocialAuthResult = + SocialAuthResult( provider = SocialProvider.APPLE, providerUserId = userInfo.appleId, - email = email, + email = userInfo.email?.trim()?.lowercase() ?: "", emailVerified = userInfo.emailVerified, - name = providerName, + name = userInfo.name?.trim()?.takeIf { it.isNotBlank() }, ) - } } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt new file mode 100644 index 00000000..4f80505c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt @@ -0,0 +1,84 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider +import com.weeth.global.config.properties.OAuthProperties +import io.swagger.v3.oas.annotations.Hidden +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.util.UriComponentsBuilder + +/** + * Apple Sign in with Apple의 form_post 콜백을 처리하는 컨트롤러. + */ +@Hidden +@RestController +class SocialCallbackController( + private val socialLoginUseCase: SocialLoginUseCase, + private val tokenCookieProvider: TokenCookieProvider, + oAuthProperties: OAuthProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + private val frontendRedirectUri = oAuthProperties.apple.frontendRedirectUri + + @PostMapping( + "/api/v4/users/social/apple/callback", + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + fun handleCallback( + @RequestParam("id_token", required = false) idToken: String?, + @RequestParam("user", required = false) userJson: String?, + @RequestParam("error", required = false) error: String?, + ): ResponseEntity { + if (error != null || idToken.isNullOrBlank()) { + return redirect( + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("error", error ?: "unknown") + .toUriString(), + ) + } + + return try { + val response = socialLoginUseCase.socialLoginByAppleCallback(idToken, userJson) + + val redirectUri = + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("registered", response.registered) + .queryParam("name", response.name) + .toUriString() + + ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, redirectUri) + .header( + HttpHeaders.SET_COOKIE, + tokenCookieProvider.createAccessTokenCookie(response.accessToken).toString(), + ).header( + HttpHeaders.SET_COOKIE, + tokenCookieProvider.createRefreshTokenCookie(response.refreshToken).toString(), + ).build() + } catch (e: Exception) { + log.error("Apple 콜백 처리 중 오류 발생", e) + redirect( + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("error", "login_failed") + .toUriString(), + ) + } + } + + private fun redirect(uri: String): ResponseEntity = + ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, uri) + .build() +} diff --git a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt index 1bc82ac0..60e2f038 100644 --- a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -56,7 +56,6 @@ class ExceptionDocController { fun userErrorCodes() { } - // todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") @ApiErrorCodeExample(JwtErrorCode::class) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index d1c02a2b..c6aaff46 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -44,6 +44,7 @@ class SecurityConfig( .requestMatchers( "/api/v4/users/social/kakao", "/api/v4/users/social/apple", + "/api/v4/users/social/apple/callback", "/api/v4/users/social/refresh", ).permitAll() .requestMatchers("/health-check") @@ -87,6 +88,7 @@ class SecurityConfig( "http://127.0.0.1:*", "https://13.124.170.169.nip.io", "https://develop.d2o3vlabneheuu.amplifyapp.com", + "https://appleid.apple.com", ) allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") allowedHeaders = listOf("*") diff --git a/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt index 9d845342..d96ea1c4 100644 --- a/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt +++ b/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt @@ -40,5 +40,7 @@ data class OAuthProperties( val keysUri: String, @field:NotBlank val privateKeyPath: String, + @field:NotBlank + val frontendRedirectUri: String, ) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 535ad46d..ee058161 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -28,6 +28,7 @@ auth: token_uri: https://appleid.apple.com/auth/token keys_uri: https://appleid.apple.com/auth/keys private_key_path: ${APPLE_PRIVATE_KEY_PATH} + frontend_redirect_uri: ${APPLE_FRONTEND_REDIRECT_URI} management: endpoints: diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt index bbc853ca..14a4bac5 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.application.usecase.command +import com.fasterxml.jackson.databind.ObjectMapper import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.exception.EmailNotFoundException @@ -7,7 +8,6 @@ import com.weeth.domain.user.application.mapper.UserMapper import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserSocialAccount import com.weeth.domain.user.domain.enums.SocialProvider -import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.port.SocialAuthPort import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.domain.repository.UserSocialAccountRepository @@ -35,6 +35,7 @@ class SocialLoginUseCaseTest : val jwtManageUseCase = mockk() val fileAccessUrlPort = mockk() val userMapper = UserMapper(fileAccessUrlPort) + val objectMapper = mockk() val useCase = SocialLoginUseCase( @@ -43,6 +44,7 @@ class SocialLoginUseCaseTest : socialAuthPortRegistry = socialAuthPortRegistry, jwtManageUseCase = jwtManageUseCase, userMapper = userMapper, + objectMapper, ) beforeTest {