JAVA/JAVA | Spring 학습기록

[Spring] FeignClient를 이용한 Apple OAuth 구현 일지 (2)

kth990303 2023. 4. 15. 11:32
반응형

해당 글에서는 Apple OAuth 연동 프로덕션 코드와 테스트 코드에 대해 다룹니다.

 

 

모카콩에서는 iOS 앱 배포를 위해 Apple OAuth를 구현했습니다. Apple OAuth 이론, FeignClient 설정과 선정 이유, 관련 프로덕션, 테스트 코드는 1편에서 볼 수 있습니다.

https://kth990303.tistory.com/436

 

[Spring] FeignClient를 이용한 Apple OAuth 구현 일지 (1)

해당 글에서는 spring boot에서의 FeignClient 선택 이유 및 연동 프로덕션 및 테스트 코드, Apple OAuth 이론에 대해 다룹니다. 사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다. 해당 프로젝트 git

kth990303.tistory.com


사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다.


 

해당 프로젝트 github: https://github.com/mocacong/Mocacong-Backend

 

GitHub - mocacong/Mocacong-Backend: 모카콩 백엔드

모카콩 백엔드. Contribute to mocacong/Mocacong-Backend development by creating an account on GitHub.

github.com


이번 2편에서는 Apple OAuth 애플리케이션 프로덕션 및 테스트 코드에 대해 다뤄보겠습니다.

 

애플리케이션 코드

앞에서 설명했던대로 identity token에서 암호화 알고리즘에 해당되는 alg, 공개키 식별자에 해당되는 kid를 먼저 추출해야 합니다. 자체 로그인 때 사용했던 JwtTokenProvider 클래스에 코드를 추가할까 고민하다가, Apple OAuth의 파싱 코드는 아예 독자적이고 패키지 의존성을 고려하여 별도로 분리하는 게 낫겠다고 판단했습니다. 따라서 아래와 같이 AppleJwtParser 클래스를 새로 생성해주었습니다.

AppleJwtParser.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class AppleJwtParser {
 
    private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\.";
    private static final int HEADER_INDEX = 0;
 
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
    public Map<StringString> parseHeaders(String identityToken) {
        try {
            String encodedHeader = identityToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX];
            String decodedHeader = new String(Base64Utils.decodeFromUrlSafeString(encodedHeader));
            return OBJECT_MAPPER.readValue(decodedHeader, Map.class);
        } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) {
            throw new InvalidTokenException("Apple OAuth Identity Token 형식이 올바르지 않습니다.");
        }
    }
}
 
cs

ObjectMapper를 매번 동적 객체로 생성하여 힙에 할당해줄 필요 없이, static 변수로 놔두었습니다.

identity token을 받아 헤더를 디코딩해주는 코드입니다. 해당 메서드는 identity token의 헤더에서 kid, alg 값을 추출하는 코드입니다. 만약 토큰의 헤더가 올바르지 않다면 JsonProcessingException 예외 또는 ArrayIndexOutOfBoundsException 예외를 반환합니다.


그리고 공개키를 얻기 위해 1편에서 작성해준 https://appleid.apple.com/auth/keys 와 통신하는 코드를 이용합니다.

1
2
3
4
5
6
7
@FeignClient(name = "apple-public-key-client", url = "https://appleid.apple.com/auth")
public interface AppleClient {
 
    @GetMapping("/keys")
    ApplePublicKeys getApplePublicKeys();
}
 
cs

이제 정말로 공개키를 만들어보도록 합시다. 공개키를 얻기 위해서는 alg, kid key-value가 포함된 헤더와, 공개키 구성 요소 정보들이 포함된 ApplePublicKeys가 필요합니다. 아래와 같이 디코딩하는 코드를 작성해주도록 합시다.

PublicKeyGenerator.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
public class PublicKeyGenerator {
 
    private static final String SIGN_ALGORITHM_HEADER_KEY = "alg";
    private static final String KEY_ID_HEADER_KEY = "kid";
    private static final int POSITIVE_SIGN_NUMBER = 1;
 
