142 lines
6.1 KiB
Dart
142 lines
6.1 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 · fnAuthLine)가 같은 ID 로 연결을 남긴다.
|
|
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 &&
|
|
impl is! LineSignInProvider) {
|
|
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
|
}
|
|
|
|
try {
|
|
if (impl is KakaoSignInProvider) return await impl.link(user);
|
|
if (impl is LineSignInProvider) 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();
|
|
|
|
// 이메일을 비교하지 않는다 — 이메일 일치는 소유 확인이 아니다. Firebase 가 지금 로그인 토큰과
|
|
// 새 자격증명을 서버에서 함께 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면
|
|
// credential-already-in-use 로 거부한다.
|
|
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, flow: AuthFlow.link),
|
|
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, flow: AuthFlow.link),
|
|
authMessage(error, flow: AuthFlow.link),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
|
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
|
final result = await retryStaleWebContext(
|
|
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
|
|
);
|
|
return AuthSucceeded(_accountOf(result.user, impl.provider));
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|