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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package makeus.cmc.malmo.adaptor.in.web.controller;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import makeus.cmc.malmo.application.port.in.member.AppleNotificationUseCase;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
* Apple Sign in with Apple Server-to-Server 알림을 처리하는 Webhook 컨트롤러
*
* @see <a href="https://developer.apple.com/documentation/sign_in_with_apple/processing_changes_for_sign_in_with_apple_accounts">Apple Documentation</a>
*/
@Slf4j
@Tag(name = "Apple Webhook", description = "Apple Server-to-Server 알림 처리")
@RestController
@RequestMapping("/webhook/apple")
@RequiredArgsConstructor
public class AppleNotificationController {

private final AppleNotificationUseCase appleNotificationUseCase;

/**
* JSON 형식의 Apple 알림을 처리합니다.
* Content-Type: application/json
*/
@Operation(
summary = "Apple Server-to-Server 알림 수신 (JSON)",
description = "Sign in with Apple 계정 변경 알림을 JSON 형식으로 처리합니다."
)
@PostMapping(value = "/notifications", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> handleNotificationJson(
@RequestBody AppleNotificationRequest request
) {
return processPayload(request.getSignedPayload());
}

/**
* Form URL Encoded 형식의 Apple 알림을 처리합니다.
* Content-Type: application/x-www-form-urlencoded
*/
@Operation(
summary = "Apple Server-to-Server 알림 수신 (Form)",
description = "Sign in with Apple 계정 변경 알림을 Form 형식으로 처리합니다."
)
@PostMapping(value = "/notifications", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<Void> handleNotificationForm(
@RequestParam("signed_payload") String signedPayload
) {
return processPayload(signedPayload);
}

/**
* 공통 페이로드 처리 로직
* Apple의 재시도를 방지하기 위해 항상 200 OK를 반환합니다.
*/
private ResponseEntity<Void> processPayload(String signedPayload) {
if (signedPayload == null || signedPayload.isBlank()) {
log.warn("Apple notification received with empty payload");
return ResponseEntity.ok().build();
}

try {
log.info("Processing Apple notification");
appleNotificationUseCase.processNotification(signedPayload);
} catch (Exception e) {
// Apple의 재시도를 방지하기 위해 예외가 발생해도 200 OK 반환
log.error("Failed to process Apple notification: {}", e.getMessage(), e);
}

return ResponseEntity.ok().build();
}

@Data
public static class AppleNotificationRequest {
@JsonProperty("signed_payload")
private String signedPayload;
}
}



Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package makeus.cmc.malmo.adaptor.out.oidc;

import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.JwkProviderBuilder;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import makeus.cmc.malmo.adaptor.out.exception.OidcIdTokenException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.net.URL;
import java.security.interfaces.RSAPublicKey;
import java.util.Date;
import java.util.concurrent.TimeUnit;

/**
* Apple Server-to-Server Notification JWT를 검증하고 파싱합니다.
*/
@Slf4j
@Component
public class AppleNotificationValidator {

private final JwkProvider jwkProvider;
private final ObjectMapper objectMapper;
private final String expectedAudience;

private static final String APPLE_ISS = "https://appleid.apple.com";
private static final String JWKS_URI = "https://appleid.apple.com/auth/keys";

/**
* 프로덕션용 생성자 - Apple JWKS URI에서 키를 가져옵니다.
*/
@Autowired
public AppleNotificationValidator(
ObjectMapper objectMapper,
@Value("${apple.oidc.aud}") String expectedAudience) {
try {
this.jwkProvider = new JwkProviderBuilder(new URL(JWKS_URI))
.cached(10, 60, TimeUnit.MINUTES)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build();
this.objectMapper = objectMapper;
this.expectedAudience = expectedAudience;
} catch (Exception e) {
throw new RuntimeException("Failed to initialize AppleNotificationValidator", e);
}
}

/**
* 테스트용 생성자 - JwkProvider를 주입받습니다.
* 주로 단위 테스트에서 mock을 주입할 때 사용합니다.
*/
AppleNotificationValidator(
JwkProvider jwkProvider,
ObjectMapper objectMapper,
String expectedAudience) {
this.jwkProvider = jwkProvider;
this.objectMapper = objectMapper;
this.expectedAudience = expectedAudience;
}

/**
* Apple Server-to-Server 알림 JWT를 검증하고 파싱합니다.
*
* @param signedPayload Apple이 보낸 JWT 형식의 서명된 페이로드
* @return 파싱된 알림 클레임
* @throws OidcIdTokenException 검증 실패 시
*/
public AppleNotificationClaims validateAndParse(String signedPayload) {
try {
DecodedJWT jwt = JWT.decode(signedPayload);

// 1. JWKS를 사용한 서명 검증
Jwk jwk = jwkProvider.get(jwt.getKeyId());
Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);
algorithm.verify(jwt);

// 2. issuer 검증 (반드시 Apple이어야 함)
if (!APPLE_ISS.equals(jwt.getIssuer())) {
throw new OidcIdTokenException("Invalid issuer for Apple notification");
}

// 3. audience 검증 (앱의 client_id)
if (!jwt.getAudience().contains(expectedAudience)) {
throw new OidcIdTokenException("Invalid audience for Apple notification");
}

// 4. 만료 시간 검증
if (jwt.getExpiresAt() != null && jwt.getExpiresAt().before(new Date())) {
throw new OidcIdTokenException("Expired Apple notification token");
}

// 5. events 클레임 파싱
String eventsPayload = jwt.getClaim("events").asString();
JsonNode eventsNode = objectMapper.readTree(eventsPayload);

return AppleNotificationClaims.builder()
.jti(jwt.getId())
.iat(jwt.getIssuedAt() != null ? jwt.getIssuedAt().getTime() : null)
.eventType(eventsNode.get("type").asText())
.sub(eventsNode.get("sub").asText())
.eventTime(eventsNode.has("event_time")
? eventsNode.get("event_time").asLong() : null)
.email(eventsNode.has("email")
? eventsNode.get("email").asText() : null)
.isPrivateEmail(eventsNode.has("is_private_email")
? eventsNode.get("is_private_email").asBoolean() : null)
.build();

} catch (OidcIdTokenException e) {
throw e;
} catch (Exception e) {
log.error("Failed to validate Apple notification JWT: {}", e.getMessage(), e);
throw new OidcIdTokenException("Failed to validate Apple notification", e);
}
}

/**
* Apple 알림에서 파싱된 클레임
*/
@Getter
@Builder
public static class AppleNotificationClaims {
private final String jti; // JWT ID (중복 방지용)
private final Long iat; // 발행 시간
private final String eventType; // email-enabled, consent-revoked, account-delete 등
private final String sub; // 사용자의 Apple providerId
private final Long eventTime; // 이벤트 발생 시간
private final String email; // 변경된 이메일 (있는 경우)
private final Boolean isPrivateEmail;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import makeus.cmc.malmo.adaptor.out.persistence.entity.BaseTimeEntity;
import makeus.cmc.malmo.adaptor.out.persistence.entity.value.CoupleEntityId;
import makeus.cmc.malmo.adaptor.out.persistence.entity.value.InviteCodeEntityValue;
import makeus.cmc.malmo.domain.value.state.MemberState;
import makeus.cmc.malmo.domain.value.type.EmailForwardingStatus;
import makeus.cmc.malmo.domain.value.type.LoveTypeCategory;
import makeus.cmc.malmo.domain.value.type.MemberRole;
import makeus.cmc.malmo.domain.value.type.Provider;
Expand Down Expand Up @@ -54,6 +56,10 @@ public class MemberEntity extends BaseTimeEntity {

private String email;

@Builder.Default
@Enumerated(value = EnumType.STRING)
private EmailForwardingStatus emailForwardingStatus = EmailForwardingStatus.ENABLED;

@Embedded
private InviteCodeEntityValue inviteCodeEntityValue;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public Member toDomain(MemberEntity entity) {
entity.getAnxietyRate(),
entity.getNickname(),
entity.getEmail(),
entity.getEmailForwardingStatus(),
entity.getInviteCodeEntityValue() != null ? InviteCodeValue.of(entity.getInviteCodeEntityValue().getValue()) : null,
entity.getStartLoveDate(),
entity.getOauthToken(),
Expand All @@ -52,6 +53,7 @@ public MemberEntity toEntity(Member domain) {
.avoidanceRate(domain.getAvoidanceRate())
.anxietyRate(domain.getAnxietyRate())
.email(domain.getEmail())
.emailForwardingStatus(domain.getEmailForwardingStatus())
.nickname(domain.getNickname())
.inviteCodeEntityValue(
domain.getInviteCode() != null ? InviteCodeEntityValue.of(domain.getInviteCode().getValue()) : null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package makeus.cmc.malmo.adaptor.out.redis;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;

/**
* Apple Server-to-Server Notification의 jti(JWT ID) 중복 처리 방지를 위한 Redis 저장소
* SETNX + TTL을 사용하여 원자적으로 중복 체크와 저장을 수행합니다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AppleNotificationJtiStore {

private final StringRedisTemplate redisTemplate;

private static final String KEY_PREFIX = "apple:notification:jti:";
private static final Duration TTL = Duration.ofDays(1); // Apple 알림은 24시간 내 재시도

/**
* JTI가 이미 처리되었는지 확인하고, 처리되지 않았다면 저장합니다.
* Redis SETNX를 사용하여 원자적으로 수행됩니다.
*
* @param jti Apple 알림의 JWT ID
* @return true if this is a new JTI (should process), false if already processed (should skip)
*/
public boolean tryMarkAsProcessed(String jti) {
String key = KEY_PREFIX + jti;

// SETNX: 키가 없을 때만 설정 (atomic operation)
Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "processed", TTL);

if (Boolean.TRUE.equals(isNew)) {
log.debug("New Apple notification JTI: {}", jti);
return true;
} else {
log.info("Duplicate Apple notification JTI detected: {}", jti);
return false;
}
}
}



Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package makeus.cmc.malmo.application.port.in.member;

/**
* Apple Server-to-Server Notification 처리를 위한 UseCase
*
* @see <a href="https://developer.apple.com/documentation/sign_in_with_apple/processing_changes_for_sign_in_with_apple_accounts">Apple Documentation</a>
*/
public interface AppleNotificationUseCase {

/**
* Apple로부터 수신한 서명된 페이로드를 처리합니다.
*
* @param signedPayload Apple이 보낸 JWT 형식의 서명된 페이로드
*/
void processNotification(String signedPayload);
}



Loading