    public PublicKey generatePublicKey(Map<StringString> headers, ApplePublicKeys applePublicKeys) {
        ApplePublicKey applePublicKey =
                applePublicKeys.getMatchesKey(headers.get(SIGN_ALGORITHM_HEADER_KEY), headers.get(KEY_ID_HEADER_KEY));
 
        return generatePublicKeyWithApplePublicKey(applePublicKey);
    }
 
    private PublicKey generatePublicKeyWithApplePublicKey(ApplePublicKey publicKey) {
        byte[] nBytes = Base64Utils.decodeFromUrlSafeString(publicKey.getN());
        byte[] eBytes = Base64Utils.decodeFromUrlSafeString(publicKey.getE());
 
        BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes);
        BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes);
 
        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
 
        try {
            KeyFactory keyFactory = KeyFactory.getInstance(publicKey.getKty());
            return keyFactory.generatePublic(publicKeySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
            throw new IllegalStateException("Apple OAuth 로그인 중 public key 생성에 문제가 발생했습니다.");
        }
    }
}
cs

공개키 구성 요소 정보들을 받아 공개키를 생성하는 코드입니다. 공개키를 생성하는 책임을 가진 객체를 별도로 만들어 준 이유는 테스트 코드 작성의 편리성을 위해서입니다.

 

테스트 코드를 작성하기 어려운 경우라면 프로덕션 코드의 책임이 과하지는 않은지, 객체지향적이지 않은지 의심할 필요가 있습니다. 관련하여 jojoldu님께서 남겨주신 좋은 글을 첨부하겠습니다.

https://jojoldu.tistory.com/697

 

1. 좋은 함수 만들기 - 부작용과 거리두기

요즘의 개발에서 프레임워크나 라이브러리 사용이 없는 개발은 생각하기 어렵다. 특히 DDD 등의 개념까지 기본지식처럼 취급되어 점점 추상화된 개발에 익숙해지고 있다. 복잡한 애플리케이션

jojoldu.tistory.com

테스트 코드에서는 실제로 apple 서버와 통신하는 것이 아닌, mock 객체를 이용하게 될 것입니다. 만약 해당 책임이 하나의 객체의 메서드에 묶여있다면 해당 메서드의 책임이 굉장히 과할 뿐 아니라, stub 하기에도 굉장히 어렵게 될 것입니다.


자, 이제 공개키까지 만들어주었으니 identity token을 파싱할 수 있게 되었습니다. 아까 만들어주었던 AppleJwtParser 클래스에 identity token을 Public Key로 파싱하는 코드를 추가해줍시다.

AppleJwtParser.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
public class AppleJwtParser {
 
    private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\.";
    private static final int HEADER_INDEX = 0;
 
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
    public Map<StringString> parseHeaders(String identityToken) {
        try {
            String encodedHeader = identityToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX];
            String decodedHeader = new String(Base64Utils.decodeFromUrlSafeString(encodedHeader));
            return OBJECT_MAPPER.readValue(decodedHeader, Map.class);
        } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) {
            throw new InvalidTokenException("Apple OAuth Identity Token 형식이 올바르지 않습니다.");
        }
    }
 
    // 추가된 코드
    public Claims parsePublicKeyAndGetClaims(String idToken, PublicKey publicKey) {
        try {
            return Jwts.parser()
                    .setSigningKey(publicKey)
                    .parseClaimsJws(idToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            throw new TokenExpiredException("Apple OAuth 로그인 중 Identity Token 유효기간이 만료됐습니다.");
        } catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
            throw new InvalidTokenException("Apple OAuth Identity Token 값이 올바르지 않습니다.");
        }
    }
}
cs

만약 JWT가 만료됐다면 ExpiredJwtException 예외를 반환합니다. 애플에서 identity token을 검증할 때 아래 5가지 작업을 서버 측에서 해주어야 한다고 했던 것, 기억나시나요?

To verify the identity token, your app server must:

