본문 바로가기

Proj/Tripot

소셜 로그인 - 애플 identity_token에서 사용자 정보 가져오기

1. 개요

 Tripot 프로젝트는 우선 아이폰 용으로 개발되었다. 기능 구현을 다 마치고 앱스토어에 올리려는데, 소셜 로그인을 사용하는 아이폰 앱 서비스는 애플 로그인을 필수로 구현해야 하는 조건이 있었다. OAuth2 자체는 프론트에서 구현을 해줬는데, 서버에서는 유저 고유id와 닉네임을 받아 회원 정보를 생성한다. 하지만 애플에서는 다음과 같이 응답이 전달된다.

{
  "authorizationCode": "인증 코드",
  "email": "이메일@....appleid.com",
  "fullName": {
    "familyName": "성",
    "givenName": "이름",
    "middleName": null,
    "namePrefix": null,
    "nameSuffix": null,
    "nickname": null
  },
  "identityToken": "JWT",
  "realUserStatus": 2,
  "state": null,
  "user": "user"
}

 이메일 및 이름 정도만 주어지고, 나머지는 identityToken(이하 idToken)에 JWT 형식으로 주어진다. 사용자 정보를 사용하려면 이를 풀어서 검증하고, 디코딩해야 한다. 이를 해보고자 한다.

2. 검증 과정

  토큰에서 정보를 얻는 과정은 다음과 같다.

  1. 토큰에서 header를 받는다.
  2. https://appleid.apple.com/auth/keys에 접속하면 공개 키 3개가 주어지는 데, 이중 토큰의 kid, alg와 같은 키를 받아온다.
  3. 해당 키를 이용해 signature를 검증하고, 각종 claim을 받아온다.
  4. claim을 검증하고, 필요한 정보를 사용한다.

2-1. header 받기

    public Map<String, String> parseHeaders(String token) throws JsonProcessingException {

        //JWT를 .을 기준으로 header, payload, signature 분리 -> 그 중 header 선택
        String header = token.split("\\.")[0];

        //header를 디코딩 및 매핑하여 리턴
        return new ObjectMapper().readValue(decodeHeader(header), Map.class);
    }
    
    private String decodeHeader(String token) {
        return new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
    }

 JWT는 header, payload, signature이 '.'을 기준으로 나누어져있다. 받은 토큰을 .을 기준으로 자르고, 맨 앞의 값을 디코딩하여 리턴한다.

2-2. 공개키를 비교하여 같은 키를 받아오기

 

public record ApplePublicKeyResponse(List<ApplePublicKey> keys) {
    public ApplePublicKey getMatchedKey(String kid, String alg) {
        return keys.stream()
                .filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
                .findAny()
                .orElseThrow(() -> new JwtErrorException(StatusCode.INVALID_TOKEN));
    }
}

우선 응답 dto이다. List에는 3개의 키가 담겨있고, 토큰의 kid, alg를 넣어 같은 값을 가지는 키를 가져올 수 있다.

    private ApplePublicKeyResponse getAppleAuthPublicKey() {
        return WebClient.create(pkHost).get()
                .uri(uribuilder -> uribuilder
                        .scheme("https")
                        .path("/auth/keys")
                        .build(true))
                .retrieve()
                .bodyToMono(ApplePublicKeyResponse.class)
                .block();
    }

pkHost에는 appleId 주소가 담겨있다. 이를 설정하여 요청 후 3개의 키를 받아온다.

        // 공개키를 생성한다
        PublicKey publicKey = null;
        try {
            publicKey = applePublicKeyGenerator.generatePublicKey(headers, getAppleAuthPublicKey());
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            log.error("[{}] error: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), e.getMessage());
            throw new CustomException(StatusCode.OAUTH2_LOGIN_FAILURE);
        }
        
        
    ...
    
    ApplePublicKeyGenerator.class

    public PublicKey generatePublicKey(Map<String, String> tokenHeaders, ApplePublicKeyResponse applePublicKeys)
            throws AuthenticationException, NoSuchAlgorithmException, InvalidKeySpecException {

        //3개의 PK 중 tokenHeaders의 kid, alg와 같은 key를 리턴
        ApplePublicKey publicKey = applePublicKeys.getMatchedKey(tokenHeaders.get("kid"), tokenHeaders.get("alg"));

        //해당 키의 n, e를 가져와 PK 생성
        return getPublicKey(publicKey);
    }


    private PublicKey getPublicKey(ApplePublicKey publicKey)
            throws NoSuchAlgorithmException, InvalidKeySpecException {
        byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.n());
        byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.e());

        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes),
                new BigInteger(1, eBytes));


        KeyFactory keyFactory = KeyFactory.getInstance(publicKey.kty());
        return keyFactory.generatePublic(publicKeySpec);
    }
}

