Computer Engineering/Server

Spring boot / Java 서버 애플로그인 api 연동

말하는호구마 2021. 9. 13. 00:29

2020년 4월부터 iOS앱에서 소셜로그인만을 지원하는 앱은 애플로그인을 필수적으로 제공해야한다는 정책이 추가되었다. 

https://developer.apple.com/app-store/review/guidelines/#sign-in-with-apple

 

App Store Review Guidelines - Apple Developer

App Store Review Guidelines Apps are changing the world, enriching people’s lives, and enabling developers like you to innovate like never before. As a result, the App Store has grown into an exciting and vibrant ecosystem for millions of developers and

developer.apple.com

앱을 출시하기 위해서는 심사를 거치게 되는데, iOS에서는 애플로그인이 없는 앱은 리젝당한다...

따라서 내가 진행하는 프로젝트에서도 애플로그인을 구현했어야 했다. 

 

 

전에 카카오 로그인, 구글 로그인 모두 서버 연동 구현을 해봤지만 애플은 타 소셜로그인 api와 매우매우 상이하다...

카카오, 구글 로그인은 access token을 해당 회사의 api로 던져주면 바로 회원정보를 얻을 수 있는 형태이다. 

하지만 애플은 상대적으로 복잡하고 용어들이 조금 달라 조금 헤맸다...^^^**

 

 

공식 문서도 그리 친절한 편이 아니라 개발자들의 원성이 자자한 것 같다,, 

사실 정말 어렵지는 않다. 하지만 구글링을 통해 봤던 블로그들이 내가 생각했던 것 보다 복잡하게 되어있어서 조금 당황했다. 

그래서 다른 블로그들의 코드를 인용하기 보단, 내가 필요한 부분만 구현하기 위해 방법만 숙지하고 직접 코드를 작성했다...ㅎ

아직 미숙한 주니어가 대충쓴 코드라 부끄럽지만,,, 삽질 기록과 함께 혹시나 나같은 개발자들에게 미약한 도움이 될까 싶어 작성하게 되었다. 

 

 

 

나는 iOS 클라이언트로 부터 token 인증 요청을 받고, Spring boot 서버를 통해 애플로그인 api연동을 구현했다. 

자체적인 로그인 토큰을 사용하기 때문에 사용자의 정보만이 필요했다.

 


 

1.  연동 프로세스 

코드를 보기에 앞서 애플로그인 연동의 플로우를 파악하는게 좋다. 

1. iOS 클라이언트로부터 identity token을 받는다.

      --> 이 시점에서 내가 헤맸던 점은, 용어이다. 

             다른 소셜 로그인들은 access token이란 것을 받지만 애플로그인에서는 identity token이 그를 대신한다.

             iOS에서는 [authorization code, identity token] 이 두가지를 받을 수 있는데, 이 중 서버에서는 identity token을

             사용한다. 

2. apple api를 통해 공개키 3개를 받아온다. 

3. 클라이언트로부터 받은 identity token을 decode한다. 

4. 3개의 공개키 중, 클라이언트에서 받은 identity token의 kid, alg 값이 같은 공개키를 찾는다.

5. 그 공개키들의 재료들로 새로운 공개키를 만들고, 이 공개키로 JWT토큰 바디부분을 decode하면 유저 정보 확인 가능하다 .

 

 

 


 

2.  구현

 

코드는 총 두가지 방식을 보여주고자 한다. 

첫번째 방법은, 애플로부터 response받은 정보가 JSON형식임을 이용하는 방법이다. 처음 구현할 때, 시간이 촉박하고 전체적인 구조가 파악이 덜 됐을 때, 구현했던 방식이다. 어떤 애플로부터 정보들을 가져와야하는지 잘 모르는 상태에서, 일단 받아보고 데이터를 파악할 수 있었다. 

두번째 방법은, 첫번째 방식의 코드를 배포하고 리팩토링한 결과이다. 생으로 JSON을 파싱하는 것보다 DTO로 매핑해서 오는 방법이다. 가독성 증가 효과를 기대한다. 

 


1. JSON 형식을 사용

1. iOS 클라이언트로부터 identity token을 받는다.

내가 진행하던 프로젝트에는 카카오 로그인이 있었기 때문에 코드가 섞여있지만,  박스친 부분만 확인하면 될 것이다. 