- Verify the JWS E256 signature using the server’s public key

- Verify the nonce for the authentication

- Verify that the iss field contains `https://appleid.apple.com`

- Verify that the aud field is the developer’s client_id

- Verify that the time is earlier than the exp value of the token

 

우리는 여기서 5번째 작업을 진행해준 것입니다. 1번째 작업도 public key를 생성하는 과정에서 진행이 완료됐습니다.


이제 Claims를 추출하는 데까지 성공했습니다! Claims를 검증하는 역할만 남았네요. 여기서 2~4번째 작업에 해당되는 검증 작업을 진행할 예정입니다.

AppleClaimsValidator.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
public class AppleClaimsValidator {
 
    private static final String NONCE_KEY = "nonce";
 
    private final String iss;
    private final String clientId;
    private final String nonce;
 
    public AppleClaimsValidator(
            @Value("${oauth.apple.iss}"String iss,
            @Value("${oauth.apple.client-id}"String clientId,
            @Value("${oauth.apple.nonce}"String nonce
    ) {
        this.iss = iss;
        this.clientId = clientId;
        this.nonce = EncryptUtils.encrypt(nonce);
    }
 
    public boolean isValid(Claims claims) {
        return claims.getIssuer().contains(iss) &&
                claims.getAudience().equals(clientId) &&
                claims.get(NONCE_KEY, String.class).equals(nonce);
    }
}
cs

여기서 검증해주는 작업은 아래 세 개와 같습니다.

- Verify the nonce for the authentication

- Verify that the iss field contains `https://appleid.apple.com` 

- Verify that the aud field is the developer’s client_id

 

먼저 해당 토큰이 애플 측에서 발급해준 identity token이 맞는지 iss를 검증합니다. 값에 `https://appleid.apple.com`가 포함되어 있는지 확인해주면 됩니다. aud 값은 apple developer에 앱 등록 시 생성된 Bundle ID 값입니다. nonce 값은 클라이언트 측에서 CSRF 공격을 방지하기 위해 생성한 임의의 문자열입니다. 우리가 정해둔 nonce값을 클라이언트에서 암호화하여 애플 서버에 identity token 발급 요청 시에 전달해주면, 애플 서버에서 해당 값을 그대로 넘겨줍니다. 따라서 우리는 설정해둔 nonce 값을 같은 방식으로 암호화하여 동일한지 검증해주는 작업을 하면 됩니다.

 

nonce 값을 검증하기 위해 SHA-256으로 암호화하여 맞는지 검증합니다.

iOS측에서도 SHA-256으로 nonce 값을 암호화하여 id token으로 담아서 보내줍니다.

SHA-256 암호화 로직은 아래와 같습니다. 저는 별도로 utils 메서드라 생각하여 아래 클래스를 별도로 만들어주었습니다.

EncryptUtils.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EncryptUtils {
 
    public static String encrypt(String value) {
        try {
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            byte[] digest = sha256.digest(value.getBytes(StandardCharsets.UTF_8));
            StringBuilder hexString = new StringBuilder();
            for (byte b : digest) {
                hexString.append(String.format("%02x", b));
            }
            return hexString.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException("Apple OAuth 통신 암호화 과정 중 문제가 발생했습니다.");
        }
    }
}
 
cs

 

이렇게 애플에서 요구한 5가지 검증이 모두 완료되었습니다.

 

Validator 클래스에서 검증 통과가 안될 경우 false만 반환하게 하고 예외를 반환하지 않았습니다. 그 이유는 Validator는 검증에서 해당 객체의 책임이 끝났다고 판단했기 때문입니다. 어떠한 예외를 반환하는지까지는 알 필요가 없다고 생각했습니다. Validator 클래스에게 메시지를 보낸 클래스가 해당 책임을 가지고 있는 것이 보다 적합하지 않을까 생각했습니다.


이제 애플 계정 유저 정보를 반환해주는 클래스를 생성하러 가봅시다.

AppleOAuthUserProvider.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
@RequiredArgsConstructor
public class AppleOAuthUserProvider {
 
    private final AppleJwtParser appleJwtParser;
    private final AppleClient appleClient;
    private final PublicKeyGenerator publicKeyGenerator;
    private final AppleClaimsValidator appleClaimsValidator;
 
    public ApplePlatformMemberResponse getApplePlatformMember(String identityToken) {
        Map<StringString> headers = appleJwtParser.parseHeaders(identityToken);
        ApplePublicKeys applePublicKeys = appleClient.getApplePublicKeys();
 
        PublicKey publicKey = publicKeyGenerator.generatePublicKey(headers, applePublicKeys);
 
        Claims claims = appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey);
        validateClaims(claims);
        return new ApplePlatformMemberResponse(claims.getSubject(), claims.get("email"String.class));
    }
    
    private void validateClaims(Claims claims) {
        if (!appleClaimsValidator.isValid(claims)) {
            throw new InvalidTokenException("Apple OAuth Claims 값이 올바르지 않습니다.");
        }
    }
}
cs

AppleJwtParser, AppleClient, PublicKeyGenerator, AppleClaimsValidator 객체들에게 메시지를 던지면서 각각의 책임을 수행해주도록 하고 있습니다. 위와 같이 책임을 분리하면 단일 책임 원칙을 준수하여 변경에 유연해질 수 있을 뿐 아니라, 테스트 코드를 작성하기에도 편리해집니다. 위에서도 잠깐 언급했듯이, 책임이 만약 몰려있다면 세부적인 테스트 상황을 작성할 때 힘들어집니다.


애플리케이션 테스트 코드

먼저 Apple Identity Token을 파싱해주는 책임을 맡는 AppleJwtParserTest를 살펴보겠습니다.

AppleJwtParserTest.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
@DisplayName("Apple identity token으로 헤더를 파싱한다")
void parseHeaders() throws NoSuchAlgorithmException {
    Date now = new Date();
    KeyPair keyPair = KeyPairGenerator.getInstance("RSA")
            .generateKeyPair();
    PrivateKey privateKey = keyPair.getPrivate();
 
    String identityToken = Jwts.builder()
            .setHeaderParam("kid""W2R4HXF3K")
            .claim("id""12345678")
            .setIssuer("iss")
            .setIssuedAt(now)
            .setAudience("aud")
            .setExpiration(new Date(now.getTime() + 1000 * 60 * 60 * 24))
            .signWith(SignatureAlgorithm.RS256, privateKey)
            .compact();
 
    Map<StringString> actual = appleJwtParser.parseHeaders(identityToken);
 
    assertThat(actual).containsKeys("alg""kid");
}
cs

올바른 형식의 identity token을 제대로 파싱할 수 있는지 확인해주기 위해 mock 객체가 아닌 실제 AppleJwtParser와 jwt 값을 생성하였습니다.

1
2
3
4
5
6
@Test
@DisplayName("올바르지 않은 형식의 Apple identity token으로 헤더를 파싱하면 예외를 반환한다")
void parseHeadersWithInvalidToken() {
    assertThatThrownBy(() -> appleJwtParser.parseHeaders("invalidToken"))
            .isInstanceOf(InvalidTokenException.class);
}
cs

예외테스트는 어렵지 않습니다. 올바르지 않은 형식 토큰을 만들기가 매우 쉽기 때문이죠. 올바르지 않은 토큰으로는 아주 단순한 문자열 값으로 테스트를 진행했습니다.

 

AppleJwtParser에는 헤더를 파싱하는 책임 뿐 아니라, 공개키와 함께 claims를 추출하는 책임도 존재합니다. 그렇기 때문에 해당 테스트도 존재해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
@DisplayName("Apple identity token, PublicKey를 받아 사용자 정보가 포함된 Claims를 반환한다")
void parsePublicKeyAndGetClaims() throws NoSuchAlgorithmException {
    String expected = "19281729";
    Date now = new Date();
    KeyPair keyPair = KeyPairGenerator.getInstance("RSA")
            .generateKeyPair();
    PublicKey publicKey = keyPair.getPublic();
    PrivateKey privateKey = keyPair.getPrivate();
    String identityToken = Jwts.builder()
            .setHeaderParam("kid""W2R4HXF3K")
            .claim("id""12345678")
            .setIssuer("iss")
            .setIssuedAt(now)
            .setAudience("aud")
            .setSubject(expected)
            .setExpiration(new Date(now.getTime() + 1000 * 60 * 60 * 24))
            .signWith(SignatureAlgorithm.RS256, privateKey)
            .compact();
 
    Claims claims = appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey);
 
    assertAll(
            () -> assertThat(claims).isNotEmpty(),
            () -> assertThat(claims.getSubject()).isEqualTo(expected)
    );
}
cs

위에서 봤던 헤더 파싱 테스트와 거의 유사하게 진행해주면 됩니다. claims에 유저 식별 정보인 subject가 존재하는지 확인해주면 됩니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Test
@DisplayName("만료된 Apple identity token을 받으면 Claims 획득 시에 예외를 반환한다")
void parseExpiredTokenAndGetClaims() throws NoSuchAlgorithmException {
    String expected = "19281729";
    Date now = new Date();
    KeyPair keyPair = KeyPairGenerator.getInstance("RSA")
            .generateKeyPair();
    PublicKey publicKey = keyPair.getPublic();
    PrivateKey privateKey = keyPair.getPrivate();
    String identityToken = Jwts.builder()
            .setHeaderParam("kid""W2R4HXF3K")
            .claim("id""12345678")
            .setIssuer("iss")
            .setIssuedAt(now)
            .setAudience("aud")
            .setSubject(expected)
            .setExpiration(new Date(now.getTime() - 1L))
            .signWith(SignatureAlgorithm.RS256, privateKey)
            .compact();
 
    assertThatThrownBy(() -> appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey))
            .isInstanceOf(TokenExpiredException.class);
}
 
