Skip to content

Commit

Permalink
[BE] feat: AppleOpenIdClient를 추가한다.(#931) (#962)
Browse files Browse the repository at this point in the history
* feat: AppleOpenId Client 구현

* chore: 패키지 구조 변경

* test: AppleOpenIdUserInfoProvider 테스트 추가

* test: AppleOpenIdJwksClient 테스트 추가

* chore: test properties apple.clinet-id 값 추가

* feat: 만약 nickname 이 없다면 기본 닉네임을 가진다

* fix: uploadUri 의 location 뒤에 "/" 추가

* refactor: Provider 삭제 및 로직 이동

* chore: javadocs 설명 변경

* chore: 서브모듈 업데이트

---------

Co-authored-by: seokjin8678 <[email protected]>
  • Loading branch information
BGuga and seokjin8678 authored May 12, 2024
1 parent 15cc097 commit 7b3c3eb
Show file tree
Hide file tree
Showing 33 changed files with 564 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.oauth2;

import com.festago.auth.application.OAuth2Client;
import com.festago.auth.domain.SocialType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.oauth2;

import com.festago.auth.dto.KakaoAccessTokenResponse;
import org.springframework.beans.factory.annotation.Value;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.oauth2;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.oauth2;

import com.festago.auth.application.OAuth2Client;
import com.festago.auth.domain.SocialType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.oauth2;

import com.festago.auth.domain.UserInfo;
import com.festago.auth.dto.KakaoUserInfo;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.oauth2;

import com.festago.common.exception.BadRequestException;
import com.festago.common.exception.ErrorCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.festago.auth.infrastructure.openid;

import com.festago.auth.domain.OpenIdClient;
import com.festago.auth.domain.OpenIdNonceValidator;
import com.festago.auth.domain.SocialType;
import com.festago.auth.domain.UserInfo;
import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.UnauthorizedException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import java.time.Clock;
import java.util.Date;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class AppleOpenIdClient implements OpenIdClient {

private static final String ISSUER = "https://appleid.apple.com";
private final OpenIdNonceValidator openIdNonceValidator;
private final OpenIdIdTokenParser idTokenParser;
private final String clientId;

public AppleOpenIdClient(
@Value("${festago.oauth2.apple.client-id}") String appleClientId,
AppleOpenIdPublicKeyLocator appleOpenIdPublicKeyLocator,
OpenIdNonceValidator openIdNonceValidator,
Clock clock
) {
this.clientId = appleClientId;
this.openIdNonceValidator = openIdNonceValidator;
this.idTokenParser = new OpenIdIdTokenParser(Jwts.parser()
.keyLocator(appleOpenIdPublicKeyLocator)
.requireIssuer(ISSUER)
.clock(() -> Date.from(clock.instant()))
.build());
}

@Override
public UserInfo getUserInfo(String idToken) {
Claims payload = idTokenParser.parse(idToken);
openIdNonceValidator.validate(payload.get("nonce", String.class), payload.getExpiration());
validateAudience(payload.getAudience());
return UserInfo.builder()
.socialType(SocialType.APPLE)
.socialId(payload.getSubject())
.build();
}

private void validateAudience(Set<String> audiences) {
for (String audience : audiences) {
if (clientId.equals(audience)) {
return;
}
}
log.info("허용되지 않는 id 토큰의 audience 값이 요청되었습니다. audiences={}", audiences);
throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN);
}

@Override
public SocialType getSocialType() {
return SocialType.APPLE;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
import io.jsonwebtoken.io.Parser;
import io.jsonwebtoken.security.JwkSet;
import io.jsonwebtoken.security.Jwks;
import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;

@Component
@Slf4j
public class AppleOpenIdJwksClient {

private final RestTemplate restTemplate;
private final Parser<JwkSet> parser;

public AppleOpenIdJwksClient(
RestTemplateBuilder restTemplateBuilder
) {
this.restTemplate = restTemplateBuilder
.errorHandler(new AppleOpenIdJwksErrorHandler())
.setConnectTimeout(Duration.ofSeconds(2))
.setReadTimeout(Duration.ofSeconds(3))
.build();
this.parser = Jwks.setParser()
.build();
}

public JwkSet requestGetJwks() {
try {
String jsonKeys = restTemplate.getForObject("https://appleid.apple.com/auth/keys", String.class);
log.info("Apple JWKS 공개키 목록을 조회했습니다.");
return parser.parse(jsonKeys);
} catch (ResourceAccessException e) {
log.warn("Apple JWKS 서버가 응답하지 않습니다.");
throw new InternalServerException(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.DefaultResponseErrorHandler;

@Slf4j
public class AppleOpenIdJwksErrorHandler extends DefaultResponseErrorHandler {

@Override
public void handleError(ClientHttpResponse response) throws IOException {
HttpStatusCode statusCode = response.getStatusCode();
if (statusCode.isError()) {
log.warn("Apple JWKS 서버에서 {} 상태코드가 반환되었습니다.", statusCode.value());
throw new InternalServerException(ErrorCode.OPEN_ID_PROVIDER_NOT_RESPONSE);
}
log.error("Apple JWKS 서버에서 알 수 없는 에러가 발생했습니다.");
throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.UnauthorizedException;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Locator;
import java.security.Key;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AppleOpenIdPublicKeyLocator implements Locator<Key> {

private final AppleOpenIdJwksClient appleOpenIdJwksClient;
private final CachedAppleOpenIdKeyProvider cachedOpenIdKeyProvider;

@Override
public Key locate(Header header) {
String kid = (String) header.get("kid");
if (kid == null) {
throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN);
}
return cachedOpenIdKeyProvider.provide(kid, appleOpenIdJwksClient::requestGetJwks);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.UnexpectedException;
import io.jsonwebtoken.security.JwkSet;
import jakarta.annotation.Nullable;
import java.security.Key;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CachedAppleOpenIdKeyProvider {

private final Map<String, Key> cache = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();

/**
* OpenId Key를 캐싱하여 반환하는 클래스 <br/> OpenID Id Token 헤더의 kid 값을 key로 가지도록 구현 <br/> Id Token을 검증할 때, 매번 공개키 목록을 조회하면
* 요청이 차단될 수 있으므로 캐싱하는 과정이 필요. <br/> 따라서 kid에 대한 Key를 찾을 수 없으면, fallback을 통해 캐시를 업데이트함 <br/> 이때, 동시에 여러 요청이 들어오면 동시성
* 문제가 발생할 수 있으므로 ReentrantLock을 사용하여 상호 배제 구현 <br/> 데드락을 방지하기 위해 ReentrantLock.tryLock() 메서드를 사용하였음 <br/> 또한 반드시
* fallback에서 Timeout에 대한 예외 발생을 구현 해야함<br/> 존재하지 않는 kid로 계속 요청 시 fallback이 계속 호출되므로 공격 가능성이 있음. <br/>
*
* @param kid 캐시의 Key로 사용될 OpenId Id Token 헤더의 kid 값
* @param fallback 캐시 미스 발생 시 캐시에 Key를 등록할 JwkSet을 반환하는 함수
* @return 캐시 Hit의 경우 Key 반환, 캐시 Miss에서 fallback으로 반환된 JwkSet에 Key가 없으면 null 반환
*/
@Nullable
public Key provide(String kid, Supplier<JwkSet> fallback) {
Key key = cache.get(kid);
if (key != null) {
return key;
}
log.info("kid에 대한 OpenId Key를 찾지 못해 Key 목록 조회를 시도합니다. kid={}", kid);
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
key = cache.get(kid);
if (key != null) {
return key;
}
JwkSet jwkSet = fallback.get();
jwkSet.forEach(jwk -> cache.put(jwk.getId(), jwk.toKey()));
key = cache.get(kid);
if (key == null) {
log.warn("OpenId kid에 대한 Key를 찾을 수 없습니다. kid={}", kid);
}
return key;
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("스레드가 인터럽트 되었습니다.", e);
}
throw new UnexpectedException("OpenId Key를 가져오는 중, 락 대기로 인해 Key를 획득하지 못했습니다. kid=" + kid);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.UnexpectedException;
import io.jsonwebtoken.security.JwkSet;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.auth.domain.OpenIdClient;
import com.festago.auth.domain.SocialType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.auth.domain.OpenIdClient;
import com.festago.auth.domain.SocialType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.UnauthorizedException;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.auth.domain.OpenIdNonceValidator;
import com.festago.auth.domain.OpenIdUserInfoProvider;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.auth.domain.OpenIdNonceValidator;
import java.util.Date;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.festago.auth.infrastructure;
package com.festago.auth.infrastructure.openid;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.UnauthorizedException;
Expand Down
4 changes: 2 additions & 2 deletions backend/src/main/java/com/festago/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
public class Member extends BaseTimeEntity {

private static final String DEFAULT_IMAGE_URL = "https://festa-go.site/images/default-profile.png";
private static final String DEFAULT_NICKNAME = "FestivalLover";
private static final int MAX_SOCIAL_ID_LENGTH = 255;
private static final int MAX_NICKNAME_LENGTH = 30;
private static final int MAX_PROFILE_IMAGE_LENGTH = 255;
Expand Down Expand Up @@ -75,7 +76,7 @@ public Member(Long id, String socialId, SocialType socialType, String nickname,
this.id = id;
this.socialId = socialId;
this.socialType = socialType;
this.nickname = nickname;
this.nickname = (StringUtils.hasText(nickname)) ? nickname : DEFAULT_NICKNAME;
this.profileImage = (StringUtils.hasText(profileImage)) ? profileImage : DEFAULT_IMAGE_URL;
}

Expand All @@ -98,7 +99,6 @@ private void validateSocialType(SocialType socialType) {

private void validateNickname(String nickname) {
String fieldName = "nickname";
Validator.notBlank(nickname, fieldName);
Validator.maxLength(nickname, MAX_NICKNAME_LENGTH, fieldName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ public MimeType getMimeType() {
}

public URI getUploadUri() {
return location.resolve(getName());
return location.resolve("/" + getName());
}

public String getName() {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/config
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import com.festago.auth.application.OAuth2Client;
import com.festago.auth.application.OAuth2Clients;
import com.festago.auth.application.OAuth2Clients.OAuth2ClientsBuilder;
import com.festago.auth.infrastructure.FestagoOAuth2Client;
import com.festago.auth.infrastructure.KakaoOAuth2AccessTokenClient;
import com.festago.auth.infrastructure.KakaoOAuth2Client;
import com.festago.auth.infrastructure.KakaoOAuth2UserInfoClient;
import com.festago.auth.infrastructure.oauth2.FestagoOAuth2Client;
import com.festago.auth.infrastructure.oauth2.KakaoOAuth2AccessTokenClient;
import com.festago.auth.infrastructure.oauth2.KakaoOAuth2Client;
import com.festago.auth.infrastructure.oauth2.KakaoOAuth2UserInfoClient;
import com.festago.common.exception.BadRequestException;
import com.festago.common.exception.UnexpectedException;
import org.junit.jupiter.api.DisplayNameGeneration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static org.mockito.BDDMockito.times;
import static org.mockito.BDDMockito.verify;

import com.festago.auth.infrastructure.openid.CachedOpenIdKeyProvider;
import io.jsonwebtoken.security.JwkSet;
import io.jsonwebtoken.security.Jwks;
import java.security.Key;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.festago.auth.dto.KakaoAccessTokenResponse;
import com.festago.auth.infrastructure.KakaoOAuth2AccessTokenErrorHandler.KakaoOAuth2ErrorResponse;
import com.festago.auth.infrastructure.oauth2.KakaoOAuth2AccessTokenClient;
import com.festago.auth.infrastructure.oauth2.KakaoOAuth2AccessTokenErrorHandler.KakaoOAuth2ErrorResponse;
import com.festago.common.exception.BadRequestException;
import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.festago.auth.dto.KakaoUserInfo;
import com.festago.auth.dto.KakaoUserInfo.KakaoAccount;
import com.festago.auth.dto.KakaoUserInfo.KakaoAccount.Profile;
import com.festago.auth.infrastructure.oauth2.KakaoOAuth2UserInfoClient;
import com.festago.common.exception.BadRequestException;
import com.festago.common.exception.InternalServerException;
import org.junit.jupiter.api.DisplayNameGeneration;
Expand Down
Loading

0 comments on commit 7b3c3eb

Please sign in to comment.