여느 로그인 기능과 같이 identity token은 RequestHeader로 받아왔다. 

성공적으로 identity token이 들어왔다면 이를 서비스단으로 전달한다. 

 

 

더욱 쉬운 이해를 위해 Service 전체 코드를 앞서 배치했다. 

@Service
@RequiredArgsConstructor
public class AppleService {

    /**
     * 1. apple로 부터 공개키 3개 가져옴
     * 2. 내가 클라에서 가져온 token String과 비교해서 써야할 공개키 확인 (kid,alg 값 같은 것)
     * 3. 그 공개키 재료들로 공개키 만들고, 이 공개키로 JWT토큰 부분의 바디 부분의 decode하면 유저 정보
     */
    public String userIdFromApple(String idToken) {
        StringBuffer result = new StringBuffer();
        try {
            URL url = new URL("https://appleid.apple.com/auth/keys");
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));

            String line = "";

            while ((line = br.readLine()) != null) {
                result.append(line);
            }
        } catch (IOException e) {
            throw new BusinessException(ErrorCode.FAILED_TO_VALIDATE_APPLE_LOGIN);
        }

        JsonParser parser = new JsonParser();
        JsonObject keys = (JsonObject) parser.parse(result.toString());
        JsonArray keyArray = (JsonArray) keys.get("keys");


        //클라이언트로부터 가져온 identity token String decode
        String[] decodeArray = idToken.split("\\.");
        String header = new String(Base64.getDecoder().decode(decodeArray[0]));

        //apple에서 제공해주는 kid값과 일치하는지 알기 위해
        JsonElement kid = ((JsonObject) parser.parse(header)).get("kid");
        JsonElement alg = ((JsonObject) parser.parse(header)).get("alg");

        //써야하는 Element (kid, alg 일치하는 element)
        JsonObject avaliableObject = null;
        for (int i = 0; i < keyArray.size(); i++) {
            JsonObject appleObject = (JsonObject) keyArray.get(i);
            JsonElement appleKid = appleObject.get("kid");
            JsonElement appleAlg = appleObject.get("alg");

            if (Objects.equals(appleKid, kid) && Objects.equals(appleAlg, alg)) {
                avaliableObject = appleObject;
                break;
            }
        }

        //일치하는 공개키 없음
        if (ObjectUtils.isEmpty(avaliableObject))
            throw new BusinessException(ErrorCode.FAILED_TO_FIND_AVALIABLE_RSA);

        PublicKey publicKey = this.getPublicKey(avaliableObject);

        //--> 여기까지 검증

        Claims userInfo = Jwts.parser().setSigningKey(publicKey).parseClaimsJws(idToken).getBody();
        JsonObject userInfoObject = (JsonObject) parser.parse(new Gson().toJson(userInfo));
        JsonElement appleAlg = userInfoObject.get("sub");
        String userId = appleAlg.getAsString();

        return userId;
    }

    public PublicKey getPublicKey(JsonObject object) {
        String nStr = object.get("n").toString();
        String eStr = object.get("e").toString();

        byte[] nBytes = Base64.getUrlDecoder().decode(nStr.substring(1, nStr.length() - 1));
        byte[] eBytes = Base64.getUrlDecoder().decode(eStr.substring(1, eStr.length() - 1));

        BigInteger n = new BigInteger(1, nBytes);
        BigInteger e = new BigInteger(1, eBytes);

        try {
            RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
            return publicKey;
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.FAILED_TO_FIND_AVALIABLE_RSA);
        }
    }
}

 

 

2. apple api를 통해 공개키 3개를 받아온다. 

result를 확인해보면 아래와 같은 데이터를 얻을 수 있다. 

더보기