@Test
@DisplayName("올바르지 않은 public Key로 Claims 획득 시에 예외를 반환한다")
void parseInvalidPublicKeyAndGetClaims() throws NoSuchAlgorithmException {
    Date now = new Date();
    PrivateKey privateKey = KeyPairGenerator.getInstance("RSA")
            .generateKeyPair()
            .getPrivate();
    PublicKey differentPublicKey = KeyPairGenerator.getInstance("RSA")
            .generateKeyPair()
            .getPublic();
    String identityToken = Jwts.builder()
            .setHeaderParam("kid""W2R4HXF3K")
            .claim("id""12345678")
            .setIssuer("iss")
            .setIssuedAt(now)
            .setAudience("aud")
            .setSubject("19281729")
            .setExpiration(new Date(now.getTime() - 1L))
            .signWith(SignatureAlgorithm.RS256, privateKey)
            .compact();
 
    assertThatThrownBy(() -> appleJwtParser.parsePublicKeyAndGetClaims(identityToken, differentPublicKey))
            .isInstanceOf(InvalidTokenException.class);
}
cs

올바르지 않은 경우의 테스트도 필요하겠죠?

토큰 만료 시각을 현재 시각보다 앞서게 만들어서 유효 기간 만료 테스트를 진행하였습니다. 또, public key와 private key를 서로 다른 KeyPairGenerator로 test double을 생성하여 올바르지 않은 key일 경우 claims를 얻을 때 예외 반환이 제대로 되는지에 대한 테스트도 진행했습니다.


