From a158e91dc2ca37a58bf9bcc4442fb2c084e6ef45 Mon Sep 17 00:00:00 2001 From: joona95 Date: Tue, 14 Oct 2025 21:25:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/apple/AppleOAuthFeignClient.java | 13 ++++ .../client/apple/dto/AppleAuthResponse.java | 24 ++++++ .../apple/dto/ApplePublicKeyResponse.java | 14 ++++ .../apple/dto/ApplePublicKeysResponse.java | 20 +++++ .../recipe/app/src/common/utils/JwtUtil.java | 40 ++++++++++ .../app/src/user/api/UserController.java | 8 ++ .../application/UserAuthClientService.java | 73 ++++++++++++++++++- .../app/src/user/application/UserService.java | 17 ++++- 8 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/recipe/app/src/common/client/apple/AppleOAuthFeignClient.java create mode 100644 src/main/java/com/recipe/app/src/common/client/apple/dto/AppleAuthResponse.java create mode 100644 src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeyResponse.java create mode 100644 src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeysResponse.java diff --git a/src/main/java/com/recipe/app/src/common/client/apple/AppleOAuthFeignClient.java b/src/main/java/com/recipe/app/src/common/client/apple/AppleOAuthFeignClient.java new file mode 100644 index 00000000..dc8f83c4 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/client/apple/AppleOAuthFeignClient.java @@ -0,0 +1,13 @@ +package com.recipe.app.src.common.client.apple; + +import com.recipe.app.src.common.client.apple.dto.ApplePublicKeysResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "apple-oauth-client", url = "https://appleid.apple.com/auth") +public interface AppleOAuthFeignClient { + + @GetMapping(value = "/keys") + ApplePublicKeysResponse getPublicKeys(); + +} diff --git a/src/main/java/com/recipe/app/src/common/client/apple/dto/AppleAuthResponse.java b/src/main/java/com/recipe/app/src/common/client/apple/dto/AppleAuthResponse.java new file mode 100644 index 00000000..d7ee26d0 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/client/apple/dto/AppleAuthResponse.java @@ -0,0 +1,24 @@ +package com.recipe.app.src.common.client.apple.dto; + +import com.recipe.app.src.user.domain.User; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AppleAuthResponse { + + private String sub; + private String email; + private String name; + + public User toEntity(String fcmToken) { + + return User.builder() + .socialId("apple_" + sub) + .nickname(name != null ? name : "Apple User") + .email(email) + .deviceToken(fcmToken) + .build(); + } +} diff --git a/src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeyResponse.java b/src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeyResponse.java new file mode 100644 index 00000000..89ffe2a7 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeyResponse.java @@ -0,0 +1,14 @@ +package com.recipe.app.src.common.client.apple.dto; + +import lombok.Getter; + +@Getter +public class ApplePublicKeyResponse { + + private String kty; + private String kid; + private String use; + private String alg; + private String n; + private String e; +} diff --git a/src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeysResponse.java b/src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeysResponse.java new file mode 100644 index 00000000..df0764b4 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/client/apple/dto/ApplePublicKeysResponse.java @@ -0,0 +1,20 @@ +package com.recipe.app.src.common.client.apple.dto; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class ApplePublicKeysResponse { + + private List keys; + + public ApplePublicKeyResponse getMatchKey(String alg, String kid) { + + return this.keys + .stream() + .filter(key -> key.getAlg().equals(alg) && key.getKid().equals(kid)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Apple 로그인 시 필요한 key 값이 존재하지 않습니다.")); + } +} diff --git a/src/main/java/com/recipe/app/src/common/utils/JwtUtil.java b/src/main/java/com/recipe/app/src/common/utils/JwtUtil.java index 1598e236..99a4ed02 100644 --- a/src/main/java/com/recipe/app/src/common/utils/JwtUtil.java +++ b/src/main/java/com/recipe/app/src/common/utils/JwtUtil.java @@ -1,5 +1,6 @@ package com.recipe.app.src.common.utils; +import com.recipe.app.src.common.client.apple.dto.ApplePublicKeyResponse; import io.jsonwebtoken.*; import io.jsonwebtoken.security.SignatureException; import jakarta.servlet.http.HttpServletRequest; @@ -12,7 +13,11 @@ import org.springframework.util.StringUtils; import javax.crypto.spec.SecretKeySpec; +import java.math.BigInteger; import java.security.Key; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; import java.time.Duration; import java.util.Base64; import java.util.Date; @@ -135,4 +140,39 @@ public void setAccessTokenBlacklist(String accessToken) { redisTemplate.opsForValue().set(accessToken, ACCESS_TOKEN_BLACKLIST_VALUE, Duration.ofMillis(accessTokenValidMillisecond)); } + + public Claims parseAppleIdToken(String idToken, ApplePublicKeyResponse publicKey) { + + try { + PublicKey key = generateApplePublicKey(publicKey); + + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(idToken) + .getBody(); + } catch (Exception e) { + logger.error("Apple id_token 검증 실패", e); + throw new IllegalArgumentException("Apple id_token 검증에 실패했습니다.", e); + } + } + + private PublicKey generateApplePublicKey(ApplePublicKeyResponse publicKey) { + + try { + byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.getN()); + byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.getE()); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance(publicKey.getKty()); + + return keyFactory.generatePublic(publicKeySpec); + } catch (Exception exception) { + logger.error("Apple Public Key 생성 실패", exception); + throw new IllegalArgumentException("Apple Public Key 생성에 실패했습니다.", exception); + } + } } diff --git a/src/main/java/com/recipe/app/src/user/api/UserController.java b/src/main/java/com/recipe/app/src/user/api/UserController.java index e54f52f3..356dc0d5 100644 --- a/src/main/java/com/recipe/app/src/user/api/UserController.java +++ b/src/main/java/com/recipe/app/src/user/api/UserController.java @@ -77,6 +77,14 @@ public UserSocialLoginResponse googleLogin(@Parameter(name = "로그인 요청 return userService.googleLogin(request); } + @Operation(summary = "애플 로그인 API") + @PostMapping("/apple-login") + public UserSocialLoginResponse appleLogin(@Parameter(name = "로그인 요청 정보", required = true) + @RequestBody UserLoginRequest request) { + + return userService.appleLogin(request); + } + @Operation(summary = "유저 프로필 조회 API") @GetMapping @LoginCheck diff --git a/src/main/java/com/recipe/app/src/user/application/UserAuthClientService.java b/src/main/java/com/recipe/app/src/user/application/UserAuthClientService.java index 37e91e95..2a9e8173 100644 --- a/src/main/java/com/recipe/app/src/user/application/UserAuthClientService.java +++ b/src/main/java/com/recipe/app/src/user/application/UserAuthClientService.java @@ -1,17 +1,27 @@ package com.recipe.app.src.user.application; +import com.recipe.app.src.common.client.apple.AppleOAuthFeignClient; +import com.recipe.app.src.common.client.apple.dto.AppleAuthResponse; +import com.recipe.app.src.common.client.apple.dto.ApplePublicKeyResponse; +import com.recipe.app.src.common.client.apple.dto.ApplePublicKeysResponse; import com.recipe.app.src.common.client.google.GoogleOAuthFeignClient; import com.recipe.app.src.common.client.kakao.KakaoFeignClient; import com.recipe.app.src.common.client.kakao.KakaoOAuthFeignClient; import com.recipe.app.src.common.client.naver.NaverFeignClient; import com.recipe.app.src.common.client.naver.NaverOAuthFeignClient; import com.recipe.app.src.common.client.naver.dto.NaverAuthResponse; +import com.recipe.app.src.common.utils.JwtUtil; import com.recipe.app.src.user.application.dto.UserLoginRequest; import com.recipe.app.src.user.domain.User; import com.recipe.app.src.user.exception.ForbiddenAccessException; +import io.jsonwebtoken.Claims; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.Base64; + @Service public class UserAuthClientService { @@ -32,20 +42,26 @@ public class UserAuthClientService { @Value("${google.redirect-uri}") private String googleRedirectURI; + private final Logger logger = LoggerFactory.getLogger(UserAuthClientService.class); private final NaverFeignClient naverFeignClient; private final NaverOAuthFeignClient naverOAuthFeignClient; private final KakaoFeignClient kakaoFeignClient; private final KakaoOAuthFeignClient kakaoOAuthFeignClient; private final GoogleOAuthFeignClient googleOAuthFeignClient; + private final AppleOAuthFeignClient appleOAuthFeignClient; + private final JwtUtil jwtUtil; public UserAuthClientService(NaverFeignClient naverFeignClient, NaverOAuthFeignClient naverOAuthFeignClient, KakaoFeignClient kakaoFeignClient, KakaoOAuthFeignClient kakaoOAuthFeignClient, - GoogleOAuthFeignClient googleOAuthFeignClient) { + GoogleOAuthFeignClient googleOAuthFeignClient, AppleOAuthFeignClient appleOAuthFeignClient, + JwtUtil jwtUtil) { this.naverFeignClient = naverFeignClient; this.naverOAuthFeignClient = naverOAuthFeignClient; this.kakaoFeignClient = kakaoFeignClient; this.kakaoOAuthFeignClient = kakaoOAuthFeignClient; this.googleOAuthFeignClient = googleOAuthFeignClient; + this.appleOAuthFeignClient = appleOAuthFeignClient; + this.jwtUtil = jwtUtil; } public UserLoginRequest getNaverLoginRequest(String code, String state) { @@ -101,4 +117,59 @@ public User getUserByGoogleAuthInfo(UserLoginRequest request) { return googleOAuthFeignClient.getAuthInfo(request.getAccessToken()) .toEntity(request.getFcmToken()); } + + public User getUserByAppleAuthInfo(UserLoginRequest request) { + + String idToken = request.getAccessToken(); + + // 1. Apple Public Keys 조회 + ApplePublicKeysResponse publicKeys = appleOAuthFeignClient.getPublicKeys(); + + // 2. id_token 헤더에서 kid, alg 추출 + String kid = getKidFromIdToken(idToken); + String alg = getAlgFromIdToken(idToken); + + // 3. 매칭되는 Public Key 찾기 + ApplePublicKeyResponse matchedKey = publicKeys.getMatchKey(alg, kid); + + // 4. JWT 검증 및 Claims 추출 + Claims claims = jwtUtil.parseAppleIdToken(idToken, matchedKey); + + // 5. User 엔티티 생성 + return AppleAuthResponse.builder() + .sub(claims.get("sub", String.class)) + .email(claims.get("email", String.class)) + .name(null) + .build() + .toEntity(request.getFcmToken()); + } + + public String getKidFromIdToken(String idToken) { + + try { + String header = idToken.split("\\.")[0]; + String decodedHeader = new String(Base64.getUrlDecoder().decode(header)); + + String kid = decodedHeader.split("\"kid\":\"")[1].split("\"")[0]; + return kid; + } catch (Exception e) { + logger.error("id_token 헤더에서 kid 추출 실패", e); + throw new IllegalArgumentException("id_token 헤더에서 kid를 추출할 수 없습니다.", e); + } + } + + private String getAlgFromIdToken(String idToken) { + + try { + String header = idToken.split("\\.")[0]; + String decodedHeader = new String(Base64.getUrlDecoder().decode(header)); + + String alg = decodedHeader.split("\"alg\":\"")[1].split("\"")[0]; + return alg; + } catch (Exception e) { + logger.error("id_token 헤더에서 alg 추출 실패", e); + throw new IllegalArgumentException("id_token 헤더에서 alg를 추출할 수 없습니다.", e); + } + } + } diff --git a/src/main/java/com/recipe/app/src/user/application/UserService.java b/src/main/java/com/recipe/app/src/user/application/UserService.java index e92418dd..1d0a8340 100644 --- a/src/main/java/com/recipe/app/src/user/application/UserService.java +++ b/src/main/java/com/recipe/app/src/user/application/UserService.java @@ -1,8 +1,8 @@ package com.recipe.app.src.user.application; import com.google.common.base.Preconditions; -import com.recipe.app.src.common.utils.JwtUtil; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.JwtUtil; import com.recipe.app.src.user.application.dto.UserDeviceTokenRequest; import com.recipe.app.src.user.application.dto.UserLoginRequest; import com.recipe.app.src.user.application.dto.UserLoginResponse; @@ -97,6 +97,21 @@ public UserSocialLoginResponse googleLogin(UserLoginRequest request) { return UserSocialLoginResponse.from(user, accessToken, refreshToken); } + @Transactional + public UserSocialLoginResponse appleLogin(UserLoginRequest request) { + + Preconditions.checkArgument(StringUtils.hasText(request.getAccessToken()), "id_token을 입력해주세요."); + + User user = create(userAuthClientService.getUserByAppleAuthInfo(request)); + + user.changeRecentLoginAt(LocalDateTime.now()); + + String accessToken = jwtUtil.createAccessToken(user.getUserId()); + String refreshToken = jwtUtil.createRefreshToken(user.getUserId()); + + return UserSocialLoginResponse.from(user, accessToken, refreshToken); + } + private User create(User user) { return userRepository.findBySocialId(user.getSocialId()) From 95d4d6038413810f9c54fa2aa836cca7a7ed62a5 Mon Sep 17 00:00:00 2001 From: joona95 Date: Tue, 14 Oct 2025 21:53:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20jjwt=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=B9=8C=EB=93=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0408334c..ccc506a5 100644 --- a/build.gradle +++ b/build.gradle @@ -46,7 +46,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-security' - compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'