View
[Flutter] 플러터 코드로만 Sign in with Apple 유저의 회원탈퇴 (Revoke) 기능 구현하기
sm_amoled 2024. 6. 26. 16:50플러터에서 애플 로그인 회원탈퇴를 할 수 있는 로직을 아래에 작성해놨다!!
필요한 사람은 참고하자!!
ㅤ
갸아아아악 진짜 이걸로 고생했다. 🥺🥺💦
ㅤ
이번에 앱 출시를 위해서 앱스토어에 심사를 올렸다가, 회원가입이 있으면 회원탈퇴도 있어야 한다는 심사 기준으로 인해 리젝을 받았다.
당연히 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 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분 동안 디버깅을 했는데, 여러가지 문제가 있었다.
- Private Key의 머리와 꼬리 (——KEY——) 도 key에 포함되며, 개행 (\n) 도 key에 포함된다. 이걸 깨트리니깐 parsing error가 발생한다. 이 형식을 그대로 지켜줘야한다.
- 그런데 .env 에서는 여러 줄에 걸쳐 문자열을 작성할 수 없었다 (문자열 여러줄 ← 이게 안됐음)
- 따라서, 알아서 분리된걸 사용하는 시점에 잘 합쳐줘야한다.
- 물론 더 섹시한 방법도 있겠지만, 나는 재심사를 하루라도 빨리 신청하고 앱스토어에 앱을 등록하고싶다.
ㅤ
이렇게 .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-정책-가이드라인-준수
'Develop > Flutter 개발' 카테고리의 다른 글
[Flutter] 플러터의 화면 렌더링 과정 (2) | 2024.07.24 |
---|---|
[Flutter] Dart는 Native Machine Code로 컴파일 되지만, Dart VM은 여전히 사용된다? (0) | 2024.07.12 |
[Flutter] Image.file 은 File 데이터의 변경사항을 반영해주지 않는다. 대신 캐싱 관리가 쉬운 FileImage를 사용하자! (0) | 2024.06.19 |
[Flutter] Apple 로그인 창 Modal 안뜨는 경우 / AuthorizationErrorCode.unknown error 1000. (0) | 2024.06.14 |
[Flutter] Visibility 위젯의 maintainState 프로퍼티, Offstage 위젯 (0) | 2024.06.05 |