공개키 구성 요소 정보들을 얻어오는 AppleClientTest는 1편에서 작성했으니 생략하도록 하겠습니다.

https://kth990303.tistory.com/436

 

[Spring] FeignClient를 이용한 Apple OAuth 구현 일지 (1)

해당 글에서는 spring boot에서의 FeignClient 선택 이유 및 연동 프로덕션 및 테스트 코드, Apple OAuth 이론에 대해 다룹니다. 사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다. 해당 프로젝트 git

kth990303.tistory.com

 

Claims 검증을 담당하는 AppleClaimsValidator에 대한 테스트 코드를 작성해보도록 하겠습니다.

AppleClaimsValidatorTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class AppleClaimsValidatorTest {
 
    private static final String ISS = "iss";
    private static final String CLIENT_ID = "aud";
    private static final String NONCE = "nonce";
    private static final String NONCE_KEY = "nonce";
 
    private final AppleClaimsValidator appleClaimsValidator = new AppleClaimsValidator(ISS, CLIENT_ID, NONCE);
 
    @Test
    @DisplayName("올바른 Claims 이면 true 반환한다")
    void isValid() {
        Map<String, Object> claimsMap = new HashMap<>();
       claimsMap.put(NONCE_KEY, EncryptUtils.encrypt(NONCE));
 
        Claims claims = Jwts.claims(claimsMap)
                .setIssuer(ISS)
                .setAudience(CLIENT_ID);
 
        assertThat(appleClaimsValidator.isValid(claims)).isTrue();
    }
 
    @ParameterizedTest
    @DisplayName("nonce, iss, aud(client_id) 중 올바르지 않은 값이 존재하면 false 반환한다")
    @CsvSource({
            "invalid, iss, aud",
            "nonce, invalid, aud",
            "nonce, iss, invalid"
    })
    void isInvalid(String nonce, String iss, String clientId) {
        Map<String, Object> claimsMap = new HashMap<>();
       claimsMap.put(NONCE_KEY, EncryptUtils.encrypt(nonce));
 
        Claims claims = Jwts.claims(claimsMap)
                .setIssuer(iss)
                .setAudience(clientId);
 
        assertThat(appleClaimsValidator.isValid(claims)).isFalse();
    }
}
cs