각 키는 위와 같이 구성되어 있다. 이중 n, e를 사용하여 공개 키를 생성해야 한다. kty로 어떤 알고리즘을 사용할지 명시한 후 공개키를 생성한다.

 

2-3. claim 받아오기

        // 토큰의 signature를 검사하고 Claim 을 반환받는다.
        Claims tokenClaims = jwtUtil.getTokenClaims(identityToken, publicKey);
        
        ...
        
        JwtUtil.class
        
        public Claims getTokenClaims(String token, PublicKey publicKey) {
        try {
            return Jwts.parser()
                    .verifyWith(publicKey)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (MalformedJwtException e) {
            throw new JwtErrorException(StatusCode.INVALID_TOKEN);
        } catch (ExpiredJwtException e) {
            throw new JwtErrorException(StatusCode.EXPIRED_ACCESS_TOKEN);
        }
    }

 이제 idToken과 방금 생성한 공개 키를 사용하여 검증 후 클레임을 받아온다.

 

2-4. claim 검증 후 값 사용하기

        // 토큰의 signature를 검사하고 Claim 을 반환받는다.
        Claims tokenClaims = jwtUtil.getTokenClaims(identityToken, publicKey);
        // Verify that the iss field contains https://appleid.apple.com
        if (!pkHost.equals(tokenClaims.getIssuer())) {
            log.error("[{}] error: issue is not correct", Thread.currentThread().getStackTrace()[1].getMethodName());
            throw new JwtErrorException(StatusCode.INVALID_TOKEN);
        }
        // aud 필드 검사
        if (!tokenClaims.getAudience().contains(clientId)) {
            log.error("[{}] error: aud is not correct", Thread.currentThread().getStackTrace()[1].getMethodName());
            throw new JwtErrorException(StatusCode.INVALID_TOKEN);
        }

        return tokenClaims.getSubject();

 토큰을 받아왔다면 iss와 aud 필드를 사용하여 해당 토큰이 잘못되지 않았는지 확인 후 필요한 값을 사용한다.

 

3. 테스트코드

    @Test
    @DisplayName("소셜 로그인 - 애플 로그인 결과가 정상적으로 리턴되어야 함")
    public void oauth2LoginV2UsingApple() throws Exception {
        //given
        String sampleAccess = "sample_access_token";
        String sampleRefresh = "sample_refresh_token";
        String appleProvider = "apple";
        String identityToken = "identityToken";

        Map<String, String> headerMap = new HashMap<>();
        headerMap.put("kid", "rs0M3kOV9p");
        headerMap.put("alg", "RS256");

        Map<String, Object> userInfo = new HashMap<>();
        Set<String> aud = new LinkedHashSet<>();

        aud.add(appleClientId);

        userInfo.put("iss", "https://appleid.apple.com");
        userInfo.put("sub", "1234");
        userInfo.put("aud", aud);

        Claims claims = (Claims)((ClaimsBuilder)claims().add(userInfo)).build();;

        OAuth2LoginDto oAuth2LoginDto = OAuth2LoginDto.builder()
                .id(identityToken)
                .nickname("nickname")
                .build();

        given(jwtUtil.parseHeaders(identityToken)).willReturn(headerMap);
        given(jwtUtil.getTokenClaims(eq(identityToken), any(PublicKey.class))).willReturn(claims);
//        given(anySet().contains(any())).willReturn(true);
        given(jwtUtil.createJwt(any(LoginCreateJwtDto.class), eq("access"))).willReturn(sampleAccess);
        given(jwtUtil.createJwt(any(LoginCreateJwtDto.class), eq("refresh"))).willReturn(sampleRefresh);

        String content = objectMapper.writeValueAsString(oAuth2LoginDto);

        //when
        ResultActions actions = mockMvc.perform(
                post("/api/v2/login/oauth2/{provider}", appleProvider)
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(content)
        );

        //then
        actions
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.customCode").value(StatusCode.OAUTH2_LOGIN_SUCCESS.getCustomCode()))
                .andExpect(jsonPath("$.customMessage").value(StatusCode.OAUTH2_LOGIN_SUCCESS.getCustomMessage()))
                .andExpect(jsonPath("$.status").value(true))
                .andExpect(jsonPath("$.data.nickname").value("테스트사용자닉네임"))
                .andExpect(jsonPath("$.data.isActivate").value(true));

        verify(redisUtil).setDataExpire(anyString(), anyString(), anyLong());

    }

 통합 테스트코드이다. 토큰을 직접 생성하기는 애매한 면이 있어서 header와 claim을 받는 과정을 mock 처리했다. 그 외에는 다른 소셜 로그인의 테스트코드와 동일했다.