애플은 공식문서도 미친듯이 심플? -애플 로그인 구현 with Spring

이민우·2024년 10월 19일
3

🚇 가는길 지금

목록 보기
1/6


오늘 포스팅은 ‘가는길 지금’ 서비스에서 구현한 애플 로그인 구현 과정에 대해서 알아보도록 하겠습니다.

보통 소셜로그인의 포스팅은 워낙 많아서, 따로 작성하지 않으려 했지만 이번 애플 로그인은 비교적? 정보가 적어서 작성을 하게 됐습니다.

보통 요즘 앱이든 웹이든 어떤 서비스를 만들게 되면, 소셜로그인 기능이 없는 서비스를 찾기 매우 매우? 힘듭니다

그만큼 사용자가 이용하기에 편리함을 제공하기 때문에, 필수적으로 개발을 합니다

저의 개인적인 경우도 그렇고 일반적으로 보통 소셜로그인을 사용하면 네이버, 카카오, 구글 이 세 가지 소셜로그인을 많이 이용하고 애플로그인은 거의 이용하지 않아 ‘가는길 지금’ 서비스에도 위에 세 가지 로그인만 추가하려 했지만, 저희 가는길 지금 서비스는 ios도 런칭 예정인데, ios 앱의 경우에는 소셜 로그인을 사용하고 심사에 통과하려면 애플 로그인 구현을 필수적이라 구현하게 됐습니다….

🤣 Apple의 공식 Docs 미니멀리즘

애플 로그인 공식 문서는 여기서 확인 할 수 있습니다!

공식 문서를 확인해보면 , 매우 매우 정보가 적고 카카오,네이버,구글에 비해 공식문서가 매우 부실합니다.

어것또한 미니멀리즘인가?

불친절한 공식 문서 탓에 삽질을 통해서 완성을 했습니다,,,

먼저 구현 과정 들어가기 전에 앞서 , 먼저 애플 로그인 인증 흐름을 알아보도록 하겠습니다

✅ 애플 로그인 인증 흐름

애플 공식 문서에 보면 Sign in with Apple은 위와 같은 flow로 구현이 된다고 나와있습니다.(Insanely Simple …)

이걸 조금 더 이해하기 쉽게 아래 flow 다이어그램으로 살펴보겠습니다.

  1. 앱에서 사용자가 애플 로그인을 시도합니다.
  2. 가는길 지금 서버가 애플 서버의 /auth/authorize 엔드포인트에 인증 요청을 보냅니다. 애플 서버가 인가 코드를 발급하여 가는길 지금 서버에 전달합니다.
  3. 가는길 지금 서버는 받은 인가 코드pem 키와 Apple Developer에 등록한 설정 정보를 토대로 client secret를 생성
  4. 애플 서버의 /auth/token API에 토큰 검증 요청합니다.
  5. 애플 서버가 요청을 검증하고 유효하다면 access_token, refresh_token, id_token 등을 포함한 응답을 반환합니다.
  6. 가는길 지금 서버는 받은 id_token에서 사용자 정보(email, sub(고유값))를 파싱하여 DB에 저장합니다.

다이어그램을 통해 전체적인 흐름을 봤으니, 이제 본견적으로 애플 로그인을 구현하겠습니다

Apple Developer 설정 및 키 발급은 해당 포스팅에서 다루지 않습니다

🔗로그인 요청 및 인가 코드 받기

자세한 내용은 애플 공식 문서 참고 부탁드리겠습니다 🙏🙏

📌 쿼리 파라미터 뜯어보기

사용할 파라미터만 보겠습니다

  1. client_id (필수)

    Apple Developer에 등록한 Services Ids에서 identifier

  2. redirect_url (필수)

  3. response_mode

    비워두면(empty fragment) GET요청

    form_post는 redirect_url에 POST 요청

  4. response_type : code 로 해야 인가 코드 반환

  5. scope : 전달받을 정보 (name, email)

    email name 로 설정

저는 아래와 같이 로그인 요청을 진행했습니다!

