diff --git a/k8s/helm-value.yaml b/k8s/helm-value.yaml index b8b8d13..195949d 100644 --- a/k8s/helm-value.yaml +++ b/k8s/helm-value.yaml @@ -1,5 +1,5 @@ image: - tag: v0.1.10 + tag: v0.1.11 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/config/AppleProperties.java b/src/main/java/com/earseo/member/common/config/AppleProperties.java new file mode 100644 index 0000000..48687b7 --- /dev/null +++ b/src/main/java/com/earseo/member/common/config/AppleProperties.java @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/java/com/earseo/member/common/config/OAuthConfig.java b/src/main/java/com/earseo/member/common/config/OAuthConfig.java new file mode 100644 index 0000000..783601b --- /dev/null +++ b/src/main/java/com/earseo/member/common/config/OAuthConfig.java @@ -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 { +} \ 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 68d83cd..80a4741 100644 --- a/src/main/java/com/earseo/member/common/exception/MemberErrorCode.java +++ b/src/main/java/com/earseo/member/common/exception/MemberErrorCode.java @@ -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; diff --git a/src/main/java/com/earseo/member/controller/AuthController.java b/src/main/java/com/earseo/member/controller/AuthController.java index 56c6f5f..95375c8 100644 --- a/src/main/java/com/earseo/member/controller/AuthController.java +++ b/src/main/java/com/earseo/member/controller/AuthController.java @@ -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; @@ -15,6 +16,7 @@ @RequiredArgsConstructor public class AuthController { private final AuthService authService; + private final AppleLoginService appleLoginService; @Operation(summary = "회원가입", description = "이메일/비밀번호 기반 회원가입") @PostMapping("/signup") @@ -99,4 +101,12 @@ public ResponseEntity> resetPassword( authService.resetPassword(request); return ResponseEntity.ok(BaseResponse.ok(null)); } + + @Operation(summary = "애플 로그인", description = "Apple identityToken으로 로그인/회원가입 처리") + @PostMapping("/oauth/apple") + public ResponseEntity> appleLogin( + @RequestBody AppleLoginRequestDto request) { + LoginResponseDto response = appleLoginService.login(request); + return ResponseEntity.ok(BaseResponse.ok(response)); + } } diff --git a/src/main/java/com/earseo/member/dto/.gitkeep b/src/main/java/com/earseo/member/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/earseo/member/dto/request/.gitkeep b/src/main/java/com/earseo/member/dto/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/earseo/member/dto/request/AppleLoginRequestDto.java b/src/main/java/com/earseo/member/dto/request/AppleLoginRequestDto.java new file mode 100644 index 0000000..fa3ae07 --- /dev/null +++ b/src/main/java/com/earseo/member/dto/request/AppleLoginRequestDto.java @@ -0,0 +1,7 @@ +package com.earseo.member.dto.request; + +public record AppleLoginRequestDto( + String identityToken, + String fullName +) { +} diff --git a/src/main/java/com/earseo/member/dto/response/.gitkeep b/src/main/java/com/earseo/member/dto/response/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/earseo/member/dto/response/ApplePublicKeyResponseDto.java b/src/main/java/com/earseo/member/dto/response/ApplePublicKeyResponseDto.java new file mode 100644 index 0000000..66eb6eb --- /dev/null +++ b/src/main/java/com/earseo/member/dto/response/ApplePublicKeyResponseDto.java @@ -0,0 +1,17 @@ +package com.earseo.member.dto.response; + +import java.util.List; + +public record ApplePublicKeyResponseDto( + List keys +) { + public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e + ) { + } +} diff --git a/src/main/java/com/earseo/member/entity/Member.java b/src/main/java/com/earseo/member/entity/Member.java index 21f99b6..0c5977f 100644 --- a/src/main/java/com/earseo/member/entity/Member.java +++ b/src/main/java/com/earseo/member/entity/Member.java @@ -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; diff --git a/src/main/java/com/earseo/member/repository/.gitkeep b/src/main/java/com/earseo/member/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/earseo/member/repository/MemberRepository.java b/src/main/java/com/earseo/member/repository/MemberRepository.java index 7eb73fe..b6e68b9 100644 --- a/src/main/java/com/earseo/member/repository/MemberRepository.java +++ b/src/main/java/com/earseo/member/repository/MemberRepository.java @@ -18,4 +18,7 @@ public interface MemberRepository extends JpaRepository { boolean existsByNickname(String nickname); Optional findByEmailAndProvider(String email, Provider provider); + + // 애플 소셜 로그인용 - providerId로 조회 + Optional findByProviderAndProviderId(Provider provider, String providerId); } diff --git a/src/main/java/com/earseo/member/service/.gitkeep b/src/main/java/com/earseo/member/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/earseo/member/service/EmailService.java b/src/main/java/com/earseo/member/service/EmailService.java index b332a37..b0c8dd8 100644 --- a/src/main/java/com/earseo/member/service/EmailService.java +++ b/src/main/java/com/earseo/member/service/EmailService.java @@ -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 { @@ -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); } } diff --git a/src/main/java/com/earseo/member/service/EmailVerificationService.java b/src/main/java/com/earseo/member/service/EmailVerificationService.java index ab39cb6..a7b764e 100644 --- a/src/main/java/com/earseo/member/service/EmailVerificationService.java +++ b/src/main/java/com/earseo/member/service/EmailVerificationService.java @@ -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 { @@ -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); } /** @@ -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); } /** @@ -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); } /** @@ -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); } } \ No newline at end of file diff --git a/src/main/java/com/earseo/member/service/RefreshTokenService.java b/src/main/java/com/earseo/member/service/RefreshTokenService.java index 5fc7431..23958cd 100644 --- a/src/main/java/com/earseo/member/service/RefreshTokenService.java +++ b/src/main/java/com/earseo/member/service/RefreshTokenService.java @@ -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 { @@ -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); } /** @@ -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); } /** diff --git a/src/main/java/com/earseo/member/service/S3Service.java b/src/main/java/com/earseo/member/service/S3Service.java index c2bdef2..35162fe 100644 --- a/src/main/java/com/earseo/member/service/S3Service.java +++ b/src/main/java/com/earseo/member/service/S3Service.java @@ -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("파일 업로드에 실패했습니다."); } @@ -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); } diff --git a/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java b/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java new file mode 100644 index 0000000..80bab51 --- /dev/null +++ b/src/main/java/com/earseo/member/service/oauth/AppleLoginService.java @@ -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 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); + } + } + + 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() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/earseo/member/service/oauth/ApplePublicKeyService.java b/src/main/java/com/earseo/member/service/oauth/ApplePublicKeyService.java new file mode 100644 index 0000000..9de81ec --- /dev/null +++ b/src/main/java/com/earseo/member/service/oauth/ApplePublicKeyService.java @@ -0,0 +1,31 @@ +package com.earseo.member.service.oauth; + +import com.earseo.member.common.config.AppleProperties; +import com.earseo.member.common.exception.BaseException; +import com.earseo.member.common.exception.MemberErrorCode; +import com.earseo.member.dto.response.ApplePublicKeyResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ApplePublicKeyService { + private final AppleProperties appleProperties; + private final RestClient restClient; + + public ApplePublicKeyResponseDto getApplePublicKeys() { + try { + return restClient.get() + .uri(appleProperties.publicKeyUrl()) + .retrieve() + .body(ApplePublicKeyResponseDto.class); + } catch (RestClientException e) { + log.error("Apple 공개키 서버 호출 실패: ", e); + throw new BaseException(MemberErrorCode.APPLE_SERVER_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/earseo/member/service/oauth/AppleTokenVerifier.java b/src/main/java/com/earseo/member/service/oauth/AppleTokenVerifier.java new file mode 100644 index 0000000..a5b375e --- /dev/null +++ b/src/main/java/com/earseo/member/service/oauth/AppleTokenVerifier.java @@ -0,0 +1,101 @@ +package com.earseo.member.service.oauth; + +import com.earseo.member.common.config.AppleProperties; +import com.earseo.member.common.exception.BaseException; +import com.earseo.member.common.exception.MemberErrorCode; +import com.earseo.member.dto.response.ApplePublicKeyResponseDto.ApplePublicKey; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleTokenVerifier { + + private final ApplePublicKeyService applePublicKeyService; + private final AppleProperties appleProperties; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public Claims verifyAndGetClaims(String identityToken) { + try { + Map header = parseHeader(identityToken); + String kid = header.get("kid"); + String alg = header.get("alg"); + + ApplePublicKey matchedKey = applePublicKeyService.getApplePublicKeys() + .keys() + .stream() + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findFirst() + .orElseThrow(() -> new BaseException(MemberErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND)); + + PublicKey publicKey = generatePublicKey(matchedKey); + + return Jwts.parser() + .verifyWith(publicKey) + .requireIssuer("https://appleid.apple.com") + .requireAudience(appleProperties.clientId()) + .build() + .parseSignedClaims(identityToken) + .getPayload(); + + } catch (ExpiredJwtException e) { + throw new BaseException(MemberErrorCode.APPLE_TOKEN_EXPIRED); + } catch (JwtException e) { + throw new BaseException(MemberErrorCode.APPLE_TOKEN_INVALID); + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("Apple 토큰 처리 중 오류: ", e); + throw new BaseException(MemberErrorCode.APPLE_TOKEN_INVALID); + } + } + + private Map parseHeader(String token) { + try { + String headerPart = token.split("\\.")[0]; + byte[] decodedBytes = Base64.getUrlDecoder().decode(headerPart); + String headerJson = new String(decodedBytes); + + Map headerMap = objectMapper.readValue(headerJson, Map.class); + + return Map.of( + "kid", headerMap.get("kid"), + "alg", headerMap.get("alg") + ); + } catch (Exception e) { + throw new BaseException(MemberErrorCode.APPLE_TOKEN_INVALID); + } + } + + private PublicKey generatePublicKey(ApplePublicKey applePublicKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return keyFactory.generatePublic(spec); + } catch (Exception e) { + log.error("Apple 공개키 생성 실패: ", e); + throw new BaseException(MemberErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/earseo/member/util/.gitkeep b/src/main/java/com/earseo/member/util/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 4df8016..203f905 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -29,6 +29,13 @@ spring: oauth: + apple: + team-id: ${APPLE_TEAM_ID} + client-id: ${APPLE_CLIENT_ID} + key-id: ${APPLE_KEY_ID} + redirect-uri: ${APPLE_REDIRECT_URI} + private-key: ${APPLE_PRIVATE_KEY} + public-key-url: https://appleid.apple.com/auth/keys google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET}