diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index e949c547..9aca4310 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -91,6 +91,7 @@ jobs: NAVER_CLIENT_SECRET="${{ secrets.NAVER_CLIENT_SECRET }}" \ NAVER_REDIRECT_URI="${{ secrets.NAVER_REDIRECT_URI }}" \ GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" \ + GOOGLE_ANDROID_CLIENT_ID="${{ secrets.GOOGLE_ANDROID_CLIENT_ID }}" \ GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ GOOGLE_REDIRECT_URI="${{ secrets.GOOGLE_REDIRECT_URI }}" \ IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \ diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml index c368358b..e6ea37b8 100644 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ b/.github/workflows/deploy-to-prod-ec2.yml @@ -89,6 +89,7 @@ jobs: NAVER_CLIENT_SECRET="${{ secrets.NAVER_CLIENT_SECRET }}" \ NAVER_REDIRECT_URI="${{ secrets.NAVER_REDIRECT_URI }}" \ GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" \ + GOOGLE_ANDROID_CLIENT_ID="${{ secrets.GOOGLE_ANDROID_CLIENT_ID }}" \ GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ GOOGLE_REDIRECT_URI="${{ secrets.GOOGLE_REDIRECT_URI }}" \ IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \ diff --git a/build.gradle b/build.gradle index 092a762d..cc3e88d8 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ repositories { ext { set('springAiVersion', "1.0.2") + set('testcontainersVersion', "2.0.3") } dependencies { @@ -75,6 +76,7 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" + mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}" } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java index 47b36ff7..9b2e0df5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java @@ -5,6 +5,8 @@ import com.devkor.ifive.nadab.domain.auth.api.dto.request.ResetPasswordRequest; import com.devkor.ifive.nadab.domain.auth.api.dto.request.RestoreRequest; import com.devkor.ifive.nadab.domain.auth.api.dto.request.SignupRequest; +import com.devkor.ifive.nadab.domain.auth.api.dto.request.NaverNativeLoginRequest; +import com.devkor.ifive.nadab.domain.auth.api.dto.request.GoogleNativeLoginRequest; import com.devkor.ifive.nadab.domain.auth.api.dto.response.AuthorizationUrlResponse; import com.devkor.ifive.nadab.domain.auth.api.dto.request.SocialLoginRequest; import com.devkor.ifive.nadab.domain.auth.api.dto.response.TokenResponse; @@ -13,6 +15,7 @@ import com.devkor.ifive.nadab.domain.auth.application.BasicAuthService; import com.devkor.ifive.nadab.domain.auth.application.PasswordService; import com.devkor.ifive.nadab.domain.auth.application.SocialAuthService; +import com.devkor.ifive.nadab.domain.auth.application.NativeAuthService; import com.devkor.ifive.nadab.domain.auth.application.TokenService; import com.devkor.ifive.nadab.domain.auth.application.TokenService.TokenBundle; import com.devkor.ifive.nadab.domain.auth.infra.cookie.CookieManager; @@ -50,6 +53,7 @@ public class AuthController { private final SocialAuthService socialAuthService; + private final NativeAuthService nativeAuthService; private final BasicAuthService basicAuthService; private final TokenService tokenService; private final PasswordService passwordService; @@ -274,6 +278,118 @@ public ResponseEntity> oauth2Login( ); } + @PostMapping("/naver/native-login") + @PermitAll + @Operation( + summary = "네이버 Native SDK 로그인", + description = """ + Android/iOS 앱에서 네이버 SDK로 받은 Access Token을 사용하여 로그인을 완료합니다.
+ Access Token과 signupStatus는 응답 바디(JSON)로 반환되며, Refresh Token은 HttpOnly 쿠키로 자동 설정됩니다.
+ 기존 회원은 바로 로그인 처리되며, 신규 사용자는 자동으로 회원가입 후 로그인됩니다.
+
+ 신규 가입자(signupStatus: PROFILE_INCOMPLETE)는 온보딩 과정에서 약관 동의(POST /terms/consent) 후 닉네임을 입력해야 합니다.
+
+ **signupStatus:**
+ - PROFILE_INCOMPLETE: 프로필 입력 필요 (신규 가입자, 약관 동의 + 닉네임 입력 필요)
+ - COMPLETED: 가입 완료 (모든 필수 정보 입력 완료)
+ - WITHDRAWN: 회원 탈퇴 (14일 내 복구 가능) + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = TokenResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = "ErrorCode: VALIDATION_FAILED - Access Token이 누락된 경우", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "ErrorCode: AUTH_OAUTH2_USERINFO_FAILED - 네이버로부터 사용자 정보 조회 실패 (유효하지 않은 토큰)", + content = @Content + ), + @ApiResponse( + responseCode = "409", + description = "ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD - 해당 이메일이 다른 방법으로 이미 가입된 경우", + content = @Content + ) + } + ) + public ResponseEntity> naverNativeLogin( + @RequestBody @Valid NaverNativeLoginRequest request, + HttpServletResponse response + ) { + // 네이버 Access Token으로 사용자 정보 조회 및 로그인 + TokenBundle tokenBundle = nativeAuthService.executeNaverLogin(request.naverAccessToken()); + + // Refresh Token을 HttpOnly 쿠키에 저장 + cookieManager.addRefreshTokenCookie(response, tokenBundle.refreshToken()); + + return ApiResponseEntity.ok( + new TokenResponse(tokenBundle.accessToken(), tokenBundle.signupStatus()) + ); + } + + @PostMapping("/google/native-login") + @PermitAll + @Operation( + summary = "구글 Native SDK 로그인", + description = """ + Android/iOS 앱에서 구글 SDK로 받은 ID Token을 사용하여 로그인을 완료합니다.
+ Access Token과 signupStatus는 응답 바디(JSON)로 반환되며, Refresh Token은 HttpOnly 쿠키로 자동 설정됩니다.
+ 기존 회원은 바로 로그인 처리되며, 신규 사용자는 자동으로 회원가입 후 로그인됩니다.
+
+ 신규 가입자(signupStatus: PROFILE_INCOMPLETE)는 온보딩 과정에서 약관 동의(POST /terms/consent) 후 닉네임을 입력해야 합니다.
+
+ **signupStatus:**
+ - PROFILE_INCOMPLETE: 프로필 입력 필요 (신규 가입자, 약관 동의 + 닉네임 입력 필요)
+ - COMPLETED: 가입 완료 (모든 필수 정보 입력 완료)
+ - WITHDRAWN: 회원 탈퇴 (14일 내 복구 가능) + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = TokenResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = "ErrorCode: VALIDATION_FAILED - ID Token이 누락된 경우", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = """ + ID Token 검증 실패 + - ErrorCode: AUTH_OAUTH2_USERINFO_FAILED - 구글 ID Token 검증 실패 + (유효하지 않은 토큰, 만료된 토큰, 잘못된 audience, 인증되지 않은 이메일) + """, + content = @Content + ), + @ApiResponse( + responseCode = "409", + description = "ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD - 해당 이메일이 다른 방법으로 이미 가입된 경우", + content = @Content + ) + } + ) + public ResponseEntity> googleNativeLogin( + @RequestBody @Valid GoogleNativeLoginRequest request, + HttpServletResponse response + ) { + // 구글 ID Token 검증 및 로그인 + TokenBundle tokenBundle = nativeAuthService.executeGoogleLogin(request.googleIdToken()); + + // Refresh Token을 HttpOnly 쿠키에 저장 + cookieManager.addRefreshTokenCookie(response, tokenBundle.refreshToken()); + + return ApiResponseEntity.ok( + new TokenResponse(tokenBundle.accessToken(), tokenBundle.signupStatus()) + ); + } + @PostMapping("/refresh") @PermitAll @Operation( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/GoogleNativeLoginRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/GoogleNativeLoginRequest.java new file mode 100644 index 00000000..6bebc548 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/GoogleNativeLoginRequest.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.domain.auth.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 구글 Native SDK 로그인 요청 DTO + * - Android/iOS 앱에서 구글 SDK로 받은 ID Token 전달 + */ +@Schema(description = "구글 Native SDK 로그인 요청") +public record GoogleNativeLoginRequest( + @Schema(description = "구글 SDK로부터 받은 ID Token (JWT)", example = "eyJhbGciOiJSUzI1NiIs...") + @NotBlank(message = "구글 ID Token은 필수입니다") + String googleIdToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/NaverNativeLoginRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/NaverNativeLoginRequest.java new file mode 100644 index 00000000..be77feff --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/NaverNativeLoginRequest.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.domain.auth.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** + * 네이버 Native SDK 로그인 요청 DTO + * - Android/iOS 앱에서 네이버 SDK로 받은 Access Token 전달 + */ +@Schema(description = "네이버 Native SDK 로그인 요청") +public record NaverNativeLoginRequest( + @Schema(description = "네이버 SDK로부터 받은 Access Token", example = "AAAANv1...") + @NotBlank(message = "네이버 Access Token은 필수입니다") + String naverAccessToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/NativeAuthService.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/NativeAuthService.java new file mode 100644 index 00000000..bb887a88 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/NativeAuthService.java @@ -0,0 +1,61 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.application.TokenService.TokenBundle; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.OAuth2Provider; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.OAuth2UserInfo; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.GoogleOAuth2Client; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.NaverOAuth2Client; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Native SDK 소셜 로그인 서비스 + * - Android/iOS 앱에서 각 SDK로 받은 토큰으로 로그인 처리 + * - 네이버, 구글, Apple 등 모든 Native SDK 로그인을 통합 관리 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class NativeAuthService { + + private final NaverOAuth2Client naverOAuth2Client; + private final GoogleOAuth2Client googleOAuth2Client; + private final SocialAccountService socialAccountService; + private final TokenService tokenService; + + // 네이버 Native SDK 로그인 처리 + public TokenBundle executeNaverLogin(String naverAccessToken) { + // 1. 네이버 API로 사용자 정보 조회 + OAuth2UserInfo userInfo = naverOAuth2Client.fetchUserInfo(naverAccessToken); + + // 2. 사용자 조회 또는 생성 + User user = socialAccountService.getOrCreateUser( + OAuth2Provider.NAVER, + userInfo.providerId(), + userInfo.email() + ); + + // 3. JWT 토큰 발급 + return tokenService.issueTokens(user.getId()); + } + + // 구글 Native SDK 로그인 처리 + public TokenBundle executeGoogleLogin(String googleIdToken) { + // 1. Google tokeninfo API로 ID Token 검증 및 사용자 정보 조회 + OAuth2UserInfo userInfo = googleOAuth2Client.verifyIdToken(googleIdToken); + + // 2. 사용자 조회 또는 생성 + User user = socialAccountService.getOrCreateUser( + OAuth2Provider.GOOGLE, + userInfo.providerId(), + userInfo.email() + ); + + // 3. JWT 토큰 발급 + return tokenService.issueTokens(user.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAccountService.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAccountService.java new file mode 100644 index 00000000..3e549bf7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAccountService.java @@ -0,0 +1,96 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.ProviderType; +import com.devkor.ifive.nadab.domain.auth.core.entity.SocialAccount; +import com.devkor.ifive.nadab.domain.auth.core.repository.SocialAccountRepository; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.OAuth2Provider; +import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +/** + * 소셜 계정 관리 서비스 + * - 모든 소셜 로그인(OAuth2 웹, Native SDK)에서 재사용하는 공통 로직 + * - 사용자 조회/생성, 탈퇴 계정 복구 등 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SocialAccountService { + + private final UserRepository userRepository; + private final SocialAccountRepository socialAccountRepository; + private final UserWalletRepository userWalletRepository; + + // 소셜 계정으로 User 조회 또는 생성 + @Transactional + public User getOrCreateUser(OAuth2Provider provider, String providerId, String email) { + ProviderType providerType = ProviderType.valueOf(provider.name()); + + return socialAccountRepository.findByProviderTypeAndProviderUserId(providerType, providerId) + .map(SocialAccount::getUser) + .map(user -> { + // 탈퇴한 계정이면 자동 복구 + if (user.getDeletedAt() != null) { + return restoreWithdrawnAccount(user); + } + return user; + }) + .orElseGet(() -> saveNewSocialUser(email, provider, providerId)); + } + + // User 조회 실패시 신규 소셜 로그인 사용자 생성 + private User saveNewSocialUser(String email, OAuth2Provider provider, String providerId) { + // 이메일 중복 체크 및 탈퇴 계정 확인 + userRepository.findByEmail(email).ifPresent(user -> { + if (user.getDeletedAt() != null) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWN_ACCOUNT_RESTORE_REQUIRED); + } + throw new ConflictException(ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD); + }); + + // User 생성 및 저장 + User newUser = User.createSocialUser(email); + userRepository.save(newUser); + + // UserWallet 생성 및 저장 + UserWallet wallet = UserWallet.create(newUser); + userWalletRepository.save(wallet); + + // SocialAccount 생성 및 저장 + ProviderType providerType = ProviderType.valueOf(provider.name()); + SocialAccount socialAccount = SocialAccount.create(newUser, providerId, providerType); + socialAccountRepository.save(socialAccount); + + return newUser; + } + + // 탈퇴한 소셜 계정 자동 복구 + private User restoreWithdrawnAccount(User user) { + // 14일 이내인지 확인 + if (user.getDeletedAt().isBefore(OffsetDateTime.now().minusDays(14))) { + throw new BadRequestException(ErrorCode.AUTH_RESTORE_PERIOD_EXPIRED); + } + + // 소셜 로그인 계정이 아닌 경우 차단 (일반 계정은 비밀번호 확인 필요) + if (!socialAccountRepository.existsByUser(user)) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWN_ACCOUNT_RESTORE_REQUIRED); + } + + // 복구 처리 + user.restoreAccount(); + user.updateSignupStatus(SignupStatusType.COMPLETED); + + return user; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAuthService.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAuthService.java index a7b25400..efc73430 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAuthService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/SocialAuthService.java @@ -1,30 +1,19 @@ package com.devkor.ifive.nadab.domain.auth.application; import com.devkor.ifive.nadab.domain.auth.application.TokenService.TokenBundle; -import com.devkor.ifive.nadab.domain.auth.core.entity.ProviderType; -import com.devkor.ifive.nadab.domain.auth.core.entity.SocialAccount; -import com.devkor.ifive.nadab.domain.auth.core.repository.SocialAccountRepository; import com.devkor.ifive.nadab.domain.auth.infra.oauth.OAuth2UserInfo; import com.devkor.ifive.nadab.domain.auth.infra.oauth.OAuth2Provider; import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.GoogleOAuth2Client; import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.NaverOAuth2Client; import com.devkor.ifive.nadab.domain.auth.infra.oauth.state.StateManager; -import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; import com.devkor.ifive.nadab.domain.user.core.entity.User; -import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; -import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; -import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; -import com.devkor.ifive.nadab.global.exception.BadRequestException; -import com.devkor.ifive.nadab.global.exception.ConflictException; import com.devkor.ifive.nadab.global.exception.OAuth2Exception; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.OffsetDateTime; - /** * OAuth2 소셜 로그인 서비스 * - Authorization URL 생성 @@ -39,9 +28,7 @@ public class SocialAuthService { private final NaverOAuth2Client naverOAuth2Client; private final GoogleOAuth2Client googleOAuth2Client; private final StateManager stateManager; - private final UserRepository userRepository; - private final SocialAccountRepository socialAccountRepository; - private final UserWalletRepository userWalletRepository; + private final SocialAccountService socialAccountService; private final TokenService tokenService; // 프론트엔드에 전달할 Authorization URL 반환 (CSRF 방지를 위한 state 파라미터 포함) @@ -78,70 +65,9 @@ public TokenBundle executeOAuth2Login(OAuth2Provider provider, String code, Stri }; // 4. 사용자 조회 또는 생성 - User user = getOrCreateUser(provider, userInfo.providerId(), userInfo.email()); + User user = socialAccountService.getOrCreateUser(provider, userInfo.providerId(), userInfo.email()); // 5. 토큰 발급 (Access Token + Refresh Token) return tokenService.issueTokens(user.getId()); } - - // User 조회 또는 생성 - private User getOrCreateUser(OAuth2Provider provider, String providerId, String email) { - ProviderType providerType = ProviderType.valueOf(provider.name()); - - return socialAccountRepository.findByProviderTypeAndProviderUserId(providerType, providerId) - .map(SocialAccount::getUser) - .map(user -> { - // 탈퇴한 계정이면 자동 복구 - if (user.getDeletedAt() != null) { - return restoreWithdrawnAccount(user); - } - return user; - }) - .orElseGet(() -> saveNewSocialUser(email, provider, providerId)); - } - - // User 조회 실패시 신규 소셜 로그인 사용자 생성 - private User saveNewSocialUser(String email, OAuth2Provider provider, String providerId) { - // 이메일 중복 체크 및 탈퇴 계정 확인 - userRepository.findByEmail(email).ifPresent(user -> { - if (user.getDeletedAt() != null) { - throw new BadRequestException(ErrorCode.AUTH_WITHDRAWN_ACCOUNT_RESTORE_REQUIRED); - } - throw new ConflictException(ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD); - }); - - // User 생성 및 저장 - User newUser = User.createSocialUser(email); - userRepository.save(newUser); - - // UserWallet 생성 및 저장 - UserWallet wallet = UserWallet.create(newUser); - userWalletRepository.save(wallet); - - // SocialAccount 생성 및 저장 - ProviderType providerType = ProviderType.valueOf(provider.name()); - SocialAccount socialAccount = SocialAccount.create(newUser, providerId, providerType); - socialAccountRepository.save(socialAccount); - - return newUser; - } - - // 탈퇴한 소셜 계정 자동 복구 - private User restoreWithdrawnAccount(User user) { - // 14일 이내인지 확인 - if (user.getDeletedAt().isBefore(OffsetDateTime.now().minusDays(14))) { - throw new BadRequestException(ErrorCode.AUTH_RESTORE_PERIOD_EXPIRED); - } - - // 소셜 로그인 계정이 아닌 경우 차단 (일반 계정은 비밀번호 확인 필요) - if (!socialAccountRepository.existsByUser(user)) { - throw new BadRequestException(ErrorCode.AUTH_WITHDRAWN_ACCOUNT_RESTORE_REQUIRED); - } - - // 복구 처리 - user.restoreAccount(); - user.updateSignupStatus(SignupStatusType.COMPLETED); - - return user; - } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/GoogleOAuth2Client.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/GoogleOAuth2Client.java index 9701d743..fcc8420d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/GoogleOAuth2Client.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/GoogleOAuth2Client.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.auth.infra.oauth.OAuth2UserInfo; import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.dto.GoogleTokenResponse; import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.dto.GoogleProfileResponse; +import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.dto.GoogleIdTokenInfoResponse; import com.devkor.ifive.nadab.global.core.properties.GoogleProperties; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.OAuth2Exception; @@ -23,6 +24,9 @@ public class GoogleOAuth2Client { private static final String AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/v2/auth"; private static final String TOKEN_URI = "https://oauth2.googleapis.com/token"; private static final String USER_INFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"; + private static final String TOKEN_INFO_URI = "https://oauth2.googleapis.com/tokeninfo"; + private static final String GOOGLE_ISSUER = "https://accounts.google.com"; + private static final String GOOGLE_ISSUER_ALT = "accounts.google.com"; private final GoogleProperties googleProperties; private final WebClient webClient; @@ -135,4 +139,87 @@ public OAuth2UserInfo fetchUserInfo(String accessToken) { response.getEmail() ); } + + // ID Token 검증 (Native SDK 로그인용) + public OAuth2UserInfo verifyIdToken(String googleIdToken) { + // 1. Google tokeninfo API 호출 + GoogleIdTokenInfoResponse response = webClient.get() + .uri(TOKEN_INFO_URI + "?id_token={idToken}", googleIdToken) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(GoogleIdTokenInfoResponse.class) + .map(errorResponse -> { + String errorMessage = errorResponse.getErrorDescription() != null + ? errorResponse.getErrorDescription() + : "HTTP " + clientResponse.statusCode(); + log.warn("구글 ID Token 검증 실패: {}", errorMessage); + return new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + })) + .bodyToMono(GoogleIdTokenInfoResponse.class) + .block(); + + // 응답 없음 + if (response == null) { + log.warn("구글 ID Token 검증 실패: 응답 없음"); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // 에러 응답 체크 + if (response.getError() != null) { + String errorMessage = response.getErrorDescription() != null + ? response.getErrorDescription() + : "알 수 없는 오류"; + log.warn("구글 ID Token 검증 실패: {}", errorMessage); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // 2. 토큰 정보 검증 + validateIdTokenInfo(response); + + // 3. 사용자 정보 반환 + return new OAuth2UserInfo( + response.getSub(), + response.getEmail() + ); + } + + // ID Token 정보 검증 + private void validateIdTokenInfo(GoogleIdTokenInfoResponse tokenInfo) { + // 필수 필드 체크 + if (tokenInfo.getSub() == null || tokenInfo.getEmail() == null) { + log.warn("구글 ID Token 검증 실패: 필수 필드 없음"); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // issuer 검증 + if (!GOOGLE_ISSUER.equals(tokenInfo.getIss()) && !GOOGLE_ISSUER_ALT.equals(tokenInfo.getIss())) { + log.warn("구글 ID Token 검증 실패: 유효하지 않은 issuer - {}", tokenInfo.getIss()); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // audience 검증 (우리 앱의 Android Client ID인지) + if (!googleProperties.getAndroidClientId().equals(tokenInfo.getAud())) { + log.warn("구글 ID Token 검증 실패: 유효하지 않은 audience - {}", tokenInfo.getAud()); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // azp (Authorized party) 검증 (azp가 있으면 우리 앱의 Client ID인지 확인) + if (tokenInfo.getAzp() != null && !googleProperties.getAndroidClientId().equals(tokenInfo.getAzp())) { + log.warn("구글 ID Token 검증 실패: 유효하지 않은 authorized party - {}", tokenInfo.getAzp()); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // 만료 시간 검증 + long currentTime = System.currentTimeMillis() / 1000; + if (tokenInfo.getExp() != null && tokenInfo.getExp() < currentTime) { + log.warn("구글 ID Token 검증 실패: 토큰 만료"); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + + // 이메일 인증 확인 + if (tokenInfo.getEmailVerified() == null || !tokenInfo.getEmailVerified()) { + log.warn("구글 ID Token 검증 실패: 인증되지 않은 이메일"); + throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED); + } + } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/dto/GoogleIdTokenInfoResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/dto/GoogleIdTokenInfoResponse.java new file mode 100644 index 00000000..bb68432a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/oauth/client/dto/GoogleIdTokenInfoResponse.java @@ -0,0 +1,48 @@ +package com.devkor.ifive.nadab.domain.auth.infra.oauth.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 구글 ID Token 검증 API 응답 + * - 구글 tokeninfo API (/tokeninfo?id_token=...) 응답 형식 + * - 성공: iss, sub, aud, email, exp 등 포함 + * - 실패: error, error_description 포함 + */ +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleIdTokenInfoResponse { + + @JsonProperty("iss") + private String iss; // 토큰 발급자 (https://accounts.google.com) + + @JsonProperty("sub") + private String sub; // 사용자 고유 ID (providerId) + + @JsonProperty("azp") + private String azp; // Authorized party (클라이언트 ID) + + @JsonProperty("aud") + private String aud; // Audience (우리 앱의 Client ID) + + @JsonProperty("email") + private String email; // 사용자 이메일 + + @JsonProperty("email_verified") + private Boolean emailVerified; // 이메일 인증 여부 + + @JsonProperty("exp") + private Long exp; // 만료 시간 (Unix timestamp, 초) + + @JsonProperty("iat") + private Long iat; // 발급 시간 (Unix timestamp, 초) + + @JsonProperty("error") + private String error; // 에러 코드 (실패 시) + + @JsonProperty("error_description") + private String errorDescription; // 에러 설명 (실패 시) +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/properties/GoogleProperties.java b/src/main/java/com/devkor/ifive/nadab/global/core/properties/GoogleProperties.java index 44613dd7..49cd4bc1 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/properties/GoogleProperties.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/properties/GoogleProperties.java @@ -10,7 +10,8 @@ @Setter @ConfigurationProperties(prefix = "oauth.google") public class GoogleProperties { - private String clientId; + private String clientId; // 웹용 Client ID + private String androidClientId; // Android용 Client ID private String clientSecret; private String redirectUri; } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 365dd751..5af5b1a2 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -28,6 +28,7 @@ oauth: redirect-uri: ${NAVER_REDIRECT_URI} google: client-id: ${GOOGLE_CLIENT_ID} + android-client-id: ${GOOGLE_ANDROID_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: ${GOOGLE_REDIRECT_URI} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 8aab9cda..c38e8b47 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,6 +29,7 @@ oauth: redirect-uri: ${NAVER_REDIRECT_URI} google: client-id: ${GOOGLE_CLIENT_ID} + android-client-id: ${GOOGLE_ANDROID_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: ${GOOGLE_REDIRECT_URI} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e5b5fced..480314a2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -28,6 +28,7 @@ oauth: redirect-uri: ${NAVER_REDIRECT_URI} google: client-id: ${GOOGLE_CLIENT_ID} + android-client-id: ${GOOGLE_ANDROID_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: ${GOOGLE_REDIRECT_URI}