{

"keys": [

         {

         "kty": "RSA",

         "kid": "eXaunmL",

         "use": "sig",

         "alg": "RS256",

         "n":                                                                                                                                                                                      "4dGQ7bQK8LgILOdLsYzfZjkEAoQeVC_aqyc8GC6RX7dq_KvRAQAWPvkam8VQv4GK5T4ogklEKEvj5ISBamdDNq1n52TpxQwI2EqxSk7I9fKPKhRt4F8-2yETlYvye-2s6NeWJim0KBtOVrk0gWvEDgd6WOqJl_yt5WBISvILNyVg1qAAM8JeX6dRPosahRVDjA52G2X-Tip84wqwyRpUlq2ybzcLh3zyhCitBOebiRWDQfG26EH9lTlJhll-p_Dg8vAXxJLIJ4SNLcqgFeZe4OfHLgdzMvxXZJnPp_VgmkcpUdRotazKZumj6dBPcXI_XID4Z4Z3OM1KrZPJNdUhxw",

         "e": "AQAB"

         },

         {

         "kty": "RSA",

         "kid": "86D88Kf",

         "use": "sig",

         "alg": "RS256",

         "n": "iGaLqP6y-SJCCBq5Hv6pGDbG_SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInqUvjJur--hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPygjLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk-ILjv1bORSRl8AK677-1T8isGfHKXGZ_ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw-zHLwQ",

         "e": "AQAB"

         },

         {

         "kty": "RSA",

         "kid": "YuyXoY",

         "use": "sig",

         "alg": "RS256",

         "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",

         "e": "AQAB"

         }

]

}

keys라는 jsonArray를 받을 수 있고 3개의 공개키로 구성되어 있다. 

 

 

 

3. 클라이언트로부터 받은 identity token을 decode한다. 

identity token은 jwt토큰이므로 decode하면 header, payload를 얻을 수 있을 것이다. 

이 중 우리는 헤더의 kid,alg 데이터의 값이 필요하다. 

이를 알아내서 이전에 애플로부터 얻었던 공개키들 중 우리에게 필요한 공개키가 무엇인지를 알아낼 수 있다!

 

 

 

 

4. 3개의 공개키 중, 클라이언트에서 받은 identity token의 kid, alg 값이 같은 공개키를 찾는다.

3개의 공개키가 JsonArray로 배열안에 있었기 때문에 내부의 JsonObject(공개키)를 하나씩 확인해보는 과정이다. 

JsonObject의 "kid","alg"를 Element형식으로 뽑아내고, identity token으로 부터 얻은 kid, alg값과 비교한다. 

만약 kid, alg가 일치하는 공개키를 찾는다면 이를 avaliableObject를 통해 가지고 나온다. 

만약 반복문을 다 돌았는데 일치하는 공개키가 없어 avaliableObject가 여전히 null을 띄고 있다면 Exception을 발생시킨다. 

 

앞으로 우리가 사용할 공개키는 avaliableObject에 담겼다!

 

 

 

5. 그 공개키들의 재료들로 새로운 공개키를 만들고, 이 공개키로 JWT토큰 바디부분을 decode하면 유저 정보 확인 가능하다 .

우리는 정보들이 일치하는 하나의 공개키의 "n","e"의 데이터들도 새로운 공개키를 만들 것이다. 

해당 정보들은 인코딩되어 있는 상태기 때문에 decode해서 사용하는 과정이 꼭 필요하다. 

 

(DTO로 매핑하지 않아서, 문자열을 수정하는 코드들이 포함되어 있다. 사실 정말 나쁜코드이다 ^^^;;;

하지만 기능을 구현하는데 급급했었고  내가 겪었었던 과정이기 때문에 이를 기록하고자 했다.)

 

이렇게 새로운 공개키를 만들고, 이를 다시 decode하여 "sub"의 데이터를 통해 계정 고유아이디를 확인할 수 있다. 

 

이렇게 얻은 userId를 사용해 우리 프로젝트가 발행하는 고유의 토큰으로 만들 수 있었다. 

 


 

2.DTO로 매핑해오는 방식

다른 많은 블로그들이 포스팅했던 방식이다. 

앞서 소개 했던 방법보다 훨씬 좋은 코드가 될 것이다. 남들이 봐도 무슨 데이터를 가져오고자 하는지 명확하게 보인다고 생각한다.

(이렇게 DTO의 중요성을 한번더,,)

하지만 처음 구현할 때는 어떤 데이터들이 오는지 정확히 모르기 때문에 처음부터 이 방법 조금 무리였다고 판단했다,,

물론 다른 블로그들을 보고 따라한다면 했겠지만 뭔지도 모르고 따라하기는 싫었다.

 

 

--> 작성중