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
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.10
tag: v0.1.11
env:
JWT_ACCESS_TOKEN_EXPIRATION: "1800000"
JWT_REFRESH_TOKEN_EXPIRATION: "1209600000"
14 changes: 14 additions & 0 deletions src/main/java/com/earseo/member/common/config/AppleProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.earseo.member.common.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "oauth.apple")
public record AppleProperties(
String teamId,
String clientId,
String keyId,
String redirectUri,
String privateKey,
String publicKeyUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.earseo.member.common.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(AppleProperties.class)
public class OAuthConfig {
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ public enum MemberErrorCode implements ErrorCodeInterface {
INVALID_VERIFICATION_CODE("MEM011", "유효하지 않은 인증코드입니다.", HttpStatus.BAD_REQUEST),
VERIFICATION_CODE_EXPIRED("MEM012", "인증코드가 만료되었습니다.", HttpStatus.BAD_REQUEST),
EMAIL_NOT_VERIFIED("MEM013", "이메일 인증이 완료되지 않았습니다.", HttpStatus.BAD_REQUEST),
SAME_AS_CURRENT_PASSWORD( "MEM014", "기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.", HttpStatus.BAD_REQUEST);
SAME_AS_CURRENT_PASSWORD( "MEM014", "기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.", HttpStatus.BAD_REQUEST),

// Apple 로그인 관련
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);

private final String status;
private final String message;
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/earseo/member/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.earseo.member.dto.request.*;
import com.earseo.member.dto.response.*;
import com.earseo.member.service.AuthService;
import com.earseo.member.service.oauth.AppleLoginService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -15,6 +16,7 @@
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final AppleLoginService appleLoginService;

@Operation(summary = "회원가입", description = "이메일/비밀번호 기반 회원가입")
@PostMapping("/signup")
Expand Down Expand Up @@ -99,4 +101,12 @@ public ResponseEntity<BaseResponse<Void>> resetPassword(
authService.resetPassword(request);
return ResponseEntity.ok(BaseResponse.ok(null));
}

@Operation(summary = "애플 로그인", description = "Apple identityToken으로 로그인/회원가입 처리")
@PostMapping("/oauth/apple")
public ResponseEntity<BaseResponse<LoginResponseDto>> appleLogin(
@RequestBody AppleLoginRequestDto request) {
LoginResponseDto response = appleLoginService.login(request);
return ResponseEntity.ok(BaseResponse.ok(response));
}
}
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.earseo.member.dto.request;

public record AppleLoginRequestDto(
String identityToken,
String fullName
) {
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.earseo.member.dto.response;

import java.util.List;

public record ApplePublicKeyResponseDto(
List<ApplePublicKey> keys
) {
public record ApplePublicKey(
String kty,
String kid,
String use,
String alg,
String n,
String e
) {
}
}
3 changes: 3 additions & 0 deletions src/main/java/com/earseo/member/entity/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public class Member extends BaseEntity{
@Column(nullable = false)
private Provider provider;

@Column(length = 255)
private String providerId;

@Column(length = 50, nullable = false, unique = true)
private String nickname;

Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
boolean existsByNickname(String nickname);

Optional<Member> findByEmailAndProvider(String email, Provider provider);

// 애플 소셜 로그인용 - providerId로 조회
Optional<Member> findByProviderAndProviderId(Provider provider, String providerId);
}
Empty file.
4 changes: 0 additions & 4 deletions src/main/java/com/earseo/member/service/EmailService.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
Expand All @@ -32,9 +30,7 @@ public void sendVerificationCode(String to, String code) {
helper.setText(buildEmailContent(code), true);

mailSender.send(message);
log.info("인증코드 이메일 발송 완료 - to: {}", to);
} catch (MessagingException e) {
log.error("이메일 발송 실패 - to: {}", to, e);
throw new BaseException(MemberErrorCode.EMAIL_SEND_FAILED);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
package com.earseo.member.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailVerificationService {
Expand All @@ -35,28 +32,14 @@ public String generateCode() {
public void saveCode(String email, String code) {
String key = VERIFICATION_PREFIX + email;
stringTemplate.opsForValue().set(key, code, EXPIRATION_MINUTES, TimeUnit.MINUTES);
log.info("인증코드 저장 완료 - email: {}", email);
}

/**
* 인증코드 검증
*/
public boolean verifyCode(String email, String code) {
String key = VERIFICATION_PREFIX + email;
String storedCode = stringTemplate.opsForValue().get(key);

if (storedCode == null) {
log.warn("인증코드 없음 또는 만료 - email: {}", email);
return false;
}

if (storedCode.equals(code)) {
log.info("인증코드 검증 성공 - email: {}", email);
return true;
}

log.warn("인증코드 불일치 - email: {}", email);
return false;
String storedCode = stringTemplate.opsForValue().get(VERIFICATION_PREFIX + email);
return storedCode != null && storedCode.equals(code);
}

/**
Expand All @@ -65,7 +48,6 @@ public boolean verifyCode(String email, String code) {
public void deleteCode(String email) {
String key = VERIFICATION_PREFIX + email;
stringTemplate.delete(key);
log.info("인증코드 삭제 완료 - email: {}", email);
}

/**
Expand All @@ -82,7 +64,6 @@ public boolean hasCode(String email) {
public void saveVerified(String email) {
String key = VERIFIED_PREFIX + email;
stringTemplate.opsForValue().set(key, "true", VERIFIED_EXPIRATION_MINUTES, TimeUnit.MINUTES);
log.info("이메일 인증 완료 상태 저장 - email: {}", email);
}

/**
Expand All @@ -99,6 +80,5 @@ public boolean isVerified(String email) {
public void deleteVerified(String email) {
String key = VERIFIED_PREFIX + email;
stringTemplate.delete(key);
log.info("이메일 인증 완료 상태 삭제 - email: {}", email);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package com.earseo.member.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
Expand All @@ -22,7 +20,6 @@ public class RefreshTokenService {
public void saveRefreshToken(Long memberId, String refreshToken, long expirationMs) {
String key = REFRESH_TOKEN_PREFIX + memberId;
stringTemplate.opsForValue().set(key, refreshToken, expirationMs, TimeUnit.MILLISECONDS);
log.info("Refresh Token 저장 완료 - memberId: {}", memberId);
}

/**
Expand All @@ -46,12 +43,7 @@ public boolean hasRefreshToken(Long memberId) {
*/
public void deleteRefreshToken(Long memberId) {
String key = REFRESH_TOKEN_PREFIX + memberId;
Boolean deleted = stringTemplate.delete(key);
if (Boolean.TRUE.equals(deleted)) {
log.info("Refresh Token 삭제 완료 - memberId: {}", memberId);
} else {
log.warn("Refresh Token 삭제 실패 또는 존재하지 않음 - memberId: {}", memberId);
}
stringTemplate.delete(key);
}

/**
Expand Down
3 changes: 0 additions & 3 deletions src/main/java/com/earseo/member/service/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public String uploadFile(MultipartFile file, String directory) {
try {
amazonS3.putObject(new PutObjectRequest(bucket, savedFilename, file.getInputStream(), metadata));
} catch (IOException e) {
log.error("S3 파일 업로드 실패: {}", e.getMessage());
throw new RuntimeException("파일 업로드에 실패했습니다.");
}

Expand All @@ -57,11 +56,9 @@ public void deleteFile(String fileUrl) {
try {
String key = extractKeyFromUrl(fileUrl);
if (key == null || key.isEmpty()) {
log.warn("알 수 없는 파일 URL 형식입니다: {}", fileUrl);
return;
}
amazonS3.deleteObject(new DeleteObjectRequest(bucket, key));
log.info("S3 파일 삭제 완료: {}", key);
} catch (Exception e) {
log.error("S3 파일 삭제 실패: {}", e.getMessage(), e);
}
Expand Down
100 changes: 100 additions & 0 deletions src/main/java/com/earseo/member/service/oauth/AppleLoginService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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.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;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class AppleLoginService {

private final AppleTokenVerifier appleTokenVerifier;
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;

@Transactional
public LoginResponseDto 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);
}
}

Member newMember = createAppleMember(providerId, email, request.fullName());
Member savedMember = memberRepository.save(newMember);

return generateLoginResponse(savedMember);
}

private Member createAppleMember(String providerId, String email, String fullName) {
String memberEmail = email != null ? email : providerId + "@apple.private";

// 닉네임 중복 방지를 위해 항상 UUID 붙이기
String baseNickname = (fullName != null && !fullName.isBlank())
? fullName
: "User";
String nickname = baseNickname + "_" + UUID.randomUUID().toString().substring(0, 8);

// 혹시 중복이면 다시 생성
while (memberRepository.existsByNickname(nickname)) {
nickname = baseNickname + "_" + UUID.randomUUID().toString().substring(0, 8);
}

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());

return new LoginResponseDto(
accessToken,
refreshToken,
member.getMemberId(),
member.getEmail(),
member.getNickname(),
member.getRole()
);
}
}
Loading
Loading