View

300x250

플러터에서 애플 로그인 회원탈퇴를 할 수 있는 로직을 아래에 작성해놨다!!

필요한 사람은 참고하자!!

 

갸아아아악 진짜 이걸로 고생했다. 🥺🥺💦

이번에 앱 출시를 위해서 앱스토어에 심사를 올렸다가, 회원가입이 있으면 회원탈퇴도 있어야 한다는 심사 기준으로 인해 리젝을 받았다.

당연히 Sign_In_With_Apple 패키지에서 해당 기능을 함수로 구현해뒀을 것이라 생각하고, 바로 회원탈퇴 기능 구현 후 심사 재제출을 하려고 했는데, 오잉? 그런 기능은 제공되고 있지 않았다. (흑흑따)

파이어베이스나 다른 기능들처럼 또 다른 패키지의 방식으로 해당 기능을 제공할 것이라고 생각했는데, 왠걸? 특정 URL로 POST 쿼리를 날려야 이를 구현해준다고 되어있다. (나 웹 방식은 진짜 감도 안온단말이야…) 그치만 어떻게 해. 바로 구현을 위해 고군분투 했다.

위 규정 자체가 2022년에 추가되었기에 찾아볼 수 있는 자료도 적었고, 그 중에서 플러터로 구현한 코드의 수는 더욱 적어서 구현하기가 더 어려웠던 것 같다. 찾아보면 java로 처리하기 / 네이티브 코드에서 처리하기 / JS로 올려서 처리하기 등의 방법은 많았는데, 막상 플러터의 Dart 언어로 처리하는 예시 코드는 거의 찾아보기가 어려웠다.

그래서, 공식문서와 구글링, 챗지피티를 합쳐 만든 (거의 잡탕이지만 나름 정리가 되어있는) 플러터 코드를 기록으로 남겨두려고 한다. 누군가는 보고 도움을 얻고, 누군가는 또 이걸 더 깔삼한 방식으로 정리해서 한 번 더 블로그에 아티클로 남겨두겠지 😚

이게 귀찮을 것 같으면, 그냥 “메일로 우리한테 남겨주면 계정탈퇴 시켜드릴게요” 라고 적어두고, 버튼 누르면 메일 앱 켜주는걸 구현하자. 문서를 읽어보니, 그런 방식으로라도 계정탈퇴를 구현해두면 괜찮다고 작성되어있었다 ㅋㅋㅋ

 


애플로그인 회원탈퇴 절차

1. Private Key 가져오기

이전에 애플 로그인을 만들던 시점에, Apple Developer 에서 Key를 등록했던 기억이 있을 것이다. 이때, Key를 생성해주면서 “Private Key”가 작성되어있는 단 한 번만 다운로드 할 수 있는 p8 파일을 다운로드 받으면서 “나중에 필요하니 잘 보관해주세요” 라는 안내를 받았을 것이다. 이게 여기에서 사용된다!

 

Private Key를 사용하기에 앞서서, Private Key에 해당하는 Key 에 “Sign in with Apple” 항목을 활성화해뒀는지 한 번 체크해보자. 나는 이때 푸쉬 알림을 위해 Key를 발급해뒀던 터라, 애플로그인 항목이 활성화가 되어있지 않았었다!

다운로드 해뒀던 파일을 열어보면 아래처럼 key가 들어있다.

-----BEGIN PRIVATE KEY-----
MI... 대충 길게
Ei... 4줄로
L4... 비밀번호가
Jb... 적혀있음
-----END PRIVATE KEY-----

요걸 프로젝트에 포함시켜줘야하는데, 아무래도 Private Key 이다보니 깃허브에 실수로 올라가면 무슨 대환장파티가 일어날 지 모른다. 요걸 숨기는 방법도 찾아 적용해줬다.

환경 값들을 저장할 수 있는 dotenv 라는 패키지를 사용해주자.

https://pub.dev/packages/flutter_dotenv

 

flutter_dotenv | Flutter package

Easily configure any flutter application with global variables using a `.env` file.

