Skip to content
2 changes: 1 addition & 1 deletion k8s/helm-value.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
image:
tag: v0.1.12
tag: v0.1.13
env:
JWT_ACCESS_TOKEN_EXPIRATION: "1800000"
JWT_REFRESH_TOKEN_EXPIRATION: "1209600000"
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ public ResponseEntity<BaseResponse<SocialLoginResponseDto>> googleCallback(
return ResponseEntity.ok(BaseResponse.ok(response));
}


@Operation(summary = "소셜 로그인 추가 정보 입력", description = "신규 소셜 회원 추가 정보 입력 및 회원가입 완료")
@PostMapping("/oauth/additional-info")
public ResponseEntity<BaseResponse<LoginResponseDto>> completeSocialSignUp(
Expand Down Expand Up @@ -104,9 +103,9 @@ public ResponseEntity<BaseResponse<Void>> resetPassword(

@Operation(summary = "애플 로그인", description = "Apple identityToken으로 로그인/회원가입 처리")
@PostMapping("/oauth/apple")
public ResponseEntity<BaseResponse<LoginResponseDto>> appleLogin(
public ResponseEntity<BaseResponse<SocialLoginResponseDto>> appleLogin(
@RequestBody AppleLoginRequestDto request) {
LoginResponseDto response = appleLoginService.login(request);
SocialLoginResponseDto response = appleLoginService.login(request);
return ResponseEntity.ok(BaseResponse.ok(response));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
20 changes: 19 additions & 1 deletion src/main/java/com/earseo/member/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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());

Expand All @@ -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())
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Member> existingAppleMember = memberRepository
.findByProviderAndProviderId(Provider.APPLE, providerId);

if (existingAppleMember.isPresent()) {
// 기존 Apple 사용자는 로그인 처리
return generateLoginResponse(existingAppleMember.get());
}

if (email != null) {
Optional<Member> 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<Member> 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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading