hoony's web study

728x90
반응형

flutter IOS Apple 회원탈퇴준수 가이드


1. 개요

 

iOS APP 출시를 위해선, 기획과 개발뿐 아니라 진행중에

 

정책이라는 큰 장벽에 부딪히기도 합니다.

 

이번에는, 정책 중 2022년 6월 30일부터 시작된 가이드라인에 대해 알아보도록 하겠습니다.

 

2022년 6월 30일부터 APP에 회원탈퇴에 대한 기능이 필수로 요구됨을 가이드라인에서

 

참조 : https://developer.apple.com/news/?id=12m75xbj

 

안내해주고 있습니다. 해당 정책을 준수하기 위한 절차를 밟아보도록 합니다.

 

2. 환경과 실 적용

 

현재, APP 단은 flutter 로 작성되었으며, https://pub.dev/packages/sign_in_with_apple 패키지를 사용중입니다.

 

패키지를 사용하기 위해 해당 패키지홈페이지의 가이드라인에 따라 설정을 먼저 해줍니다.

 

final credential = await SignInWithApple.getAppleIDCredential(
      scopes: [
        AppleIDAuthorizationScopes.email,
        AppleIDAuthorizationScopes.fullName,
      ],
    );

 

버튼 클릭시, 해당 패키지의 메서드를 이용하여 ID의 Credential 정보를 받아오고 있습니다.

 

아직 이 과정은, Apple의 정책에 따르면 Back-End에 거치는 작업은 현재 수중에 드러나지 않은 상태입니다.

 

우선, Apple 에서 권장하는 프로세스는 아래 이미지와 같습니다.

 

 

flutter 패키지를 이용하여 구현시, glitch 서버 설정과 함께 우선적으로 App에 요청합니다.

 

그렇게 우리는 UserCredential 을 상단의 코드로 받아볼 수 있습니다. 이후, 해당 Credential 을 가지고

 

Back-End에서의 작업을 살펴보도록 하겠습니다.

 

우선적으로, 아래 코드는 SpringBoot를 기준으로 작성되었음을 알려드립니다.

 

Client Secret 생성

 

Apple Developer Documentation


문서를 확인해보시면 client_secret은 다음과 같은 내용을 포함하여 JWT형식의 토큰을 생성해야 합니다.

 

{
    "alg": "ES256",
    "kid": "XYZ123ABCD"
}
{
    "iss": "ZYX987FDEG",
    "iat": 1467179036,
    "exp": 1493908100,
    "aud": "https://appleid.apple.com",
    "sub": "com.example.apps"
}

 

alg :  ES256을 사용하도록 하겠습니다.

 

kid :  Apple Developer 페이지에 명시되어있는 Key ID

 

 https://developer.apple.com/account/resources/authkeys/list

 

로그인 - Apple

 

idmsa.apple.com

 

iss : Developer 페이지에 명시되어있는 Team ID

 

 https://developer.apple.com/account/#!/membership

 

로그인 - Apple

 

idmsa.apple.com

iat : client secret이 생성된 일시를 입력. (현재시간)

 

exp : client secret이 만료될 일시를 입력. (현재시간으로 부터 6개월을 초과하면 안된다.)

 

aud : "https://appleid.apple.com".

 

sub : App의 Bundle ID 값을 입력.

 

저는 해당 값을 yml 로 미리 빼두었습니다.

 

Token 생성을 위한 정보

 

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

 

Apple Developer Documentation

 

developer.apple.com

 

URL

 

POST https://appleid.apple.com/auth/token

 

HTTP Body

 

form-data

The list of input parameters required for the server to validate the authorization code or refresh token.

Content-Type: application/x-www-form-urlencoded
 
 

Parts

 
key type required
client_id string Required
client_secret string Required
code string  
grant_type string Required
refresh_token string  
redirect_uri string  

 

 

위의 URL을 확인해보시면, Apple에서의 Token 생성을 위한 API 정보가 명시되어 있습니다.

 

grant_type 을 명시해주어야 하는데,

 