pub.dev

flutter pub add flutter_dotenv

위 패키지를 설치해주고 난 다음, 아래 이미지처럼 pubspec.yaml 파일과 동일한 계층 (프로젝트 디렉토리의 root)에 .env 라는 파일을 추가해준다. 그리고 pubspec.yaml 파일에는 assets 에 요 .env 파일을 추가하여, 파일에 접근이 가능하도록 만들어준다.

 

(별 5개) .env 파일을 gitignore에 등록하는 것을 잊지말자!!

그리고, .env 파일에는 아까 찾아둔 Private Key를 아래처럼 작성해준다. 이것도 이유가 있다.

APPLE_PRIVATE_KEY_LINE1=-----BEGIN PRIVATE KEY-----
APPLE_PRIVATE_KEY_LINE2=MI...
APPLE_PRIVATE_KEY_LINE3=Ei...
APPLE_PRIVATE_KEY_LINE4=L4...
APPLE_PRIVATE_KEY_LINE5=Jb...
APPLE_PRIVATE_KEY_LINE6=-----END PRIVATE KEY-----

문자열 형태로 key를 넣어두고 자꾸 작동을 안해서 한 30분 동안 디버깅을 했는데, 여러가지 문제가 있었다.

  1. Private Key의 머리와 꼬리 (——KEY——) 도 key에 포함되며, 개행 (\n) 도 key에 포함된다. 이걸 깨트리니깐 parsing error가 발생한다. 이 형식을 그대로 지켜줘야한다.
  2. 그런데 .env 에서는 여러 줄에 걸쳐 문자열을 작성할 수 없었다 (문자열 여러줄 ← 이게 안됐음)
  3. 따라서, 알아서 분리된걸 사용하는 시점에 잘 합쳐줘야한다.
  4. 물론 더 섹시한 방법도 있겠지만, 나는 재심사를 하루라도 빨리 신청하고 앱스토어에 앱을 등록하고싶다.

이렇게 .env에 key를 줄 별로 나누어 넣어두면, 아래처럼 합쳐서 private key로 활용할 수 있다.

final String privateKey = [
  dotenv.env['APPLE_PRIVATE_KEY_LINE1']!,
  dotenv.env['APPLE_PRIVATE_KEY_LINE2']!,
  dotenv.env['APPLE_PRIVATE_KEY_LINE3']!,
  dotenv.env['APPLE_PRIVATE_KEY_LINE4']!,
  dotenv.env['APPLE_PRIVATE_KEY_LINE5']!,
  dotenv.env['APPLE_PRIVATE_KEY_LINE6']!,
].join('\\n');

2. 회원탈퇴 구현 코드

회원 탈퇴 코드는 아래처럼 작성해줬다. 복사해서 사용하자!!

<<여기에 적혀있는 것들은>> 자신의 설정에 맞게 채워주면 된다.

코드의 설명은 코드 아래에 작성해뒀다.

// 나는 함수 반환형이 이렇게 되어있음. 필요한 방향으로 알아서 바꿔 사용하면 될 것 같다
Future<Either<Failure,void>> revokeSignInWithApple() async {
	try {
	    final appleCredential = await SignInWithApple.getAppleIDCredential(
	      scopes: [
	        AppleIDAuthorizationScopes.email,
	        AppleIDAuthorizationScopes.fullName,
	      ],
	    );
	    final String authCode = appleCredential.authorizationCode;
	
	    final String privateKey = [
	      dotenv.env['APPLE_PRIVATE_KEY_LINE1']!,
	      dotenv.env['APPLE_PRIVATE_KEY_LINE2']!,
	      dotenv.env['APPLE_PRIVATE_KEY_LINE3']!,
	      dotenv.env['APPLE_PRIVATE_KEY_LINE4']!,
	      dotenv.env['APPLE_PRIVATE_KEY_LINE5']!,
	      dotenv.env['APPLE_PRIVATE_KEY_LINE6']!,
	    ].join('\\n');
	
	    const String teamId = '<<팀 ID>>';
	    const String clientId = '<<앱 번들ID>>';
	    const String keyId = '<<키 ID>>';
	
	
	    final String clientSecret = createJwt(
	      teamId: teamId,
	      clientId: clientId,
	      keyId: keyId,
	      privateKey: privateKey,
	    );
	
	    final accessToken = (await requestAppleTokens(
	      authCode,
	      clientSecret,
	      clientId,
	    ))['access_token'] as String;
	    const String tokenTypeHint = 'access_token';
	
	    await revokeAppleToken(
	      clientId: clientId,
	      clientSecret: clientSecret,
	      token: accessToken,
	      tokenTypeHint: tokenTypeHint,
	    );
	
	    return right(null);
	  } on Exception catch (e) {
	    return left(Failure('사용자 계정 삭제 중 오류 발생: $e'));
	  }
	}
}

