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
1 change: 1 addition & 0 deletions .github/workflows/deploy-to-dev-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}" \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-to-prod-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}" \
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ repositories {

ext {
set('springAiVersion', "1.0.2")
set('testcontainersVersion', "2.0.3")
}

dependencies {
Expand Down Expand Up @@ -75,6 +76,7 @@ dependencies {
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -274,6 +278,118 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> oauth2Login(
);
}

@PostMapping("/naver/native-login")
@PermitAll
@Operation(
summary = "네이버 Native SDK 로그인",
description = """
Android/iOS 앱에서 네이버 SDK로 받은 Access Token을 사용하여 로그인을 완료합니다.<br>
Access Token과 signupStatus는 응답 바디(JSON)로 반환되며, Refresh Token은 HttpOnly 쿠키로 자동 설정됩니다.<br>
기존 회원은 바로 로그인 처리되며, 신규 사용자는 자동으로 회원가입 후 로그인됩니다.<br>
<br>
신규 가입자(signupStatus: PROFILE_INCOMPLETE)는 온보딩 과정에서 약관 동의(POST /terms/consent) 후 닉네임을 입력해야 합니다.<br>
<br>
**signupStatus:**<br>
- PROFILE_INCOMPLETE: 프로필 입력 필요 (신규 가입자, 약관 동의 + 닉네임 입력 필요)<br>
- COMPLETED: 가입 완료 (모든 필수 정보 입력 완료)<br>
- 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<ApiResponseDto<TokenResponse>> 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을 사용하여 로그인을 완료합니다.<br>
Access Token과 signupStatus는 응답 바디(JSON)로 반환되며, Refresh Token은 HttpOnly 쿠키로 자동 설정됩니다.<br>
기존 회원은 바로 로그인 처리되며, 신규 사용자는 자동으로 회원가입 후 로그인됩니다.<br>
<br>
신규 가입자(signupStatus: PROFILE_INCOMPLETE)는 온보딩 과정에서 약관 동의(POST /terms/consent) 후 닉네임을 입력해야 합니다.<br>
<br>
**signupStatus:**<br>
- PROFILE_INCOMPLETE: 프로필 입력 필요 (신규 가입자, 약관 동의 + 닉네임 입력 필요)<br>
- COMPLETED: 가입 완료 (모든 필수 정보 입력 완료)<br>
- 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<ApiResponseDto<TokenResponse>> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading