Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(Auth): OIDC 기반 GoogleLogin 구현 #45

Merged
merged 3 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.jabiseo.auth.application.oidc;


import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.client.oidc.OidcPublicKey;
import com.jabiseo.auth.application.oidc.property.OidcIdTokenProperty;
import com.jabiseo.member.domain.OauthServer;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.jabiseo.auth.application.oidc;


import com.jabiseo.auth.application.oidc.property.GoogleOidcProperty;
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.client.NetworkApiException;
import com.jabiseo.client.oidc.GoogleAccountsClient;
import com.jabiseo.client.oidc.GoogleOidcClient;
import com.jabiseo.client.oidc.GoogleOpenIdConfiguration;
import com.jabiseo.client.oidc.OidcPublicKey;
import com.jabiseo.common.exception.CommonErrorCode;
import com.jabiseo.member.domain.OauthServer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Slf4j
@Component
public class GoogleIdTokenValidator extends AbstractIdTokenValidator {

private final RedisCacheRepository redisCacheRepository;
private final GoogleAccountsClient googleAccountsClient;
private final GoogleOidcClient googleOidcClient;
private static final String OPENID_CONFIGURATION_CACHE_KEY = "GOOGLE_OPENID_CONFIGURATION";
private static final String PUBLIC_KEY_CACHE_KEY = "GOOGLE_OIDC_PUBLIC_KEY";
private static final String GOOGLE_ID_KEY = "sub";
private static final String GOOGLE_EMAIL_KEY = "email";

/* Google Public Key를 받는 로직
* 1. https://accounts.google.com/.well-known/openid-configuration -> jwks_uri 값을 파싱해온다.
* 2. jwks_uri 으로 API를 호출해서 Public Key에 대한 응답 값을 받는다
* 1번과 2번의 모든 결과는 캐싱을 해야 한다.
* Ref: https://developers.google.com/identity/openid-connect/openid-connect?hl=ko#validatinganidtoken
*/

public GoogleIdTokenValidator(GoogleOidcProperty googleOidcProperty,
IdTokenJwtHandler idTokenJwtHandler,
RedisCacheRepository redisCacheRepository,
GoogleAccountsClient googleAccountsClient,
GoogleOidcClient googleOidcClient) {
super(googleOidcProperty.toIdTokenProperty(), idTokenJwtHandler);
this.redisCacheRepository = redisCacheRepository;
this.googleAccountsClient = googleAccountsClient;
this.googleOidcClient = googleOidcClient;
}


@Override
protected OidcPublicKey getOidcPublicKey(String kid) {
List<OidcPublicKey> keys = redisCacheRepository.getPublicKeys(PUBLIC_KEY_CACHE_KEY);

if (keys == null) {
keys = getOidcPublicKeysFromGoogle();
redisCacheRepository.savePublicKey(PUBLIC_KEY_CACHE_KEY, keys);
}

return keys.stream().filter((key) -> key.getKid().equals(kid))
.findAny()
.orElseThrow(() -> new AuthenticationBusinessException(AuthenticationErrorCode.INVALID_ID_TOKEN));
}

private List<OidcPublicKey> getOidcPublicKeysFromGoogle() {
GoogleOpenIdConfiguration config = redisCacheRepository.getGoogleConfiguration(OPENID_CONFIGURATION_CACHE_KEY);

try {
if (config == null) {
ResponseEntity<GoogleOpenIdConfiguration> oidcConfiguration = googleAccountsClient.getOidcConfiguration();
config = oidcConfiguration.getBody();
redisCacheRepository.saveOpenConfiguation(OPENID_CONFIGURATION_CACHE_KEY, config);
}
return googleOidcClient.getPublicKeys(config.getJwks_uri());
} catch (NetworkApiException e) {
log.error(e.getMessage());
throw new AuthenticationBusinessException(AuthenticationErrorCode.GET_JWK_FAIL);
}
}

@Override
protected OauthMemberInfo extractMemberInfoFromPayload(Map<String, Object> payload) {
String oauthId = (String) payload.get(GOOGLE_ID_KEY);
String email = (String) payload.get(GOOGLE_EMAIL_KEY);
if (requireValueIsNull(oauthId, email)) {
throw new AuthenticationBusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR);
}

return OauthMemberInfo.builder()
.oauthId(oauthId)
.email(email)
.oauthServer(OauthServer.GOOGLE)
.build();
}

@Override
OauthServer getOauthServer() {
return OauthServer.GOOGLE;
}

private boolean requireValueIsNull(String oauthId, String email) {
return oauthId == null || email == null;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.jabiseo.auth.application.oidc;

import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.client.oidc.OidcPublicKey;
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import io.jsonwebtoken.*;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.jabiseo.auth.application.oidc;

import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.client.KakaoKauthClient;
import com.jabiseo.client.oidc.KakaoKauthClient;
import com.jabiseo.client.NetworkApiException;
import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.client.OidcPublicKeyResponse;
import com.jabiseo.client.oidc.OidcPublicKey;
import com.jabiseo.client.oidc.OidcPublicKeyResponse;
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.auth.application.oidc.property.KakaoOidcProperty;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.jabiseo.auth.application.oidc.property;

import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@ConfigurationProperties(prefix = "oidc.google")
public class GoogleOidcProperty {

private final String clientId;
private final String issuer;

public GoogleOidcProperty(String clientId, String issuer) {
this.clientId = clientId;
this.issuer = issuer;
}

public OidcIdTokenProperty toIdTokenProperty() {
return new OidcIdTokenProperty(issuer, clientId);
}
}
3 changes: 3 additions & 0 deletions jabiseo-api/src/main/resources/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ oidc:
issuer: ${KAKAO_ISSUER}
admin-key: ${KAKAO_ADMIN_KEY}
client-id: ${KAKAO_CLIENT_ID}
google:
client-id: ${GOOGLE_CLIENT_ID}
issuer: ${GOOGLE_ISSUER}

jwt:
access-expired-min: 60
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.jabiseo.auth.application.oidc;

import com.jabiseo.auth.application.oidc.property.GoogleOidcProperty;
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.client.NetworkApiException;
import com.jabiseo.client.oidc.*;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;

@DisplayName("구글 IdToken 검증 테스트")
@ExtendWith(MockitoExtension.class)
class GoogleIdTokenValidatorTest {

@InjectMocks
GoogleIdTokenValidator validator;

@Mock
IdTokenJwtHandler idTokenJwtHandler;
InHyeok-J marked this conversation as resolved.
Show resolved Hide resolved

@Mock
GoogleOidcProperty googleOidcProperty;

@Mock
RedisCacheRepository redisCacheRepository;

@Mock
GoogleAccountsClient googleAccountsClient;

@Mock
GoogleOidcClient googleOidcClient;

GoogleOpenIdConfiguration configuration = mockGoogleOpenIdConfiguration();

@BeforeEach
void setUp() {
googleOidcProperty = new GoogleOidcProperty("client_id", "issuer");
}

@Test
@DisplayName("구글 jwk 조회시 캐시에 데이터가 있다면 Google API를 호출하지 않는다.")
void notCallApiAlreadySavedCache() {
//given
given(redisCacheRepository.getPublicKeys(any(String.class))).willReturn(List.of(mockPublicKey("kid1"), mockPublicKey("kid2")));

//when
validator.getOidcPublicKey("kid1");

//then
verify(googleOidcClient, never()).getPublicKeys("urls");
verify(googleAccountsClient, never()).getOidcConfiguration();
}

@Test
@DisplayName("구글 jwk 조회시 캐시에 데이터가 없다면 캐시 데이터를 호출하고 구글API호출 후 캐시에 저장한다.")
void callApiNotSavedCallGoogleAPIAndSavedCache(){
//given
List<OidcPublicKey> publicKeys = List.of(mockPublicKey("kid1"), mockPublicKey("kid2"));
given(redisCacheRepository.getPublicKeys(any())).willReturn(null); // cache hit fail
given(redisCacheRepository.getGoogleConfiguration(any())).willReturn(configuration); // cahce hit success
given(googleOidcClient.getPublicKeys(configuration.getJwks_uri())).willReturn(publicKeys);

//when
validator.getOidcPublicKey("kid1");

//then
verify(googleOidcClient, times(1)).getPublicKeys(configuration.getJwks_uri());
verify(redisCacheRepository, times(1)).savePublicKey("GOOGLE_OIDC_PUBLIC_KEY",publicKeys);
}

@Test
@DisplayName("구글 jwk 조회시 jwk도 없고 openid정보도 없다면 둘 다 호출한다")
void isNullJwKAndOpenIdCallingJwkAndOpenId(){
//given
ResponseEntity<GoogleOpenIdConfiguration> res = ResponseEntity.of(Optional.of(configuration));

List<OidcPublicKey> publicKeys = List.of(mockPublicKey("kid1"), mockPublicKey("kid2"));
given(redisCacheRepository.getPublicKeys(any())).willReturn(null); // cache hit fail
given(redisCacheRepository.getGoogleConfiguration(any())).willReturn(null); // cahce hit success
given(googleOidcClient.getPublicKeys(any())).willReturn(publicKeys);
given(googleAccountsClient.getOidcConfiguration()).willReturn(res);

//when
validator.getOidcPublicKey("kid1");

//then
verify(googleAccountsClient, times(1)).getOidcConfiguration();
verify(googleOidcClient, times(1)).getPublicKeys(any());
}

@Test
@DisplayName("구글 jwk 조회시 kid 에 맞는 key가 없다면 예외를 반환한다.")
void notMatchKidThrownException(){
//given
List<OidcPublicKey> publicKeys = List.of(mockPublicKey("kid1"), mockPublicKey("kid2"));
given(redisCacheRepository.getPublicKeys(any())).willReturn(publicKeys);
String otherKid = "kid";
//when //then

Assertions.assertThatThrownBy(()->validator.getOidcPublicKey(otherKid))
.isInstanceOf(AuthenticationBusinessException.class)
.hasMessage(AuthenticationErrorCode.INVALID_ID_TOKEN.getMessage());
}

@Test
@DisplayName("구글 jwk 획득 중 api 호출 실패시 예외를 반환한다")
void getJwkKakaoApiCallingFailThrownException(){
//given
given(redisCacheRepository.getPublicKeys(any())).willReturn(null);
given(redisCacheRepository.getGoogleConfiguration(any())).willReturn(configuration);
given(googleOidcClient.getPublicKeys(configuration.getJwks_uri())).willThrow(NetworkApiException.class);

//when //then
assertThatThrownBy(() -> validator.getOidcPublicKey("key"))
.isInstanceOf(AuthenticationBusinessException.class)
.hasMessage(AuthenticationErrorCode.GET_JWK_FAIL.getMessage());
}

@Test
@DisplayName("구글 jwk 획등 중 kid에 맞는 jwk가 있다면 반환한다")
void successMatchKidReturnOidcPublicKey(){
//given
String matchKid = "kid";
OidcPublicKey matchPublicKey = mockPublicKey(matchKid);
List<OidcPublicKey> publicKeys = List.of(matchPublicKey, mockPublicKey("kid2"));

given(redisCacheRepository.getPublicKeys(any())).willReturn(publicKeys);

//when
OidcPublicKey key = validator.getOidcPublicKey("kid");

//then
Assertions.assertThat(key).isEqualTo(matchPublicKey);
}


private GoogleOpenIdConfiguration mockGoogleOpenIdConfiguration() {
return GoogleOpenIdConfiguration
.builder()
.jwks_uri("https..")
.build();
}


private OidcPublicKey mockPublicKey(String kid) {
return new OidcPublicKey(kid, "a", "u", "n", "e", ".");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import com.jabiseo.auth.exception.AuthenticationBusinessException;
import com.jabiseo.auth.exception.AuthenticationErrorCode;
import com.jabiseo.cache.RedisCacheRepository;
import com.jabiseo.client.KakaoKauthClient;
import com.jabiseo.client.oidc.KakaoKauthClient;
import com.jabiseo.client.NetworkApiException;
import com.jabiseo.client.OidcPublicKey;
import com.jabiseo.client.OidcPublicKeyResponse;
import com.jabiseo.client.oidc.OidcPublicKey;
import com.jabiseo.client.oidc.OidcPublicKeyResponse;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -113,7 +113,7 @@ void SuccessMatchKidReturnOidcPublicKey() {

@Test
@DisplayName("카카오 jwk 획득 api 호출 실패시 에러를 반환한다")
void getJwkKakaoApiCallingFailThrownExcetpion(){
void getJwkKakaoApiCallingFailThrownException(){
//given
given(redisCacheRepository.getPublicKeys(any())).willReturn(null);
given(kakaoKauthClient.getPublicKeys()).willThrow(NetworkApiException.class);
Expand All @@ -126,6 +126,6 @@ void getJwkKakaoApiCallingFailThrownExcetpion(){
}

private OidcPublicKey mockPublicKey(String kid) {
return new OidcPublicKey(kid, "a", "u", "n", "e");
return new OidcPublicKey(kid, "a", "u", "n", "e",".");
}
}
Loading
Loading