위 코드의 하위 함수들은 아래처럼 작성해줬다. 이것도 같이 붙여넣고 사용해주면 된다.

// JWT 생성 함수
String createJwt({
  required String teamId,
  required String clientId,
  required String keyId,
  required String privateKey,
}) {
  final jwt = JWT(
    {
      'iss': teamId,
      'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
      'exp': (DateTime.now().millisecondsSinceEpoch ~/ 1000) + 3600,
      'aud': '<https://appleid.apple.com>',
      'sub': clientId,
    },
    header: {
      'kid': keyId,
      'alg': 'ES256',
    },
  );

  final key = ECPrivateKey(privateKey);
  return jwt.sign(key, algorithm: JWTAlgorithm.ES256);
}

// 사용자 토큰 취소 함수
EitherFuture revokeAppleToken({
  required String clientId,
  required String clientSecret,
  required String token,
  required String tokenTypeHint,
}) async {
  final url = Uri.parse('<https://appleid.apple.com/auth/revoke>');
  final response = await http.post(
    url,
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: {
      'client_id': clientId,
      'client_secret': clientSecret,
      'token': token,
      'token_type_hint': tokenTypeHint,
    },
  );

  if (response.statusCode == 200) {
    // 토큰이 성공적으로 취소됨
    return right(null);
  } else {
    return left(Failure('토큰 취소 중 오류 발생 : ${response.statusCode}'));
  }
}

Future<map<string, dynamic="">> requestAppleTokens(
  String authorizationCode,
  String clientSecret,
  String clientId,
) async {
  final response = await http.post(
    Uri.parse('<https://appleid.apple.com/auth/token>'),
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: {
      'client_id': clientId,
      'client_secret': clientSecret,
      'code': authorizationCode,
      'grant_type': 'authorization_code',
      'redirect_uri': 'YOUR_REDIRECT_URI', // 필요시 설정
    },
  );

  if (response.statusCode == 200) {
    return jsonDecode(response.body);
  } else {
    throw Exception('토큰 요청 실패: ${response.body}');
  }
}
</map<string,>

1) revokeAppleToken

우선 사용자를 탈퇴시키는 것을 ‘토큰 취소’라고 부르는 것 같다. 요걸 하기 위해서는 애플에서 시키는대로, “https://appleid.apple.com/auth/revoke URL로 내 앱과 사용자의 정보에 대한 쿼리를 보내주면 된다.

쿼리에 포함되어야 하는 정보는 공식문서에서 확인할 수 있다.

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

 

위 쿼리를 날리는 메서드를 revokeAppleToken 로 작성해주었다. 이 함수에는 4가지 인자 [ 앱 번들 ID, 클라이언트 정보(JWT), 사용자 access token, 사용자 토큰의 타입 ] 를 받는다. 토큰의 타입은 refresh_token 또는 access_token 2가지가 있으며, token 에 전달한 데이터가 어떤 토큰인지를 적어주면 된다. 나의 경우에는 access_token을 사용해줬다. 혹시 위 코드를 고쳐서 refresh_token을 사용해줄 계획이라면 token_type_hint 부분도 함께 수정해주자.

