diff --git a/k8s/helm-value.yaml b/k8s/helm-value.yaml index d16af7a..a2cf555 100644 --- a/k8s/helm-value.yaml +++ b/k8s/helm-value.yaml @@ -1,5 +1,5 @@ image: - tag: v0.1.12 + tag: v0.1.13 env: JWT_ACCESS_TOKEN_EXPIRATION: "1800000" JWT_REFRESH_TOKEN_EXPIRATION: "1209600000" \ No newline at end of file diff --git a/src/main/java/com/earseo/member/common/exception/MemberErrorCode.java b/src/main/java/com/earseo/member/common/exception/MemberErrorCode.java index 80a4741..c140d02 100644 --- a/src/main/java/com/earseo/member/common/exception/MemberErrorCode.java +++ b/src/main/java/com/earseo/member/common/exception/MemberErrorCode.java @@ -27,7 +27,8 @@ public enum MemberErrorCode implements ErrorCodeInterface { APPLE_TOKEN_INVALID("MEM015", "유효하지 않은 Apple 토큰입니다.", HttpStatus.UNAUTHORIZED), APPLE_PUBLIC_KEY_NOT_FOUND("MEM016", "Apple 공개키를 찾을 수 없습니다.", HttpStatus.INTERNAL_SERVER_ERROR), APPLE_TOKEN_EXPIRED("MEM017", "만료된 Apple 토큰입니다.", HttpStatus.UNAUTHORIZED), - APPLE_SERVER_ERROR("MEM018", "Apple 서버 연동 중 오류가 발생했습니다.", HttpStatus.SERVICE_UNAVAILABLE); + APPLE_SERVER_ERROR("MEM018", "Apple 서버 연동 중 오류가 발생했습니다.", HttpStatus.SERVICE_UNAVAILABLE), + INVALID_TEMP_TOKEN("MEM019", "유효하지 않거나 만료된 토큰입니다.", HttpStatus.BAD_REQUEST); private final String status; private final String message; diff --git a/src/main/java/com/earseo/member/controller/AuthController.java b/src/main/java/com/earseo/member/controller/AuthController.java index 95375c8..cf8c21f 100644 --- a/src/main/java/com/earseo/member/controller/AuthController.java +++ b/src/main/java/com/earseo/member/controller/AuthController.java @@ -40,7 +40,6 @@ public ResponseEntity> googleCallback( return ResponseEntity.ok(BaseResponse.ok(response)); } - @Operation(summary = "소셜 로그인 추가 정보 입력", description = "신규 소셜 회원 추가 정보 입력 및 회원가입 완료") @PostMapping("/oauth/additional-info") public ResponseEntity> completeSocialSignUp( @@ -104,9 +103,9 @@ public ResponseEntity> resetPassword( @Operation(summary = "애플 로그인", description = "Apple identityToken으로 로그인/회원가입 처리") @PostMapping("/oauth/apple") - public ResponseEntity> appleLogin( + public ResponseEntity> appleLogin( @RequestBody AppleLoginRequestDto request) { - LoginResponseDto response = appleLoginService.login(request); + SocialLoginResponseDto response = appleLoginService.login(request); return ResponseEntity.ok(BaseResponse.ok(response)); } } diff --git a/src/main/java/com/earseo/member/dto/request/SocialSignUpRequestDto.java b/src/main/java/com/earseo/member/dto/request/SocialSignUpRequestDto.java index 88ca770..1422d9b 100644 --- a/src/main/java/com/earseo/member/dto/request/SocialSignUpRequestDto.java +++ b/src/main/java/com/earseo/member/dto/request/SocialSignUpRequestDto.java @@ -17,6 +17,9 @@ public record SocialSignUpRequestDto( @NotNull(message = "Provider는 필수입니다.") Provider provider, + @NotBlank(message = "임시 토큰은 필수입니다.") + String tempToken, + @NotBlank(message = "닉네임은 필수입니다.") @Size(min = 2, max = 50, message = "닉네임은 2자 이상 50자 이하여야 합니다.") String nickname, diff --git a/src/main/java/com/earseo/member/dto/response/SocialLoginResponseDto.java b/src/main/java/com/earseo/member/dto/response/SocialLoginResponseDto.java index 8c4dcd2..abafcfb 100644 --- a/src/main/java/com/earseo/member/dto/response/SocialLoginResponseDto.java +++ b/src/main/java/com/earseo/member/dto/response/SocialLoginResponseDto.java @@ -1,45 +1,43 @@ package com.earseo.member.dto.response; import com.earseo.member.entity.Role; +import lombok.Builder; +@Builder public record SocialLoginResponseDto( Boolean isNewMember, String email, + String provider, + String nickname, + String tempToken, // 기존 회원인 경우에만 값이 있음 String accessToken, String refreshToken, Long memberId, - String nickname, Role role ) { // 기존 회원용 - public static SocialLoginResponseDto existing( - LoginResponseDto loginResponseDto - ) { - return new SocialLoginResponseDto( - false, - loginResponseDto.email(), - loginResponseDto.accessToken(), - loginResponseDto.refreshToken(), - loginResponseDto.memberId(), - loginResponseDto.nickname(), - loginResponseDto.role() - ); + public static SocialLoginResponseDto existing(LoginResponseDto loginResponseDto) { + return SocialLoginResponseDto.builder() + .isNewMember(false) + .email(loginResponseDto.email()) + .nickname(loginResponseDto.nickname()) // 기존 닉네임 + .accessToken(loginResponseDto.accessToken()) + .refreshToken(loginResponseDto.refreshToken()) + .memberId(loginResponseDto.memberId()) + .role(loginResponseDto.role()) + .build(); } // 신규 회원용 응답 생성 - public static SocialLoginResponseDto newMember( - String email - ) { - return new SocialLoginResponseDto( - true, - email, - null, - null, - null, - null, - null - ); + public static SocialLoginResponseDto newMember(String email, String provider, String tempToken , String nickname) { + return SocialLoginResponseDto.builder() + .isNewMember(true) + .email(email) + .provider(provider) + .tempToken(tempToken) + .nickname(nickname) + .build(); } } \ No newline at end of file diff --git a/src/main/java/com/earseo/member/service/AuthService.java b/src/main/java/com/earseo/member/service/AuthService.java index 59fa5c9..fb788da 100644 --- a/src/main/java/com/earseo/member/service/AuthService.java +++ b/src/main/java/com/earseo/member/service/AuthService.java @@ -9,6 +9,7 @@ import com.earseo.member.entity.Role; import com.earseo.member.repository.MemberRepository; import com.earseo.member.service.oauth.GoogleOAuthService; +import com.earseo.member.service.oauth.SocialSignUpTempService; import com.earseo.member.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -29,6 +30,7 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final EmailService emailService; private final EmailVerificationService emailVerificationService; + private final SocialSignUpTempService socialSignUpTempService; @Value("${member.default-profile-image}") private String defaultProfileImage; @@ -143,13 +145,25 @@ public SocialLoginResponseDto googleLogin(String code) { return SocialLoginResponseDto.existing(loginResponse); } else { // 신규 회원 - 추가 정보 입력 필요 - return SocialLoginResponseDto.newMember(googleUser.email()); + return SocialLoginResponseDto.newMember( + googleUser.email(), + "GOOGLE", + googleUser.id(), // Google의 providerId (sub 클레임) + googleUser.name() // 또는 null + ); } } @Transactional public LoginResponseDto completeSocialSignUp(SocialSignUpRequestDto request) { + + // tempToken으로 providerId 조회 + String providerId = socialSignUpTempService.getProviderId(request.tempToken()); + if (providerId == null) { + throw new BaseException(MemberErrorCode.INVALID_TEMP_TOKEN); + } + // 이미 가입된 회원인지 확인 validateDuplicateMember(request.email(), request.provider()); @@ -161,6 +175,7 @@ public LoginResponseDto completeSocialSignUp(SocialSignUpRequestDto request) { Member member = Member.builder() .email(request.email()) .provider(request.provider()) + .providerId(providerId) .nickname(request.nickname()) .gender(request.gender()) .birthdate(request.birthdate()) @@ -171,6 +186,9 @@ public LoginResponseDto completeSocialSignUp(SocialSignUpRequestDto request) { Member savedMember = memberRepository.save(member); + // tempToken 삭제 + socialSignUpTempService.deleteTempToken(request.tempToken()); + String accessToken = jwtUtil.generateAccessToken( savedMember.getMemberId(), savedMember.getEmail(), diff --git a/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java b/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java index 80bab51..ce08f40 100644 --- a/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java +++ b/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java @@ -1,12 +1,10 @@ package com.earseo.member.service.oauth; -import com.earseo.member.common.exception.BaseException; import com.earseo.member.dto.request.AppleLoginRequestDto; import com.earseo.member.dto.response.LoginResponseDto; +import com.earseo.member.dto.response.SocialLoginResponseDto; import com.earseo.member.entity.Member; import com.earseo.member.entity.Provider; -import com.earseo.member.entity.Role; -import com.earseo.member.common.exception.MemberErrorCode; import com.earseo.member.repository.MemberRepository; import com.earseo.member.util.JwtUtil; import io.jsonwebtoken.Claims; @@ -24,77 +22,45 @@ public class AppleLoginService { private final AppleTokenVerifier appleTokenVerifier; private final MemberRepository memberRepository; private final JwtUtil jwtUtil; + private final SocialSignUpTempService socialSignUpTempService; @Transactional - public LoginResponseDto login(AppleLoginRequestDto request) { + public SocialLoginResponseDto login(AppleLoginRequestDto request) { Claims claims = appleTokenVerifier.verifyAndGetClaims(request.identityToken()); String providerId = claims.getSubject(); String email = claims.get("email", String.class); - // 이미 Apple로 가입한 사용자인지 확인 - Optional existingAppleMember = memberRepository - .findByProviderAndProviderId(Provider.APPLE, providerId); - - if (existingAppleMember.isPresent()) { - // 기존 Apple 사용자는 로그인 처리 - return generateLoginResponse(existingAppleMember.get()); - } - - if (email != null) { - Optional existingEmailMember = memberRepository.findByEmail(email); - - if (existingEmailMember.isPresent() && - existingEmailMember.get().getProvider() != Provider.APPLE) { - throw new BaseException(MemberErrorCode.ALREADY_REGISTERED_WITH_DIFFERENT_PROVIDER); - } + // 애플은 이메일 비공개일 경우 이메일이 안 올 수 있음 -> providerId로 대체 + if (email == null) { + email = providerId + "@apple.private"; } - Member newMember = createAppleMember(providerId, email, request.fullName()); - Member savedMember = memberRepository.save(newMember); + // 이미 Apple로 가입한 사용자인지 확인 + Optional existingMember = memberRepository + .findByProviderAndProviderId(Provider.APPLE, providerId); - return generateLoginResponse(savedMember); - } + if (existingMember.isPresent()) { + Member member = existingMember.get(); - private Member createAppleMember(String providerId, String email, String fullName) { - String memberEmail = email != null ? email : providerId + "@apple.private"; + String accessToken = jwtUtil.generateAccessToken(member.getMemberId(), member.getEmail(), member.getRole()); + String refreshToken = jwtUtil.generateRefreshToken(member.getMemberId()); - // 닉네임 중복 방지를 위해 항상 UUID 붙이기 - String baseNickname = (fullName != null && !fullName.isBlank()) - ? fullName - : "User"; - String nickname = baseNickname + "_" + UUID.randomUUID().toString().substring(0, 8); + LoginResponseDto loginData = new LoginResponseDto( + accessToken, refreshToken, member.getMemberId(), + member.getEmail(), member.getNickname(), member.getRole() + ); - // 혹시 중복이면 다시 생성 - while (memberRepository.existsByNickname(nickname)) { - nickname = baseNickname + "_" + UUID.randomUUID().toString().substring(0, 8); + return SocialLoginResponseDto.existing(loginData); } - return Member.builder() - .email(memberEmail) - .provider(Provider.APPLE) - .providerId(providerId) - .nickname(nickname) - .role(Role.USER) - .password(null) - .build(); - } - - private LoginResponseDto generateLoginResponse(Member member) { - String accessToken = jwtUtil.generateAccessToken( - member.getMemberId(), - member.getEmail(), - member.getRole() - ); - String refreshToken = jwtUtil.generateRefreshToken(member.getMemberId()); + String tempToken = socialSignUpTempService.createTempToken(providerId); - return new LoginResponseDto( - accessToken, - refreshToken, - member.getMemberId(), - member.getEmail(), - member.getNickname(), - member.getRole() + return SocialLoginResponseDto.newMember( + email, + "APPLE", + tempToken, + request.fullName() ); } } \ No newline at end of file diff --git a/src/main/java/com/earseo/member/service/oauth/SocialSignUpTempService.java b/src/main/java/com/earseo/member/service/oauth/SocialSignUpTempService.java new file mode 100644 index 0000000..1ca5fe6 --- /dev/null +++ b/src/main/java/com/earseo/member/service/oauth/SocialSignUpTempService.java @@ -0,0 +1,31 @@ +package com.earseo.member.service.oauth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class SocialSignUpTempService { + + private final StringRedisTemplate stringRedisTemplate; + private static final String PREFIX = "social:signup:"; + private static final Duration TTL = Duration.ofMinutes(30); + + public String createTempToken(String providerId) { + String tempToken = UUID.randomUUID().toString(); + stringRedisTemplate.opsForValue().set(PREFIX + tempToken, providerId, TTL); + return tempToken; + } + + public String getProviderId(String tempToken) { + return stringRedisTemplate.opsForValue().get(PREFIX + tempToken); + } + + public void deleteTempToken(String tempToken) { + stringRedisTemplate.delete(PREFIX + tempToken); + } +}