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 .github/workflows/deploy-to-dev-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ jobs:
GOOGLE_ANDROID_CLIENT_ID="${{ secrets.GOOGLE_ANDROID_CLIENT_ID }}" \
GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \
GOOGLE_REDIRECT_URI="${{ secrets.GOOGLE_REDIRECT_URI }}" \
KAKAO_CLIENT_ID="${{ secrets.KAKAO_CLIENT_ID }}" \
KAKAO_CLIENT_SECRET="${{ secrets.KAKAO_CLIENT_SECRET }}" \
KAKAO_REDIRECT_URI="${{ secrets.KAKAO_REDIRECT_URI }}" \
KAKAO_APP_ID="${{ secrets.KAKAO_APP_ID }}" \
IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \
IAM_SECRET_KEY="${{ secrets.IAM_SECRET_KEY }}" \
BUCKET_NAME="${{ secrets.BUCKET_NAME }}" \
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/deploy-to-prod-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ jobs:
GOOGLE_ANDROID_CLIENT_ID="${{ secrets.GOOGLE_ANDROID_CLIENT_ID }}" \
GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \
GOOGLE_REDIRECT_URI="${{ secrets.GOOGLE_REDIRECT_URI }}" \
KAKAO_CLIENT_ID="${{ secrets.KAKAO_CLIENT_ID }}" \
KAKAO_CLIENT_SECRET="${{ secrets.KAKAO_CLIENT_SECRET }}" \
KAKAO_REDIRECT_URI="${{ secrets.KAKAO_REDIRECT_URI }}" \
KAKAO_APP_ID="${{ secrets.KAKAO_APP_ID }}" \
IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \
IAM_SECRET_KEY="${{ secrets.IAM_SECRET_KEY }}" \
BUCKET_NAME="${{ secrets.BUCKET_NAME }}" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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.request.KakaoNativeLoginRequest;
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 Down Expand Up @@ -77,12 +78,12 @@ public class AuthController {
),
@ApiResponse(
responseCode = "400",
description = "ErrorCode: AUTH_UNSUPPORTED_OAUTH2_PROVIDER - provider가 'naver' 또는 'google'이 아닌 경우",
description = "ErrorCode: AUTH_UNSUPPORTED_OAUTH2_PROVIDER - provider가 'naver', 'google', 'kakao'가 아닌 경우",
content = @Content)
}
)
public ResponseEntity<ApiResponseDto<AuthorizationUrlResponse>> getAuthorizationUrl(
@Parameter(description = "OAuth2 제공자 (naver, google)", example = "naver")
@Parameter(description = "OAuth2 제공자 (naver, google, kakao)", example = "naver")
@PathVariable("provider") String provider
) {
// Provider 검증 및 변환
Expand Down Expand Up @@ -236,7 +237,7 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> login(
responseCode = "400",
description = """
잘못된 요청
- ErrorCode: AUTH_UNSUPPORTED_OAUTH2_PROVIDER - provider가 'naver' 또는 'google'이 아닌 경우
- ErrorCode: AUTH_UNSUPPORTED_OAUTH2_PROVIDER - provider가 'naver', 'google', 'kakao'가 아닌 경우
- ErrorCode: VALIDATION_FAILED - code 또는 state 값이 누락된 경우
""",
content = @Content
Expand All @@ -253,13 +254,19 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> login(
),
@ApiResponse(
responseCode = "409",
description = "ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD - 해당 이메일이 다른 방법으로 이미 가입된 경우",
description = """
이메일 중복
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_NAVER - 네이버 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_GOOGLE - 구글 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_KAKAO - 카카오 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_BASIC - 일반 계정으로 이미 가입됨
""",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<TokenResponse>> oauth2Login(
@Parameter(description = "OAuth2 제공자 (naver, google)", example = "naver")
@Parameter(description = "OAuth2 제공자 (naver, google, kakao)", example = "naver")
@PathVariable("provider") String provider,
@RequestBody @Valid SocialLoginRequest request,
HttpServletResponse response
Expand Down Expand Up @@ -312,7 +319,13 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> oauth2Login(
),
@ApiResponse(
responseCode = "409",
description = "ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD - 해당 이메일이 다른 방법으로 이미 가입된 경우",
description = """
이메일 중복
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_NAVER - 네이버 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_GOOGLE - 구글 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_KAKAO - 카카오 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_BASIC - 일반 계정으로 이미 가입됨
""",
content = @Content
)
}
Expand Down Expand Up @@ -370,7 +383,13 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> naverNativeLogin(
),
@ApiResponse(
responseCode = "409",
description = "ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD - 해당 이메일이 다른 방법으로 이미 가입된 경우",
description = """
이메일 중복
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_NAVER - 네이버 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_GOOGLE - 구글 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_KAKAO - 카카오 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_BASIC - 일반 계정으로 이미 가입됨
""",
content = @Content
)
}
Expand All @@ -390,6 +409,66 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> googleNativeLogin(
);
}

@PostMapping("/kakao/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_NAVER - 네이버 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_GOOGLE - 구글 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_KAKAO - 카카오 계정으로 이미 가입됨
- ErrorCode: AUTH_EMAIL_ALREADY_REGISTERED_WITH_BASIC - 일반 계정으로 이미 가입됨
""",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<TokenResponse>> kakaoNativeLogin(
@RequestBody @Valid KakaoNativeLoginRequest request,
HttpServletResponse response
) {
// 카카오 Access Token으로 사용자 정보 조회 및 로그인
TokenBundle tokenBundle = nativeAuthService.executeKakaoLogin(request.kakaoAccessToken());

// Refresh Token을 HttpOnly 쿠키에 저장
cookieManager.addRefreshTokenCookie(response, tokenBundle.refreshToken());

return ApiResponseEntity.ok(
new TokenResponse(tokenBundle.accessToken(), tokenBundle.signupStatus())
);
}

@PostMapping("/refresh")
@PermitAll
@Operation(
Expand Down Expand Up @@ -625,7 +704,7 @@ public ResponseEntity<ApiResponseDto<Void>> withdrawUser(
<br>
**소셜 로그인 계정의 경우:**<br>
- 별도 복구 API가 필요 없습니다.<br>
- 소셜 로그인(POST /naver/login 또는 POST /google/login)을 시도하면 자동으로 복구됩니다.
- 소셜 로그인(POST /{provider}/login)을 시도하면 자동으로 복구됩니다.
""",
responses = {
@ApiResponse(
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로 받은 Access Token 전달
*/
@Schema(description = "카카오 Native SDK 로그인 요청")
public record KakaoNativeLoginRequest(
@Schema(description = "카카오 SDK로부터 받은 Access Token", example = "AAAANv1...")
@NotBlank(message = "카카오 Access Token은 필수입니다")
String kakaoAccessToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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.KakaoOAuth2Client;
import com.devkor.ifive.nadab.domain.auth.infra.oauth.client.NaverOAuth2Client;
import com.devkor.ifive.nadab.domain.user.core.entity.User;
import lombok.RequiredArgsConstructor;
Expand All @@ -14,7 +15,7 @@
/**
* Native SDK 소셜 로그인 서비스
* - Android/iOS 앱에서 각 SDK로 받은 토큰으로 로그인 처리
* - 네이버, 구글, Apple 등 모든 Native SDK 로그인을 통합 관리
* - 네이버, 구글, 카카오, Apple 등 모든 Native SDK 로그인을 통합 관리
*/
@Slf4j
@Service
Expand All @@ -24,6 +25,7 @@ public class NativeAuthService {

private final NaverOAuth2Client naverOAuth2Client;
private final GoogleOAuth2Client googleOAuth2Client;
private final KakaoOAuth2Client kakaoOAuth2Client;
private final SocialAccountService socialAccountService;
private final TokenService tokenService;

Expand Down Expand Up @@ -58,4 +60,23 @@ public TokenBundle executeGoogleLogin(String googleIdToken) {
// 3. JWT 토큰 발급
return tokenService.issueTokens(user.getId());
}

// 카카오 Native SDK 로그인 처리
public TokenBundle executeKakaoLogin(String kakaoAccessToken) {
// 1. 앱 키 검증 (다른 앱의 토큰 사용 방지)
kakaoOAuth2Client.validateAccessTokenAppId(kakaoAccessToken);

// 2. 카카오 API로 사용자 정보 조회
OAuth2UserInfo userInfo = kakaoOAuth2Client.fetchUserInfo(kakaoAccessToken);

// 3. 사용자 조회 또는 생성
User user = socialAccountService.getOrCreateUser(
OAuth2Provider.KAKAO,
userInfo.providerId(),
userInfo.email()
);

// 4. JWT 토큰 발급
return tokenService.issueTokens(user.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,27 @@ public User getOrCreateUser(OAuth2Provider provider, String providerId, String e
// User 조회 실패시 신규 소셜 로그인 사용자 생성
private User saveNewSocialUser(String email, OAuth2Provider provider, String providerId) {
// 이메일 중복 체크 및 탈퇴 계정 확인
userRepository.findByEmail(email).ifPresent(user -> {
if (user.getDeletedAt() != null) {
userRepository.findByEmail(email).ifPresent(existingUser -> {
if (existingUser.getDeletedAt() != null) {
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWN_ACCOUNT_RESTORE_REQUIRED);
}
throw new ConflictException(ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_DIFFERENT_METHOD);

// 기존 제공자 확인
SocialAccount existingSocialAccount = socialAccountRepository.findByUser(existingUser)
.orElse(null);

if (existingSocialAccount != null) {
// 소셜 로그인 계정
ErrorCode errorCode = switch (existingSocialAccount.getProviderType()) {
case GOOGLE -> ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_GOOGLE;
case KAKAO -> ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_KAKAO;
case NAVER -> ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_NAVER;
};
throw new ConflictException(errorCode);
} else {
// 일반 계정
throw new ConflictException(ErrorCode.AUTH_EMAIL_ALREADY_REGISTERED_WITH_BASIC);
}
});

// User 생성 및 저장
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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.KakaoOAuth2Client;
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.User;
Expand All @@ -27,6 +28,7 @@ public class SocialAuthService {

private final NaverOAuth2Client naverOAuth2Client;
private final GoogleOAuth2Client googleOAuth2Client;
private final KakaoOAuth2Client kakaoOAuth2Client;
private final StateManager stateManager;
private final SocialAccountService socialAccountService;
private final TokenService tokenService;
Expand All @@ -40,6 +42,7 @@ public String getAuthorizationUrl(OAuth2Provider provider) {
return switch (provider) {
case NAVER -> naverOAuth2Client.buildAuthorizationUrl(state);
case GOOGLE -> googleOAuth2Client.buildAuthorizationUrl(state);
case KAKAO -> kakaoOAuth2Client.buildAuthorizationUrl(state);
};
}

Expand All @@ -56,12 +59,14 @@ public TokenBundle executeOAuth2Login(OAuth2Provider provider, String code, Stri
String accessToken = switch (provider) {
case NAVER -> naverOAuth2Client.fetchAccessToken(code, state);
case GOOGLE -> googleOAuth2Client.fetchAccessToken(code);
case KAKAO -> kakaoOAuth2Client.fetchAccessToken(code);
};

// 3. 사용자 정보 조회
OAuth2UserInfo userInfo = switch (provider) {
case NAVER -> naverOAuth2Client.fetchUserInfo(accessToken);
case GOOGLE -> googleOAuth2Client.fetchUserInfo(accessToken);
case KAKAO -> kakaoOAuth2Client.fetchUserInfo(accessToken);
};

// 4. 사용자 조회 또는 생성
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public enum ProviderType {
GOOGLE,
KAKAO,
NAVER
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Optional<SocialAccount> findByProviderTypeAndProviderUserId(
String providerUserId
);

// 사용자로 소셜 계정 조회
Optional<SocialAccount> findByUser(User user);

// 사용자로 소셜 계정 존재 여부 확인
boolean existsByUser(User user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* - 지원되는 소셜 로그인 제공자를 정의
*/
public enum OAuth2Provider {
NAVER,
GOOGLE;
GOOGLE,
KAKAO,
NAVER;

public static OAuth2Provider fromString(String provider) {
for (OAuth2Provider p : values()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,13 @@ private void validateIdTokenInfo(GoogleIdTokenInfoResponse tokenInfo) {
throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED);
}

// audience 검증 (우리 앱의 Android Client ID인지)
if (!googleProperties.getAndroidClientId().equals(tokenInfo.getAud())) {
// audience 검증 (우리 앱의 Web Client ID인지)
if (!googleProperties.getClientId().equals(tokenInfo.getAud())) {
log.warn("구글 ID Token 검증 실패: 유효하지 않은 audience - {}", tokenInfo.getAud());
throw new OAuth2Exception(ErrorCode.AUTH_OAUTH2_USERINFO_FAILED);
}

// azp (Authorized party) 검증 (azp가 있으면 우리 앱의 Client ID인지 확인)
// azp (Authorized party) 검증 (azp가 있으면 우리 앱의 Android 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);
Expand Down
Loading