Map<String, Object> claimsMap = new HashMap<>()으로 작성하지 않고 Map.of(String, Object) 값을 바로 Jwts.claims에 넣어주면 Unsupportedoperationexception 이 발생할 수 있으니 주의해주세요.

 

테스트 자체는 어렵지 않습니다. 테스트 용도의 AppleClaimsValidator을 생성합니다. 이 때 AppleClaimsValidator 의 client_id, iss, aud 값으로는 테스트 용도로 적절하게 지정해줍시다. 이 값들을 올바르게 포함하고 있는 claims를 생성해주거나, 올바르지 않게 포함하고 있는 claims를 생성해주면 됩니다.

 

nonce, iss, aud 값이 올바르지 않은 케이스는 모두 false를 반환해주는 동일한 테스트이므로 @ParameterizedTest@CsvSource를 이용했습니다.


AppleOAuthUserProviderTest.class

이제 전체적인 비즈니스 로직을 다루는 AppleOAuthUserProvider 객체의 테스트를 진행하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@SpringBootTest
class AppleOAuthUserProviderTest {
 
    @Autowired
    private AppleOAuthUserProvider appleOAuthUserProvider;
 
    @MockBean
    private AppleClient appleClient;
    @MockBean
    private PublicKeyGenerator publicKeyGenerator;
    @MockBean
    private AppleClaimsValidator appleClaimsValidator;
 
    @Test
    @DisplayName("Apple OAuth 유저 접속 시 platform Id를 반환한다")
    void getApplePlatformMember() throws NoSuchAlgorithmException {
        String expected = "19281729";
        Date now = new Date();
        KeyPair keyPair = KeyPairGenerator.getInstance("RSA")
                .generateKeyPair();
        PublicKey publicKey = keyPair.getPublic();
        PrivateKey privateKey = keyPair.getPrivate();
        String identityToken = Jwts.builder()
                .setHeaderParam("kid""W2R4HXF3K")
                .claim("id""12345678")
                .claim("email""kth@apple.com")
                .setIssuer("iss")
                .setIssuedAt(now)
                .setAudience("aud")
                .setSubject(expected)
                .setExpiration(new Date(now.getTime() + 1000 * 60 * 60 * 24))
                .signWith(SignatureAlgorithm.RS256, privateKey)
                .compact();
 
        when(appleClient.getApplePublicKeys()).thenReturn(mock(ApplePublicKeys.class));
        when(publicKeyGenerator.generatePublicKey(any(), any())).thenReturn(publicKey);
        when(appleClaimsValidator.isValid(any())).thenReturn(true);
 
        ApplePlatformMemberResponse actual = appleOAuthUserProvider.getApplePlatformMember(identityToken);
 
        assertAll(
                () -> assertThat(actual.getPlatformId()).isEqualTo(expected),
                () -> assertThat(actual.getEmail()).isEqualTo("kth@apple.com")
        );
    }
 