authorization_code 를 사용시 code 파라미터를 사용해주시면 되고,

 

refresh_token 을 사용시 refresh_token 파라미터를 사용해주시면 됩니다.

 

Revoke를 위한 정보

 

https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens

 

Apple Developer Documentation

 

developer.apple.com

 

URL

 

POST https://appleid.apple.com/auth/revoke

 

HTTP Body

form-data

The list of input parameters required for the server to invalidate the token.

Content-Type: application/x-www-form-urlencoded
 
 

Parts

 
key type required
client_id string Required
client_secret string Required
token string Required
token_type_hint    

 

애플 회원 탈퇴시 토큰 해지처리를 위한 정보입니다.

 

해당정보들을 토대로 아래에서, 코드를 작성해주도록 하겠습니다.

 

Apple Login 관련 Util Java 생성

 

[AppleUtil.java]

 

package com.example.appleloginspringboot.utils;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.PrivateKey;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
public class AppleUtil {

    @Value("${apple.signin.bundle-id}")
    private String appleAppBundleId;

    @Value("${apple.signin.key-id}")
    private String appleSignKeyId;

    @Value("${apple.signin.team-id}")
    private String appleTeamId;

    @Value("${apple.signin.key-path}")
    private String appleSignKeyFilePath;

    public String createClientSecret() throws IOException {
        Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
        Map<String, Object> jwtHeader = new HashMap<>();
        jwtHeader.put("kid", appleSignKeyId);
        jwtHeader.put("alg", "ES256");

        return Jwts.builder()
                .setHeaderParams(jwtHeader)
                .setIssuer(appleTeamId)
                .setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간
                .setExpiration(expirationDate) // 만료 시간
                .setAudience("https://appleid.apple.com")
                .setSubject(appleAppBundleId)
                .signWith(getPrivateKey())
                .compact();
    }

    public PrivateKey getPrivateKey() throws IOException {
        ClassPathResource resource = new ClassPathResource(appleSignKeyFilePath);
        String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));

        Reader pemReader = new StringReader(privateKey);
        PEMParser pemParser = new PEMParser(pemReader);
        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
        return converter.getPrivateKey(object);
    }

    public HashMap<String, Object> generateAuthToken(String authorizationCode) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        RestTemplate restTemplate = new RestTemplateBuilder().build();
        HashMap<String, Object> rtnMap = new HashMap<String, Object>();
                String authUrl = "https://appleid.apple.com/auth/token";

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("code", authorizationCode);
        params.add("client_id", appleAppBundleId);
        params.add("client_secret", createClientSecret());
        params.add("grant_type", "authorization_code");

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

        try {
            ResponseEntity<String> response = restTemplate.postForEntity(authUrl, httpEntity, String.class);
            HashMap respMap = objectMapper.readValue(response.getBody(), HashMap.class);

            rtnMap.put("statusCode", response.getStatusCodeValue());
            rtnMap.put("accessToken", respMap.get("access_token"));
            rtnMap.put("refreshToken", respMap.get("refresh_token"));
            rtnMap.put("idToken", respMap.get("id_token"));
            rtnMap.put("expiresIn", respMap.get("expires_in"));

            return rtnMap;
        } catch (HttpClientErrorException e) {
            log.error("Apple Auth Token Error");
            HashMap respMap = objectMapper.readValue(e.getResponseBodyAsString(), HashMap.class);
            rtnMap.put("statusCode", e.getRawStatusCode());
            rtnMap.put("errorDescription", respMap.get("error_description"));
            rtnMap.put("error", respMap.get("error"));

            return rtnMap;
        }
    }

}

 

 

저의 경우, 위와같이 코드를 작성하여 컨트롤러로 호출하여 테스트를 해보았는데, 문제없이 진행됩니다.

 

아래는, 해당 코드를 작성하기 위해 추가사용된 패키지를 Gradle 기준으로 적어두었습니다.

 

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.bouncycastle:bcprov-jdk18on:1.72'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.72'
728x90

공유하기

facebook twitter kakaoTalk kakaostory naver band
loading