170 lines
7.3 KiB
Dart
170 lines
7.3 KiB
Dart
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'auth.dart';
|
|
import 'social_providers.dart';
|
|
|
|
/// 하나의 FANUZU UID 에 소셜 Provider 를 붙이고 떼는 Firebase 어댑터.
|
|
///
|
|
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
|
///
|
|
/// **새 계정을 만들지 않는다.** `signInWithCredential` 은 자격증명에 해당하는 계정이
|
|
/// 없으면 새로 만들어 버리지만 `linkWithCredential` 은 지금 로그인한 UID 에 붙이기만
|
|
/// 한다. 이 구분이 「UID 는 하나」를 지키는 지점이라 연결 경로는 링크 API 만 쓴다.
|
|
final class FirebaseAccountLinker implements AccountLinker {
|
|
FirebaseAccountLinker({
|
|
required List<SocialAuthProvider> providers,
|
|
fb.FirebaseAuth? firebaseAuth,
|
|
}) : _providers = providers,
|
|
_firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance;
|
|
|
|
final List<SocialAuthProvider> _providers;
|
|
final fb.FirebaseAuth _firebaseAuth;
|
|
|
|
/// Firebase providerId ↔ 앱 제공자. `authStateChanges` 로 오는 값과 같은 표를 쓴다.
|
|
static const Map<AuthProviderKind, String> providerIds = {
|
|
AuthProviderKind.google: 'google.com',
|
|
AuthProviderKind.apple: 'apple.com',
|
|
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
|
|
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
|
|
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다.
|
|
AuthProviderKind.kakao: 'oidc.kakao',
|
|
AuthProviderKind.line: 'oidc.line',
|
|
AuthProviderKind.email: 'password',
|
|
};
|
|
|
|
@override
|
|
Map<AuthProviderKind, String?> get linkedAccounts {
|
|
final data = _firebaseAuth.currentUser?.providerData;
|
|
if (data == null) return const {};
|
|
final byId = {for (final d in data) d.providerId: d.email};
|
|
return {
|
|
for (final entry in providerIds.entries)
|
|
if (byId.containsKey(entry.value)) entry.key: byId[entry.value],
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> link(AuthProviderKind provider) async {
|
|
final user = _firebaseAuth.currentUser;
|
|
if (user == null) {
|
|
return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.');
|
|
}
|
|
final impl = _implOf(provider);
|
|
if (impl == null) {
|
|
return AuthNotConfigured(provider, const ['연결 가능한 제공자 구현']);
|
|
}
|
|
final missing = impl.missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
if (impl is! CredentialProvider &&
|
|
impl is! OidcSignInProvider &&
|
|
impl is! KakaoSignInProvider) {
|
|
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
|
}
|
|
|
|
try {
|
|
if (impl is KakaoSignInProvider) return await impl.link(user);
|
|
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
|
|
final source = impl as CredentialProvider;
|
|
final obtained = await source.obtainCredential();
|
|
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
|
|
if (obtained == null) return const AuthCancelled();
|
|
|
|
// 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다.
|
|
// 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다.
|
|
//
|
|
// ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가
|
|
// 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서
|
|
// 다시 보려면 Functions 층에 같은 판정을 둔다.
|
|
final mine = user.email;
|
|
final theirs = obtained.email;
|
|
if (mine != null &&
|
|
theirs != null &&
|
|
!sameAccountIdentity(mine, theirs)) {
|
|
return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.');
|
|
}
|
|
|
|
final result = await user.linkWithCredential(obtained.credential);
|
|
return AuthSucceeded(_accountOf(result.user, provider));
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.link),
|
|
);
|
|
} on Object catch (error) {
|
|
// SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다.
|
|
debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}');
|
|
return const AuthFailed('E-AUTH-03', '계정 연결에 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> unlink(AuthProviderKind provider) async {
|
|
final user = _firebaseAuth.currentUser;
|
|
if (user == null) {
|
|
return const AuthFailed('E-AUTH-03', '로그인이 필요합니다.');
|
|
}
|
|
// **마지막 하나는 떼지 않는다.** 떼는 순간 로그인 수단이 0개가 되어 계정에
|
|
// 다시 들어올 방법이 사라진다 — Firebase 는 이걸 막아 주지 않는다.
|
|
if (linkedAccounts.length <= 1) {
|
|
return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.');
|
|
}
|
|
final id = providerIds[provider];
|
|
if (id == null) {
|
|
return const AuthFailed('E-AUTH-10', '연결되어 있지 않은 계정입니다.');
|
|
}
|
|
try {
|
|
await user.unlink(id);
|
|
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.link),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 카카오·LINE(OIDC) 연결.
|
|
///
|
|
/// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다.
|
|
/// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다.
|
|
/// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의
|
|
/// blocking 함수(beforeSignIn)로 서버에서 거른다.
|
|
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
|
final mine = user.email;
|
|
final result = await untilAbandoned(
|
|
user.linkWithProvider(impl.authProvider),
|
|
);
|
|
// 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때).
|
|
final theirs = _emailOf(result.user, impl.providerId);
|
|
if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) {
|
|
await user.unlink(impl.providerId);
|
|
return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.');
|
|
}
|
|
return AuthSucceeded(_accountOf(result.user, impl.provider));
|
|
}
|
|
|
|
static String? _emailOf(fb.User? user, String providerId) {
|
|
for (final info in user?.providerData ?? const <fb.UserInfo>[]) {
|
|
if (info.providerId == providerId) return info.email;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
SocialAuthProvider? _implOf(AuthProviderKind provider) {
|
|
for (final candidate in _providers) {
|
|
if (candidate.provider == provider) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static AuthAccount _accountOf(fb.User? user, AuthProviderKind provider) =>
|
|
AuthAccount(
|
|
uid: user?.uid ?? '',
|
|
provider: provider,
|
|
displayName: user?.displayName,
|
|
email: user?.email,
|
|
);
|
|
}
|