    @Test
    @DisplayName("Claim 검증에 실패할 경우 예외를 반환한다")
    void invalidClaims() throws NoSuchAlgorithmException {
        Date now = new Date();
        KeyPair keyPair = KeyPairGenerator.getInstance("RSA")
                .generateKeyPair();
        PublicKey publicKey = keyPair.getPublic();
        PrivateKey privateKey = keyPair.getPrivate();
        String identityToken = Jwts.builder()
                .setHeaderParam("kid""W2R4HXF3K")
                .claim("id""12345678")
                .claim("email""kth@apple.com")
                .setIssuer("iss")
                .setIssuedAt(now)
                .setAudience("aud")
                .setSubject("19281729")
                .setExpiration(new Date(now.getTime() + 1000 * 60 * 60 * 24))
                .signWith(SignatureAlgorithm.RS256, privateKey)
                .compact();
 
        when(appleClient.getApplePublicKeys()).thenReturn(mock(ApplePublicKeys.class));
        when(publicKeyGenerator.generatePublicKey(any(), any())).thenReturn(publicKey);
        when(appleClaimsValidator.isValid(any())).thenReturn(false);
 
        assertThatThrownBy(() -> appleOAuthUserProvider.getApplePlatformMember(identityToken))
                .isInstanceOf(InvalidTokenException.class);
    }
}
cs

여기서 주의해야 할 점은 아래 코드입니다.

1
2
3
4
5
6
@MockBean
private AppleClient appleClient;
@MockBean
private PublicKeyGenerator publicKeyGenerator;
@MockBean
private AppleClaimsValidator appleClaimsValidator;
cs

우리는 identity token이 어떠한 형식으로 만들어지는지, public key가 어떠한 방법으로 생성되는지는 (해당 테스트에 한해서는) 관심이 없습니다. 이미 관련 책임들은 AppleJwtParser, AppleClient 등의 객체들에게 존재합니다.

실제로 애플 서버와 통신하면 지나치게 테스트가 느려지는 것도 한 몫을 합니다.

 

따라서 @MockBean 을 이용하였으며, Mockito로 아래와 같이 stub해두었습니다.

1
2
3
when(appleClient.getApplePublicKeys()).thenReturn(mock(ApplePublicKeys.class));
when(publicKeyGenerator.generatePublicKey(any(), any())).thenReturn(publicKey);
when(appleClaimsValidator.isValid(any())).thenReturn(true);
cs

만약 객체의 책임이 한 군데에 몰려있었다면 위와 같이 stub할 수 없었을 것입니다. 객체의 책임이 올바르게 분리돼있었던 덕분에 우리가 원하는대로 stub할 수 있었던 것이죠.

 

맨 처음에는 public key를 생성하는 로직을 AppleOAuthUserProvider의 getApplePlatformMember()에 합쳐놨었습니다. 하지만 public key를 임의의 mock 객체로 만들 수 없어 실제 애플 서버와 통신하여 public key를 얻어오지 않는 이상 테스트를 작성하기 힘든 상황이었습니다.

 

테스트 코드의 작성을 통해 객체의 책임 위치를 파악하고 코드를 리팩터링할 수 있었습니다. (갑자기 테스트 코드의 장점에 대해 어필하는 시간이 돼버린 것 같으니 빨리 화제를 돌리겠습니다 😅)


