diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5df43f1..8d1fe36 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @JjungminLee @minhyuk2 \ No newline at end of file +* @JjungminLee @minhyuk2 @bricksky diff --git a/src/main/java/com/usememo/jugger/domain/calendar/dto/PostCalendarDto.java b/src/main/java/com/usememo/jugger/domain/calendar/dto/PostCalendarDto.java index 638ad2a..1231e0b 100644 --- a/src/main/java/com/usememo/jugger/domain/calendar/dto/PostCalendarDto.java +++ b/src/main/java/com/usememo/jugger/domain/calendar/dto/PostCalendarDto.java @@ -15,4 +15,4 @@ public class PostCalendarDto { private String place; private Instant alarm; private String description; -} +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/config/WebClientConfig.java b/src/main/java/com/usememo/jugger/global/config/WebClientConfig.java new file mode 100644 index 0000000..c6815a9 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package com.usememo.jugger.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/src/main/java/com/usememo/jugger/global/exception/BaseException.java b/src/main/java/com/usememo/jugger/global/exception/BaseException.java index 3ab5122..843018e 100644 --- a/src/main/java/com/usememo/jugger/global/exception/BaseException.java +++ b/src/main/java/com/usememo/jugger/global/exception/BaseException.java @@ -4,12 +4,12 @@ @Getter public class BaseException extends RuntimeException { - private final ErrorCode errorCode; - private final String message; + private final ErrorCode errorCode; + private final String message; - public BaseException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - this.message = errorCode.getMessage(); - } + public BaseException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.message = errorCode.getMessage(); + } } diff --git a/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java b/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java index bbfebb2..e0569e8 100644 --- a/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java +++ b/src/main/java/com/usememo/jugger/global/exception/ErrorCode.java @@ -34,14 +34,27 @@ public enum ErrorCode { KAKAO_UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 420, "카카오 로그인 중 알 수 없는 오류가 발생했습니다."), KAKAO_JWT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 421, "카카오 jwt 토큰 제공시 에러"), + + REQUIRED_TERMS_NOT_AGREED(BAD_REQUEST, 429, "필수 약관에 동의하지 않았습니다."), + USER_NOT_FOUND(BAD_REQUEST, 427, "존재하지 않는 회원입니다."), DUPLICATE_USER(BAD_REQUEST, 428, "중복된 회원정보입니다."), - GOOGLE_LOGIN_FAIL(BAD_REQUEST, 404, "로그인에 실패하였습니다."), - GOOGLE_NO_EMAIL(BAD_REQUEST, 404, "이메일이 존재하지 않습니다."), - GOOGLE_USER_NOT_FOUND(BAD_REQUEST, 404, "구글 유저가 존재하지 않습니다."), - GOOGLE_NO_NAME(BAD_REQUEST, 404, "이름이 존재하지 않습니다."), + + GOOGLE_LOGIN_FAIL(BAD_REQUEST,404,"로그인에 실패하였습니다."), + GOOGLE_NO_EMAIL(BAD_REQUEST,404,"이메일이 존재하지 않습니다."), + GOOGLE_USER_NOT_FOUND(BAD_REQUEST,404,"구글 유저가 존재하지 않습니다."), + GOOGLE_NO_NAME(BAD_REQUEST,404,"이름이 존재하지 않습니다."), + + APPLE_USER_NOT_FOUND(BAD_REQUEST, 430, "존재하지 않는 Apple 회원입니다."), + APPLE_TOKEN_REQUEST_FAILED(BAD_REQUEST, 431, "Apple 인가 코드로 토큰을 요청하는 데 실패했습니다."), + APPLE_CLIENT_SECRET_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 432, "Apple client secret 생성에 실패했습니다."), + APPLE_USERINFO_MISSING(BAD_REQUEST, 433, "Apple 사용자 정보가 불완전합니다."), + APPLE_TOKEN_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 434, "Apple id_token 파싱에 실패했습니다."), + APPLE_TOKEN_INVALID(BAD_REQUEST, 435, "Apple 토큰이 유효하지 않습니다."), + + JWT_KEY_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 422, "JWT 키 생성에 실패했습니다."), JWT_ACCESS_TOKEN_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 423, "액세스 토큰 생성 실패"), diff --git a/src/main/java/com/usememo/jugger/global/security/AppleJwtGenerator.java b/src/main/java/com/usememo/jugger/global/security/AppleJwtGenerator.java new file mode 100644 index 0000000..2f3e223 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/AppleJwtGenerator.java @@ -0,0 +1,73 @@ +package com.usememo.jugger.global.security; + +import com.usememo.jugger.global.security.token.domain.AppleProperties; +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class AppleJwtGenerator { + private final AppleProperties appleProperties; + private final ResourceLoader resourceLoader; + private static final String APPLE_AUDIENCE = "https://appleid.apple.com"; + + @Autowired + public AppleJwtGenerator(AppleProperties appleProperties, ResourceLoader resourceLoader) { + this.appleProperties = appleProperties; + this.resourceLoader = resourceLoader; + } + + public String createClientSecret() + throws java.io.IOException, + NoSuchAlgorithmException, + InvalidKeySpecException{ + Date now = new Date(); + Date expiration = new Date(now.getTime() + 3600_000); + + return Jwts.builder() + .header() + .keyId(appleProperties.getKeyId()) + .and() + .subject(appleProperties.getClientId()) + .issuer(appleProperties.getTeamId()) + .audience() + .add(APPLE_AUDIENCE) + .and() + .expiration(expiration) + .signWith(getPrivateKey(), Jwts.SIG.ES256) + .compact(); + } + + private PrivateKey getPrivateKey() + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + Resource resource = resourceLoader.getResource(appleProperties.getPrivateKeyLocation()); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(resource.getInputStream()))) { + String keyContent = reader.lines().collect(Collectors.joining("\n")); + String key = + keyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + byte[] encoded = Base64.getDecoder().decode(key); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } + } +} diff --git a/src/main/java/com/usememo/jugger/global/security/ApplePublicKeyProvider.java b/src/main/java/com/usememo/jugger/global/security/ApplePublicKeyProvider.java new file mode 100644 index 0000000..82e17f2 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/ApplePublicKeyProvider.java @@ -0,0 +1,72 @@ +package com.usememo.jugger.global.security; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +@Slf4j +@Component +public class ApplePublicKeyProvider { + + private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"; + private static final Duration CACHE_DURATION = Duration.ofDays(15); + + private final Map keyCache = new ConcurrentHashMap<>(); + private volatile Instant lastCacheTime = Instant.MIN; + private final ReadWriteLock cacheLock = new ReentrantReadWriteLock(); + + public JWK getKeyById(String keyId) { + + try { + // 캐시 확인 및 만료 체크 + cacheLock.readLock().lock(); + try { + if (keyCache.containsKey(keyId) && !isCacheExpired()) { + return keyCache.get(keyId); + } + } finally { + cacheLock.readLock().unlock(); + } + + // 캐시 갱신 + cacheLock.writeLock().lock(); + try { + // 다른 스레드가 갱신했는지 재확인 + if (keyCache.containsKey(keyId) && !isCacheExpired()) { + return keyCache.get(keyId); + } + // Apple 서버에서 공개키 가져오기 + JWKSet jwkSet = JWKSet.load(new URL(APPLE_KEYS_URL)); + for (JWK key : jwkSet.getKeys()) { + keyCache.put(key.getKeyID(), key); + } + lastCacheTime = Instant.now(); + } finally { + cacheLock.writeLock().unlock(); + } + + JWK foundKey = keyCache.get(keyId); + if (foundKey == null) { + throw new IllegalArgumentException("Apple 공개키에서 keyId를 찾을 수 없습니다: " + keyId); + } + + return foundKey; + } catch (Exception e) { + log.error("Apple 공개키 로딩 실패", e); + throw new IllegalArgumentException("Apple 공개키 로딩 실패", e); + } + } + + private boolean isCacheExpired() { + return Duration.between(lastCacheTime, Instant.now()).compareTo(CACHE_DURATION) > 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/JwtValidator.java b/src/main/java/com/usememo/jugger/global/security/JwtValidator.java new file mode 100644 index 0000000..6e1d25a --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/JwtValidator.java @@ -0,0 +1,68 @@ +package com.usememo.jugger.global.security; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jwt.SignedJWT; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.text.ParseException; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtValidator { + + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + + private final ApplePublicKeyProvider keyProvider; + + @Value("${apple.client-id}") + private String clientId; + + public SignedJWT validate(String idToken) { + try { + SignedJWT jwt = SignedJWT.parse(idToken); + JWSHeader header = jwt.getHeader(); + + // 알고리즘 확인 + if (!JWSAlgorithm.RS256.equals(header.getAlgorithm())) { + throw new IllegalArgumentException("Unexpected JWS algorithm: " + header.getAlgorithm()); + } + + // 공개키로 서명 검증 + var jwk = keyProvider.getKeyById(header.getKeyID()); + JWSVerifier verifier = new RSASSAVerifier(jwk.toRSAKey()); + + if (!jwt.verify(verifier)) { + throw new IllegalArgumentException("Apple ID Token 서명 검증 실패"); + } + + // 클레임 검증 + var claims = jwt.getJWTClaimsSet(); + Date now = new Date(); + + if (claims.getExpirationTime() == null || now.after(claims.getExpirationTime())) { + throw new IllegalArgumentException("Apple ID Token 만료됨"); + } + + if (!APPLE_ISSUER.equals(claims.getIssuer())) { + throw new IllegalArgumentException("잘못된 iss: " + claims.getIssuer()); + } + + if (!claims.getAudience().contains(clientId)) { + throw new IllegalArgumentException("잘못된 aud: " + claims.getAudience()); + } + + return jwt; + + } catch (ParseException e) { + throw new IllegalArgumentException("Apple ID Token 파싱 실패", e); + } catch (Exception e) { + throw new IllegalArgumentException("Apple ID Token 검증 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/token/controller/AuthController.java b/src/main/java/com/usememo/jugger/global/security/token/controller/AuthController.java index e869b43..a7bd7d1 100644 --- a/src/main/java/com/usememo/jugger/global/security/token/controller/AuthController.java +++ b/src/main/java/com/usememo/jugger/global/security/token/controller/AuthController.java @@ -1,5 +1,17 @@ package com.usememo.jugger.global.security.token.controller; +import static com.fasterxml.jackson.databind.type.LogicalType.*; + +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +import com.usememo.jugger.global.security.token.domain.*; +import com.usememo.jugger.global.security.token.service.AppleOAuthService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -8,6 +20,13 @@ import com.usememo.jugger.global.exception.BaseException; import com.usememo.jugger.global.exception.ErrorCode; + +import com.usememo.jugger.global.exception.KakaoException; +import com.usememo.jugger.global.security.CustomOAuth2User; +import com.usememo.jugger.global.security.JwtTokenProvider; + +import com.usememo.jugger.global.security.token.repository.RefreshTokenRepository; + import com.usememo.jugger.global.security.token.domain.GoogleLoginRequest; import com.usememo.jugger.global.security.token.domain.GoogleSignupRequest; import com.usememo.jugger.global.security.token.domain.KakaoLoginRequest; @@ -17,6 +36,7 @@ import com.usememo.jugger.global.security.token.domain.NewTokenResponse; import com.usememo.jugger.global.security.token.domain.RefreshTokenRequest; import com.usememo.jugger.global.security.token.domain.TokenResponse; + import com.usememo.jugger.global.security.token.service.GoogleOAuthService; import com.usememo.jugger.global.security.token.service.KakaoOAuthService; @@ -33,6 +53,7 @@ public class AuthController { private final KakaoOAuthService kakaoService; private final GoogleOAuthService googleOAuthService; + private final AppleOAuthService appleOAuthService; @Operation(summary = "[POST] refresh token으로 새로운 access token 발급") @PostMapping(value = "/refresh") @@ -82,4 +103,18 @@ public Mono> signUpGoogle(@RequestBody GoogleSignu .map(token -> ResponseEntity.ok().body(token)); } + + @Operation(summary = "[POST] 애플 로그인") + @PostMapping("/apple") + public Mono> loginByApple(@RequestBody AppleLoginRequest appleLoginRequest){ + return appleOAuthService.loginWithApple(appleLoginRequest.code()) + .map(token -> ResponseEntity.ok().body(token)); + } + + @Operation(summary = "[POST] 애플 회원가입") + @PostMapping("/apple/signup") + public Mono> signUpApple(@RequestBody AppleSignUpRequest appleSignUpRequest){ + return appleOAuthService.signUpApple(appleSignUpRequest) + .map(token -> ResponseEntity.ok().body(token)); + } } diff --git a/src/main/java/com/usememo/jugger/global/security/token/domain/AppleLoginRequest.java b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleLoginRequest.java new file mode 100644 index 0000000..bfc0f85 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleLoginRequest.java @@ -0,0 +1,7 @@ +package com.usememo.jugger.global.security.token.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AppleLoginRequest(@Schema(description = "애플 인가 코드", example = "dY94g8JaZ1Ki1s...") + String code) { +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/token/domain/AppleProperties.java b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleProperties.java new file mode 100644 index 0000000..75a02c6 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleProperties.java @@ -0,0 +1,20 @@ +package com.usememo.jugger.global.security.token.domain; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "apple") +public class AppleProperties { + + private String clientId; + private String teamId; + private String keyId; + private String privateKeyLocation; + //private String privateKey; + private String redirectUri; +} diff --git a/src/main/java/com/usememo/jugger/global/security/token/domain/AppleSignUpRequest.java b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleSignUpRequest.java new file mode 100644 index 0000000..74d46b0 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleSignUpRequest.java @@ -0,0 +1,27 @@ +package com.usememo.jugger.global.security.token.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +public record AppleSignUpRequest(@Schema(description = "사용자 이름", example = "홍길동", required = true) + String name, + + @Schema(description = "이메일 주소", example = "hong@example.com", required = true) + String email, + + @Schema(description = "약관 동의 정보", required = true) + KakaoSignUpRequest.Terms terms +) { + @Schema(name = "Terms", description = "약관 동의 항목들") + @Data + public static class Terms { + @Schema(description = "서비스 이용약관 동의 여부", example = "true", required = true) + private boolean termsOfService; + + @Schema(description = "개인정보 처리방침 동의 여부", example = "true", required = true) + private boolean privacyPolicy; + + @Schema(description = "마케팅 정보 수신 동의 여부", example = "false", required = true) + private boolean marketing; + } +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/token/domain/AppleUserResponse.java b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleUserResponse.java new file mode 100644 index 0000000..3588144 --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/token/domain/AppleUserResponse.java @@ -0,0 +1,4 @@ +package com.usememo.jugger.global.security.token.domain; + +public record AppleUserResponse(String sub, String email) { +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/token/domain/RefreshTokenRequest.java b/src/main/java/com/usememo/jugger/global/security/token/domain/RefreshTokenRequest.java index 13d2dc1..a99e29a 100644 --- a/src/main/java/com/usememo/jugger/global/security/token/domain/RefreshTokenRequest.java +++ b/src/main/java/com/usememo/jugger/global/security/token/domain/RefreshTokenRequest.java @@ -1,4 +1,4 @@ package com.usememo.jugger.global.security.token.domain; public record RefreshTokenRequest(String refreshToken) { -} +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/token/service/AppleOAuthService.java b/src/main/java/com/usememo/jugger/global/security/token/service/AppleOAuthService.java new file mode 100644 index 0000000..2f71a4f --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/token/service/AppleOAuthService.java @@ -0,0 +1,71 @@ +package com.usememo.jugger.global.security.token.service; + +import com.usememo.jugger.domain.user.entity.User; +import com.usememo.jugger.domain.user.repository.UserRepository; +import com.usememo.jugger.global.exception.BaseException; +import com.usememo.jugger.global.exception.ErrorCode; +import com.usememo.jugger.global.security.JwtTokenProvider; +import com.usememo.jugger.global.security.token.domain.AppleSignUpRequest; +import com.usememo.jugger.global.security.token.domain.AppleUserResponse; +import com.usememo.jugger.global.security.token.domain.TokenResponse; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.ui.Model; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleOAuthService { + private final AppleTokenService appleTokenService; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + public Mono loginWithApple(String code) { + return appleTokenService.exchangeCodeForTokens(code) + .flatMap(appleTokenService::extractUserFromIdToken) + .flatMap(this::findUser) + .flatMap(user -> jwtTokenProvider.createTokenBundle(user.getUuid())); + } + + private Mono findUser(AppleUserResponse userInfo) { + return userRepository.findByEmailAndDomain(userInfo.email(), "apple") + .switchIfEmpty(Mono.error(new BaseException(ErrorCode.APPLE_USER_NOT_FOUND))); + } + + public Mono signUpApple(AppleSignUpRequest request) { + String email = request.email(); + String name = request.name(); + + return userRepository.findByEmailAndDomainAndName(email, "apple", name) + .flatMap(existingUser -> Mono.error(new BaseException(ErrorCode.DUPLICATE_USER))) + .switchIfEmpty(Mono.defer(() -> { + if (!request.terms().isTermsOfService() || !request.terms().isPrivacyPolicy()) { + return Mono.error(new BaseException(ErrorCode.REQUIRED_TERMS_NOT_AGREED)); + } + String uuid = UUID.randomUUID().toString(); + + User.Terms terms = new User.Terms(); + terms.setMarketing(request.terms().isMarketing()); + terms.setPrivacyPolicy(request.terms().isPrivacyPolicy()); + terms.setTermsOfService(request.terms().isTermsOfService()); + + User user = User.builder() + .uuid(uuid) + .name(name) + .email(email) + .terms(terms) + .domain("apple") + .build(); + + return userRepository.save(user) + .flatMap(savedUser -> jwtTokenProvider.createTokenBundle(savedUser.getUuid())); + })); + } +} \ No newline at end of file diff --git a/src/main/java/com/usememo/jugger/global/security/token/service/AppleTokenService.java b/src/main/java/com/usememo/jugger/global/security/token/service/AppleTokenService.java new file mode 100644 index 0000000..06906ba --- /dev/null +++ b/src/main/java/com/usememo/jugger/global/security/token/service/AppleTokenService.java @@ -0,0 +1,76 @@ +package com.usememo.jugger.global.security.token.service; + +import com.nimbusds.jwt.SignedJWT; +import com.usememo.jugger.global.exception.BaseException; +import com.usememo.jugger.global.exception.ErrorCode; +import com.usememo.jugger.global.security.AppleJwtGenerator; +import com.usememo.jugger.global.security.JwtValidator; +import com.usememo.jugger.global.security.token.domain.AppleProperties; +import com.usememo.jugger.global.security.token.domain.AppleUserResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AppleTokenService { + private final WebClient webClient; + private final AppleJwtGenerator appleJwtGenerator; + private final AppleProperties appleProperties; + private final JwtValidator jwtValidator; + + // 1. 인가 코드로 Apple 토큰 요청 + public Mono> exchangeCodeForTokens(String code) { + return Mono.fromCallable(() -> appleJwtGenerator.createClientSecret()) + .flatMap(clientSecret -> + webClient.post() + .uri("https://appleid.apple.com/auth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("grant_type", "authorization_code") + .with("code", code) + .with("redirect_uri", appleProperties.getRedirectUri()) + .with("client_id", appleProperties.getClientId()) + .with("client_secret", clientSecret)) + .retrieve() + .onStatus(status -> !status.is2xxSuccessful(), + response -> Mono.error(new BaseException(ErrorCode.APPLE_TOKEN_REQUEST_FAILED))) + .bodyToMono(new ParameterizedTypeReference>() { + }) + ) + .onErrorResume(e -> Mono.error(new BaseException(ErrorCode.APPLE_CLIENT_SECRET_FAILED))); + } + + // 2. id_token 검증 및 사용자 정보 추출 + public Mono extractUserFromIdToken(Map tokenMap) { + return Mono.fromCallable(() -> { + String idToken = (String) tokenMap.get("id_token"); + if(idToken == null){ + throw new BaseException(ErrorCode.APPLE_TOKEN_PARSE_ERROR); + } + SignedJWT jwt; + + try{ + jwt = jwtValidator.validate(idToken); + } catch (IllegalArgumentException e){ + throw new BaseException(ErrorCode.APPLE_TOKEN_INVALID); + } + var claims = jwt.getJWTClaimsSet(); + + String email = claims.getStringClaim("email"); + String sub = claims.getSubject(); + + if (sub == null || email == null) { + throw new BaseException(ErrorCode.APPLE_USERINFO_MISSING); + } + + return new AppleUserResponse(sub, email); + }).onErrorResume(e -> Mono.error(new BaseException(ErrorCode.APPLE_TOKEN_PARSE_ERROR))); + } +}