이 함수를 실행하기 위해서는 2가지—client_secret(JWT), 사용자 access token—가 필요하다. 이걸 이제 만들어보자.

2) client_secret (JWT)

쉽게 말해, 클라이언트 (앱 서비스) 에 대한 정보를 Json Web Token 에 담아 쿼리에 담아 보내줘야한다. 그러면 웹 서버에서는 이 요청이 유효헌 앱 서비스에서 제대로 날아왔는지를 확인하고 처리를 해준다. 앱 ID, 앱 번들 ID, 팀 ID와 함께 private key를 통해 해당 데이터들이 유효한지 검증하고, 앱에 대한 요청을 처리해주는 것으로 보인다.

      const String teamId = '<<팀 ID>>';
      const String clientId = '<<앱 번들ID>>';
      const String keyId = '<<키 ID>>';
      final String privateKey = [
        dotenv.env['APPLE_PRIVATE_KEY_LINE1']!,
        dotenv.env['APPLE_PRIVATE_KEY_LINE2']!,
        dotenv.env['APPLE_PRIVATE_KEY_LINE3']!,
        dotenv.env['APPLE_PRIVATE_KEY_LINE4']!,
        dotenv.env['APPLE_PRIVATE_KEY_LINE5']!,
        dotenv.env['APPLE_PRIVATE_KEY_LINE6']!,
      ].join('\\n');
      
      final String clientSecret = createJwt(
        teamId: teamId,
        clientId: clientId,
        keyId: keyId,
        privateKey: privateKey,
      );

여기에 사용되는 키 ID는 아까전에 Certificates 에서 private key 파일을 받았던 바로 그 key의 ID를 넣어주면 된다. 팀 ID와 앱 번들 ID도 함께 작성해주자. 요 두 개는 어디있는지 못찾겠으면 Xcode Configuration 을 찾아가자. 거기에도 동일하게 작성되어있다!

 

이제 “어떤 앱에서” 이 요청을 보냈고, “유효한 요청인지”에 대해서는 확인할 수 있다. 이제 “어느 사용자에게”를 알려주기 위한 사용자 Access Token 을 만드는 방법을 찾아보자.

3) 사용자 Access Token

요 녀석이 조금 의아했다.

사용자의 access token을 발급받기 위해서는 위의 revoke와 유사하게, “https://appleid.apple.com/auth/token URL로 현재 사용자의 정보를 쿼리로 보내고, 그에 따른 사용자 Access Token을 발급받을 수 있다.

다만, 현재 사용자 정보를 authorizationCode 로 보내도록 되어있는데 요 부분이 살짝 귀찮다.

기본적으로 사용자가 Sign in with Apple 로 로그인을 하면 authorizationCode를 발급해주는데, 이 코드의 유효기간이 5분 정도이다. 심지어 1회용 코드라서 발급받은 코드로 뭔가 처리를 하고나서 다시 사용하려고 하면 “이미 사용한 코드”라면서 처리르 안해준다. 아 ㅋㅋ ㅜㅜ

그래서, 사용자가 처음에 로그인을 할 때 발급해주는 authorizationCode를 DB에 저장해뒀다가 회원탈퇴 시점에 요 데이터를 불러와서 쿼리를 날리는건 불가능하다.

다른 사람들은 어떻게 구현했는지 쭉 살펴봤는데, 그냥 회원탈퇴 직전에 애플로그인으로 AppleCredential 을 생성해준 다음에, 여기에서 authorizationCode를 가져와 회원탈퇴를 진행하고 있었다. 공식문서를 살펴봤을 때에도 의외로 “회원탈퇴 (애플로그인 토큰 취소) 와 같은 민감한 작업을 수행하기 전에는 애플 로그인으로 사용자를 확인”하는 것을 권장하고 있었다. 그래서 나도 Access Token을 만들어주기 위해서 애플로그인을 하는 로직을 코드에 넣어뒀다.

final appleCredential = await SignInWithApple.getAppleIDCredential(
  scopes: [
    AppleIDAuthorizationScopes.email,
    AppleIDAuthorizationScopes.fullName,
  ],
);
final String authCode = appleCredential.authorizationCode;
...