모카콩에서는 유저가 OAuth로 로그인할 시, 아래에 따른 비즈니스 프로세스를 따르게 됩니다.

그렇기 때문에 회원가입이 돼있는 유저인지, 돼있지 않은 유저인지에 따라 api의 response가 조금 달라지게 되는데요.

애플로그인 OAuth api response

 

참고로 platformId 값은 Claims에 담겨있던 subject에 해당됩니다.

AuthService.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public AppleTokenResponse appleOAuthLogin(AppleLoginRequest request) {
    ApplePlatformMemberResponse applePlatformMember =
            appleOAuthUserProvider.getApplePlatformMember(request.getToken());
    String platformId = applePlatformMember.getPlatformId();
 
    return memberRepository.findIdByPlatformAndPlatformId(Platform.APPLE, platformId)
            .map(memberId -> {
                // 회원가입 돼있는 경우, 유저를 찾아 토큰 발급
                Member findMember = memberRepository.findById(memberId)
                        .orElseThrow(NotFoundMemberException::new);
                String token = issueToken(findMember);
                return new AppleTokenResponse(token, findMember.getEmail(), true, platformId);
            })
            .orElseGet(() -> {
                // 회원가입 되어있지 않은 경우, 유저를 새로 저장하여 토큰 발급
                // 이 때, 유저에 대한 정보가 불충분(이메일, platformId 뿐)하므로 새로운 Member 생성자를 생성
                Member oauthMember = new Member(applePlatformMember.getEmail(), Platform.APPLE, platformId);
                Member savedMember = memberRepository.save(oauthMember);
                String token = issueToken(savedMember);
                return new AppleTokenResponse(token, applePlatformMember.getEmail(), false, platformId);
            });
}
cs

위처럼 Java8 의 stream 문법을 이용하여 간단하게 코드를 작성할 수 있습니다. 이 부분은 Apple OAuth 부분이 아닌 모카콩의 비즈니스 프로세스에 따른 모카콩 코드에 해당됩니다. 따라서 해당 로직의 테스트 코드는 여기서는 생략하겠습니다.

 

해당 로직은 꽤나 빈번하게 호출될테니 Platform과 PlatformId를 묶어서 인덱스로 만들어두면 더더욱 좋을 듯합니다.


마치며

길고 긴 여정이었던 Apple OAuth 일지는 여기서 끝이 납니다. 하지만 혹시 이러한 의문을 가지신 분, 존재하시나요?

 

Public Key를 생성하는 데에 비용이 너무 많이 드는데… 따로 캐싱해놓을 수는 없을까?

 

그렇습니다! OAuth 로그인은 자주 발생하기 때문에 외부 서버와 통신하는 과정이 꽤나 빈번하게 일어나는 반면, 실제 서버에서 public key를 갱신하는 작업은 자주 일어나지 않죠. local cache든 global cache든 캐싱해두면 정말 좋을 것 같습니다.

실제로 관련 코드리뷰도 받았습니다.

팀원분이 좋은 리뷰를 남겨주었습니다.

따라서 관련된 위키를 이후에 작성할 예정입니다.

 

Apple OAuth 일지 3편으로 작성하기보단, 다른 제목으로 돌아올 확률이 높습니다. 여기서부터는 주된 내용이 Spring AOP와 Redis가 될 것 같기 때문입니다. 이왕이면 캐싱 도입 전후 성능테스트를 진행해보고 APM으로 모니터링할 수 있다면 더더욱 좋겠죠. 필자도 정말 기대됩니다. (기대돼서 시험공부 기간에 위키 작성하고 프로젝트하고 있습니다… 시험 결과가 벌써부터 그려지네요 😭)

 

이번 위키는 여기서 마치도록 하겠습니다. 감사합니다 :)

 

참고

우아한테크코스 4기 백엔드 크루 크리스의 블로그 포스팅

https://velog.io/@byeongju/Apple-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

반응형