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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -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();

}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<ApplePublicKeyResponse> 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 값이 존재하지 않습니다."));
}
}
40 changes: 40 additions & 0 deletions src/main/java/com/recipe/app/src/common/utils/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/recipe/app/src/user/api/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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())
Expand Down
Loading