그리고 여기에서 발급받은 appleCredential 에서 authCode 를 가져와 사용자 정보로 사용해주면 된다.

 

위 authCode를 이용해 Access Token을 받기 위한 방법도 공식문서에 작성되어있다. 

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

 

위 URL로 POST 쿼리를 날려서 사용자 Access Token을 만들어내는 로직은 requestAppleTokens 함수에 작성해뒀다. authCode와 clientSecret, clientId를 인자로 넣어 요 함수를 호출하면 accessToken을 발급받을 수 있다 :)

 

함수를 호출하면 사용자에 대한 데이터가 Map 형태로 반환되는데, 이 중에서 ‘access_token’ 항목을 accessToken 변수에 저장하고 revoke 함수에서 사용해주면 된다!

final accessToken = (await requestAppleTokens(
  authCode,
  clientSecret,
  clientId,
))['access_token'] as String;

4) 작동 확인

위 코드를 모두 잘 작성해줬다면 코드를 실행했을 때 아래와 같은 ‘로그인 취소’ 가 등록된 메일로 날아온다.

진짜 이거 날아왔을 때 감동 그 자체였다. 눈물 줄줄 흘렀음 ㅜㅜ

 

FirebaseAuth 에서 Sign in with Apple 로 들어온 유저인지 구분하기

아무 유저에게나 회원탈퇴 버튼을 눌렀을 때 애플 회원탈퇴 로직을 들이밀면 안되기 때문에, 이 유저의 로그인 방식이 이메일인지, 구글인지, 카카오인지, 애플인지를 구분할 필요가 있었다.

나는 Firebase Auth 로 앱 내 계정 관리를 하고 있기 때문에, 요걸 파악하는 방법은 아주 간단했다.

FirebaseAuth.instance.currenUser.providerData 의 정보 중에서 providerId 의 데이터를 확인하면 된다.

providerData의 타입이 List<UserInfo> 라서 어떻게 까봐야 할지 막막했는데, 검색해보니 간단한 코드를 나에게 던져줬다. 그냥 이 중에서 providerId가 ‘apple.com’ 인 정보가 있으면 true를 반환하고, 없으면 false를 반환하도록 로직을 작성하면 되더라.

final isAppleLogin = FirebaseAuth
	                      .instance.currentUser?.providerData
	                      .any(
	                    (info) => info.providerId == 'apple.com',
	                  );

나는 서둘러서 apple 로그인에 대해서만 로직을 작성하려고 이렇게 적어뒀는데, 만약에 구현하고 있는 서비스에서는 google, apple, kakao, email 등 다양한 소셜 로그인을 사용하고 있고, 각 유형별로 로그아웃이나 회원탈퇴 절차가 달라져야 한다면 enum 타입으로 관리해주면 될 것 같다.

나는 나중에 구글 회원 탈퇴 구현하면서 해당 부분을 구현해줘야겠다 :)


진짜 이 소중한 정보를 왜 아무도 바닐라 플러터로 코드를 작성해두지 않았는지 이해가 안된다…

왜 다들 자바, JS 같은걸 섞어서 쓰거나 백엔드로 로직을 넘겨버리냐구… 그래도 덕분에 양질의 글감이 하나 나온 것 같아서 기분이 좋다. ㅎㅎ 🤣

샤라웃

https://velog.io/@tmdckd232/iOS-애플-Revoke-Token

https://medium.com/@tellingme/ios-애플로그인-회원탈퇴-4b598af1677a

https://velog.io/@givepro91/jjo2cyus

https://medium.com/@tellingme/ios-애플로그인-회원탈퇴-4b598af1677a

https://puleugo.tistory.com/138

https://velog.io/@givepro91/jjo2cyus

https://islet4you.tistory.com/entry/Flutter-iOS-Apple-Login-정책-가이드라인-준수

https://weekoding.tistory.com/29

320x100
Share Link
reply
반응형
«   2024/06   »
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