/**
 * 애플 인가 코드 받아오기
 */
  private URI getAppleAuthUri() {
      return UriComponentsBuilder.fromHttpUrl("https://appleid.apple.com/auth/authorize")
              .queryParam("client_id", appleProperties.getClientId())
              .queryParam("redirect_uri", appleProperties.getRedirectUrl())
              .queryParam("scope", "email name")
              .queryParam("response_type", "code")
              .queryParam("response_mode", "form_post")
              .build().toUri();
  }

그리고 따로 설정한 redirect_url에서 해당 요청을 post로 받습니다

 	@Hidden
    @PostMapping("/apple")
    public ResponseEntity<Void> appleCallback(@RequestParam String code) {
        AppleLoginParams appleLoginParams = new AppleLoginParams(code);
        return oAuthLoginService.login(appleLoginParams);
    }

🚨 주의사항

  1. 로컬에서 테스트 불가… 도메인(https)이 등록이 돼있고 앱 환경에서만 테스트가 가능합니다.(꽤나 보수적인 애플,,)

  2. response modeform_post로 해야 개인 정보 공유한 유저에 한해, Email값이나 name값을 받아올 수 있습니다. 그래서 회원가입시 email이 필요한 서비스에서는 form_post로 설정해야 합니다

    저는 처음에 비워둬서 꽤나 삽집을 했답니다..

  3. 2번에서 말한 scope email, name은 최초 로그인 요청시에만 애플에서 제공합니다. 그래서 scope가 필요하단면, 첫 요청시 DB에 누락없이 저장을 해야합니다!

🔑 Client Secret (JWT) 생성 쉽게 이해하기

위에 과정을 통해 로그인 요청을 하고 인가 코드를 받아왔다면, 이제 저희가 Apple Developer에 설정한 cliend_id, team_id , pem 파일 등등 과 인가 코드를 통해 Client Secret을 생성해야 합니다

자세한 내용이 궁금하신 분들은 애플 공식 문서 클릭!

Client Secret은 저희가 애플 서버에 사용자 정보를 얻기 위해 " 나 진짜 이 앱 만든 팀 or 사람이야!"라고 증명하는 용도입니다
이 암호문을 만들 때는 JWT 형식을 사용합니다.

JWT 헤더

  • alg: 서명 알고리즘으로 ES256을 사용합니다.
  • kid: Apple Developer 계정의 10자리 키 식별자입니다.

JWT 페이로드

  • iss: 개발자 계정의 10자리 Team ID
  • iat: 토큰 생성 시간
  • exp: 만료 시간
  • aud: https://appleid.apple.com
  • sub: 앱의 Client ID (Services ID)

코드로 구현하기

/**
     * client_secret 생성 Apple Document URL ‣
     * https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
     * @return client_secret(jwt)
     */
    public String generateClientSecret() {
        LocalDateTime expiration = LocalDateTime.now().plusMinutes(5);

        return Jwts.builder()
                .setHeaderParam(JwsHeader.KEY_ID, appleProperties.getKeyId())
                .setIssuer(appleProperties.getTeamId())
                .setAudience(appleProperties.getAuth())
                .setSubject(appleProperties.getClientId())
                .setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant()))
                .setIssuedAt(new Date())
                .signWith(readPrivateKey(), SignatureAlgorithm.ES256)
                .compact();
    }

    /**
     * 파일에서 private key 획득
     * @return Private Key
     */
    public PrivateKey readPrivateKey() {
        try {
            String privateKeyPEM = appleProperties.getPrivateKey()
                    .replace("-----BEGIN PRIVATE KEY-----", "")
                    .replace("-----END PRIVATE KEY-----", "")
                    .replaceAll("\\s+", ""); // 공백 및 줄바꿈 제거

            byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyPEM);

            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);

            KeyFactory keyFactory = KeyFactory.getInstance("EC"); // 필요한 알고리즘으로 변경 가능 (예: "RSA")
            return keyFactory.generatePrivate(keySpec);

        } catch (Exception e) {
            throw new RuntimeException("Error converting private key from String", e);
        }
    }
  • Apple Developer에서 발급받은 p8 파일에서 데이터만 추출
  • private Key와 애플 developer 에서 설정한 값으로 client secret 을 생성합니다
    • resources 디렉토리에 p8 파일을 넣어주시면 됩니다

