diff --git a/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java b/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java index 76aadb7..8de3cef 100644 --- a/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java +++ b/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java @@ -24,6 +24,12 @@ public enum ErrorMessage { INVALID_REFRESH_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "e4015", "리프레시 토큰의 값이 올바르지 않습니다."), EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "e4016", "리프레시 토큰이 만료되었습니다. 다시 로그인해 주세요."), MISMATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "e4017", "리프레시 토큰이 일치하지 않습니다."), + INVALID_IDENTITY_TOKEN(HttpStatus.UNAUTHORIZED, "e4018", "애플 아이덴티티 토큰의 형식이 올바르지 않습니다."), + INVALID_IDENTITY_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "e4019", "애플 아이덴티티 토큰의 값이 올바르지 않습니다."), + UNABLE_TO_CREATE_APPLE_PUBLIC_KEY(HttpStatus.UNAUTHORIZED, "e40110", "애플 로그인 중 퍼블릭 키 생성에 문제가 발생했습니다."), + EXPIRED_IDENTITY_TOKEN(HttpStatus.UNAUTHORIZED, "e40111", "애플 로그인 중 아이덴티티 토큰의 유효 기간이 만료되었습니다."), + INVALID_IDENTITY_TOKEN_CLAIMS(HttpStatus.UNAUTHORIZED, "e40112", "애플 아이덴터티 토큰의 클레임 값이 올바르지 않습니다."), + INVALID_KAKAO_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "e40113", "카카오 액세스 토큰의 정보를 조회하는 과정에서 오류가 발생하였습니다."), /** * 403 Forbidden diff --git a/doorip-external/build.gradle b/doorip-external/build.gradle index 75eae5c..9ad1895 100644 --- a/doorip-external/build.gradle +++ b/doorip-external/build.gradle @@ -1,4 +1,9 @@ dependencies { + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.0.3' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2' implementation project(path: ':doorip-common') } diff --git a/doorip-external/src/main/java/org/doorip/config/FeignClientConfig.java b/doorip-external/src/main/java/org/doorip/config/FeignClientConfig.java new file mode 100644 index 0000000..1b41be6 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/config/FeignClientConfig.java @@ -0,0 +1,10 @@ +package org.doorip.config; + +import org.doorip.ExternalRoot; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients(basePackageClasses = ExternalRoot.class) +public class FeignClientConfig { +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleFeignClient.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleFeignClient.java new file mode 100644 index 0000000..cc2b702 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleFeignClient.java @@ -0,0 +1,10 @@ +package org.doorip.openfeign.apple; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "${oauth.apple.name}", url = "${oauth.apple.url}") +public interface AppleFeignClient { + @GetMapping("/keys") + ApplePublicKeys getApplePublicKeys(); +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleIdentityTokenParser.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleIdentityTokenParser.java new file mode 100644 index 0000000..8603cda --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleIdentityTokenParser.java @@ -0,0 +1,47 @@ +package org.doorip.openfeign.apple; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.*; +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.PublicKey; +import java.util.Base64; +import java.util.Map; + +@Component +public class AppleIdentityTokenParser { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public Map parseHeaders(String identityToken) { + try { + String encoded = identityToken.split("\\.")[0]; + String decoded = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + return OBJECT_MAPPER.readValue(decoded, Map.class); + } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) { + throw new UnauthorizedException(ErrorMessage.INVALID_IDENTITY_TOKEN); + } + } + + public Claims parseWithPublicKeyAndGetClaims(String identityToken, PublicKey publicKey) { + try { + return getJwtParser(publicKey) + .parseClaimsJws(identityToken) + .getBody(); + } catch (ExpiredJwtException e) { + throw new UnauthorizedException(ErrorMessage.EXPIRED_IDENTITY_TOKEN); + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { + throw new UnauthorizedException(ErrorMessage.INVALID_IDENTITY_TOKEN_VALUE); + } + } + + private JwtParser getJwtParser(Key key) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build(); + } +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleIdentityTokenValidator.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleIdentityTokenValidator.java new file mode 100644 index 0000000..df85f23 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleIdentityTokenValidator.java @@ -0,0 +1,18 @@ +package org.doorip.openfeign.apple; + +import io.jsonwebtoken.Claims; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AppleIdentityTokenValidator { + @Value("${oauth.apple.iss}") + private String iss; + @Value("${oauth.apple.client-id}") + private String clientId; + + public boolean isValidAppleIdentityToken(Claims claims) { + return claims.getIssuer().contains(iss) + && claims.getAudience().equals(clientId); + } +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleOAuthProvider.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleOAuthProvider.java new file mode 100644 index 0000000..08368f4 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/AppleOAuthProvider.java @@ -0,0 +1,35 @@ +package org.doorip.openfeign.apple; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.stereotype.Component; + +import java.security.PublicKey; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class AppleOAuthProvider { + private final AppleFeignClient appleFeignClient; + private final AppleIdentityTokenParser appleIdentityTokenParser; + private final ApplePublicKeyGenerator applePublicKeyGenerator; + private final AppleIdentityTokenValidator appleIdentityTokenValidator; + + public String getApplePlatformId(String identityToken) { + Map headers = appleIdentityTokenParser.parseHeaders(identityToken); + ApplePublicKeys applePublicKeys = appleFeignClient.getApplePublicKeys(); + PublicKey publicKey = applePublicKeyGenerator.generatePublicKeyWithApplePublicKeys(headers, applePublicKeys); + Claims claims = appleIdentityTokenParser.parseWithPublicKeyAndGetClaims(identityToken, publicKey); + validateClaims(claims); + + return claims.getSubject(); + } + + private void validateClaims(Claims claims) { + if (!appleIdentityTokenValidator.isValidAppleIdentityToken(claims)) { + throw new UnauthorizedException(ErrorMessage.INVALID_IDENTITY_TOKEN_CLAIMS); + } + } +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKey.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKey.java new file mode 100644 index 0000000..ca9031a --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKey.java @@ -0,0 +1,11 @@ +package org.doorip.openfeign.apple; + +public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e) { +} + diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKeyGenerator.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKeyGenerator.java new file mode 100644 index 0000000..d0d019c --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKeyGenerator.java @@ -0,0 +1,35 @@ +package org.doorip.openfeign.apple; + +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.stereotype.Component; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; +import java.math.BigInteger; + +@Component +public class ApplePublicKeyGenerator { + public PublicKey generatePublicKeyWithApplePublicKeys(Map headers, ApplePublicKeys applePublicKeys) { + ApplePublicKey applePublicKey = applePublicKeys + .getMatchesKey(headers.get("alg"), headers.get("kid")); + + byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); + + RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec( + new BigInteger(1, nBytes), new BigInteger(1, eBytes)); + + try { + KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.kty()); + return keyFactory.generatePublic(rsaPublicKeySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) { + throw new UnauthorizedException(ErrorMessage.UNABLE_TO_CREATE_APPLE_PUBLIC_KEY); + } + } +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKeys.java b/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKeys.java new file mode 100644 index 0000000..cb05809 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/apple/ApplePublicKeys.java @@ -0,0 +1,17 @@ +package org.doorip.openfeign.apple; + +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; + +import java.util.List; + +public class ApplePublicKeys { + private List keys; + + public ApplePublicKey getMatchesKey(String alg, String kid) { + return keys.stream() + .filter(applePublicKey -> applePublicKey.alg().equals(alg) && applePublicKey.kid().equals(kid)) + .findFirst() + .orElseThrow(() -> new UnauthorizedException(ErrorMessage.INVALID_IDENTITY_TOKEN)); + } +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoAccessToken.java b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoAccessToken.java new file mode 100644 index 0000000..1a0e695 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoAccessToken.java @@ -0,0 +1,20 @@ +package org.doorip.openfeign.kakao; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class KakaoAccessToken { + private static final String TOKEN_TYPE = "Bearer "; + private String accessToken; + + public static KakaoAccessToken createKakaoAccessToken(String accessToken) { + return new KakaoAccessToken(accessToken); + } + + public String getAccessTokenWithTokenType() { + return TOKEN_TYPE + accessToken; + } +} \ No newline at end of file diff --git a/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoAccessTokenInfo.java b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoAccessTokenInfo.java new file mode 100644 index 0000000..6a951e0 --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoAccessTokenInfo.java @@ -0,0 +1,6 @@ +package org.doorip.openfeign.kakao; + +public record KakaoAccessTokenInfo( + Long id +) { +} diff --git a/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoFeignClient.java b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoFeignClient.java new file mode 100644 index 0000000..f6ecf9f --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoFeignClient.java @@ -0,0 +1,12 @@ +package org.doorip.openfeign.kakao; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "${oauth.kakao.name}", url = "${oauth.kakao.url}") +public interface KakaoFeignClient { + @GetMapping + KakaoAccessTokenInfo getKakaoAccessTokenInfo(@RequestHeader("Authorization") String accessTokenWithTokenType); +} + diff --git a/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoOAuthProvider.java b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoOAuthProvider.java new file mode 100644 index 0000000..426b53a --- /dev/null +++ b/doorip-external/src/main/java/org/doorip/openfeign/kakao/KakaoOAuthProvider.java @@ -0,0 +1,31 @@ +package org.doorip.openfeign.kakao; + +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KakaoOAuthProvider { + private final KakaoFeignClient kakaoFeignClient; + + public String getKakaoPlatformId(String accessToken) { + KakaoAccessToken kakaoAccessToken = KakaoAccessToken.createKakaoAccessToken(accessToken); + String accessTokenWithTokenType = kakaoAccessToken.getAccessTokenWithTokenType(); + KakaoAccessTokenInfo kakaoAccessTokenInfo = getKakaoAccessTokenInfo(accessTokenWithTokenType); + return String.valueOf(kakaoAccessTokenInfo.id()); + } + + private KakaoAccessTokenInfo getKakaoAccessTokenInfo(String accessTokenWithTokenType) { + try { + return kakaoFeignClient.getKakaoAccessTokenInfo(accessTokenWithTokenType); + } catch (FeignException e) { + log.error("Feign Exception: ", e); + throw new UnauthorizedException(ErrorMessage.INVALID_KAKAO_ACCESS_TOKEN); + } + } +} \ No newline at end of file