🔑 생성한 토큰 검증 요청 및 사용자 정보 응답

이제 방금 전 단계에서 생성한 토큰을 이제 애플 서버로 검증 요청을 보내야 합니다!/

애플 서버가 토큰을 검증하는 방법

공식문서에 따르면, identity Token내 payload에 속한 값들이 변조되지 않았는지 검증하기 위해서는 애플 서버의 public key를 사용해 JWS E256 signature를 검증해야한다고 나와있습니다

자세한 내용은 공식문서 참고 부탁드리겠습니다

유효하다면 애플 서버에서는 아래와 같은 익숙치 않은 응답을 줍니다

{
  "access_token": "cs12x...c65qq9",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "rca6...sCSqs",
  "id_token": "eyKse...23xdf"
}

정말 애플 번거럽죠?

보통 다른 OAuth는 이 단계에서 사용자의 정보를 주지만 , 애플 로그인은 여기서 id_token의 payload를 한 번 더 파싱 해서 사용자의 정보를 얻을 수 있습니다!!

✅ id_token 파싱해서 사용자 정보 얻기

아래 코드를 통해 id_token을 파싱하면
드디어….! 다음과 같은 응답을 받을 수 있습니다!

private static <T> T decodePayload(String token, Class<T> targetClass) {

        String[] tokenParts = token.split("\\.");
        String payloadJWT = tokenParts[1];
        Base64.Decoder decoder = Base64.getUrlDecoder();
        String payload = new String(decoder.decode(payloadJWT));
        ObjectMapper objectMapper = new ObjectMapper()
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        log.info("Apple 로그인 응답: " + payload);
        try {
            return objectMapper.readValue(payload, targetClass);
        } catch (Exception e) {
            throw new RuntimeException("Error decoding token payload", e);
        }
    }
{
  "iss": "https://appleid.apple.com",
  "aud": "~~~",
  "exp": 토큰 만료시간,
  "iat": 토큰 생성시간,
  "sub": "애플 사용자 식별값",
  "at_hash": "~~",
  "auth_time": ~~~,
  "nonce_supported": true,
  "email" : "사용자 이메일"
}

🚨 응답 scope email 관련 주의할 점!

애플 로그인을 진행할 경우, 간혹가다가 사용자가 자기 정보 공유하는 걸 원치 않아 email값을 제공하는 걸 동의하지 않을 수도 있습니다! (email 가리기)

이렇게 되면, 애플 서버에서는 email을 빼고 응답하기 때문에, DB에 이메일을 저장해야 하는 서비스의 경우는 email 대신에 사용자를 식별할 수 있는 저장할 대체 값이 필요합니다!!

아래 코드처럼 저는 이러한 경우를 대비해서 , sub 값을 email를 대체해서 저장해 줬습니다!

@Override
    public OAuthInfoResponse requestOauthInfo(String idToken) {
        AppleInfoResponse appleInfoResponse = decodePayload(idToken, AppleInfoResponse.class);

        // email과 nickname이 null 또는 빈 값일 경우, sub 값을 할당
        if (appleInfoResponse.getEmail() == null || appleInfoResponse.getEmail().isEmpty()) {
            appleInfoResponse.setEmail(appleInfoResponse.getSub());
        }
        return appleInfoResponse;
    }

📌 3줄 요약

  1. 여태컷 많은 소셜로그인을 구현해 봤지만, 애플로그인을 처음으로 구현해 봤다 (정보가 매우 없어서 힘들었다)
  2. response mode를 form_post로 하지 않아 email값을 파싱 할 수 없어서 삽집을 많이 했다,,,
  3. 구현하기에 앞서, 전체적인 로그인 프로세스를 잡고 가는 것을 추천한다.

참고

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api
https://gengminy.tistory.com/56#%F-%-F%--%-D%--UserService

profile
백엔드 공부중입니다!

2개의 댓글

comment-user-thumbnail
2024년 10월 21일

유익한 내용입니다! 잘 읽고가요~

1개의 답글