669 lines
28 KiB
Dart
669 lines
28 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:cloud_functions/cloud_functions.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
|
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao;
|
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
|
|
|
import '../../core/data/backend.dart';
|
|
import '../../core/firebase/firebase_boot.dart';
|
|
import 'auth.dart';
|
|
|
|
/// Firebase 자격증명을 만들 수 있는 제공자.
|
|
///
|
|
/// 로그인과 **계정 연결**(별첨 B v1.4 「복수 소셜 Provider 연결 허용」)은 자격증명을
|
|
/// 얻는 데까지 완전히 같고 그 뒤 `signInWithCredential` 이냐 `linkWithCredential`
|
|
/// 이냐만 다르다. 그 공통 부분을 여기로 뺀다 — 두 벌로 적으면 nonce 같은 보안
|
|
/// 절차가 한쪽에서만 고쳐진다.
|
|
abstract interface class CredentialProvider {
|
|
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
|
|
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
|
|
///
|
|
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
|
|
/// 쓰지 않는다(이메일 일치는 소유 확인이 아니다).
|
|
Future<SocialCredential?> obtainCredential();
|
|
}
|
|
|
|
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
|
|
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
|
|
|
|
/// Google 로그인 — 실 코드 경로.
|
|
///
|
|
/// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다.
|
|
/// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다.
|
|
final class GoogleSignInProvider
|
|
implements SocialAuthProvider, CredentialProvider {
|
|
const GoogleSignInProvider();
|
|
|
|
static Future<void>? _initialization;
|
|
|
|
@override
|
|
SocialProvider get provider => SocialProvider.google;
|
|
|
|
@override
|
|
bool get isLive => true;
|
|
|
|
// 화면에 그대로 노출되는 목록이라 리포 경로가 아니라 사람이 읽는 이름을 쓴다.
|
|
// 실제 파일 경로는 FirebaseBoot.detail 로 로그에만 남는다.
|
|
@override
|
|
List<String> get missingConfig =>
|
|
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
|
|
|
|
@override
|
|
Future<SocialCredential?> obtainCredential() async {
|
|
final signIn = gsi.GoogleSignIn.instance;
|
|
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
|
|
await (_initialization ??= signIn.initialize());
|
|
final account = await signIn.authenticate();
|
|
final idToken = account.authentication.idToken;
|
|
if (idToken == null) return null;
|
|
return (
|
|
credential: fb.GoogleAuthProvider.credential(idToken: idToken),
|
|
email: account.email,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> signIn() async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
|
|
try {
|
|
final obtained = await obtainCredential();
|
|
if (obtained == null) {
|
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
|
}
|
|
final result = await fb.FirebaseAuth.instance.signInWithCredential(
|
|
obtained.credential,
|
|
);
|
|
return AuthSucceeded(_toAccount(result.user, provider));
|
|
} on gsi.GoogleSignInException catch (error) {
|
|
if (error.code == gsi.GoogleSignInExceptionCode.canceled) {
|
|
return const AuthCancelled();
|
|
}
|
|
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.signIn),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
if (!FirebaseBoot.isLive) return;
|
|
await (_initialization ??= gsi.GoogleSignIn.instance.initialize());
|
|
await gsi.GoogleSignIn.instance.signOut();
|
|
// Firebase 세션 종료는 AuthSession이 한 번만 담당한다.
|
|
}
|
|
}
|
|
|
|
/// Apple 로그인 — 실 코드 경로.
|
|
///
|
|
/// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은
|
|
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
|
|
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
|
|
final class AppleSignInProvider
|
|
implements SocialAuthProvider, CredentialProvider {
|
|
const AppleSignInProvider({this.webAuthenticationOptions});
|
|
|
|
/// Android·Web에서 필요한 Service ID와 검증된 callback URL.
|
|
/// 값이 없으면 SDK를 호출하지 않고 [AuthNotConfigured]로 닫는다.
|
|
final apple.WebAuthenticationOptions? webAuthenticationOptions;
|
|
|
|
@override
|
|
SocialProvider get provider => SocialProvider.apple;
|
|
|
|
@override
|
|
bool get isLive => true;
|
|
|
|
@override
|
|
List<String> get missingConfig => [
|
|
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
|
if (_requiresWebAuthentication && webAuthenticationOptions == null)
|
|
'Apple Android 웹 인증 설정',
|
|
if (!FirebaseBoot.isLive) 'Apple 개발자 설정',
|
|
];
|
|
|
|
/// 시도마다 새 nonce 를 만들고 SHA-256 해시를 Apple 에, 원문을 Firebase 에 준다.
|
|
/// 재사용하면 탈취한 토큰을 되쏘는 replay 가 성립한다.
|
|
@override
|
|
Future<SocialCredential?> obtainCredential() async {
|
|
final rawNonce = apple.generateNonce();
|
|
final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
|
|
final credential = await apple.SignInWithApple.getAppleIDCredential(
|
|
scopes: const [
|
|
apple.AppleIDAuthorizationScopes.email,
|
|
apple.AppleIDAuthorizationScopes.fullName,
|
|
],
|
|
webAuthenticationOptions: _requiresWebAuthentication
|
|
? webAuthenticationOptions
|
|
: null,
|
|
nonce: hashedNonce,
|
|
);
|
|
final idToken = credential.identityToken;
|
|
if (idToken == null) return null;
|
|
return (
|
|
credential: fb.OAuthProvider('apple.com').credential(
|
|
idToken: idToken,
|
|
accessToken: credential.authorizationCode,
|
|
rawNonce: rawNonce,
|
|
),
|
|
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
|
|
email: credential.email,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> signIn() async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
|
|
if (!await apple.SignInWithApple.isAvailable()) {
|
|
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
|
|
}
|
|
try {
|
|
final obtained = await obtainCredential();
|
|
if (obtained == null) {
|
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
|
}
|
|
final result = await fb.FirebaseAuth.instance.signInWithCredential(
|
|
obtained.credential,
|
|
);
|
|
return AuthSucceeded(_toAccount(result.user, provider));
|
|
} on apple.SignInWithAppleAuthorizationException catch (error) {
|
|
if (error.code == apple.AuthorizationErrorCode.canceled) {
|
|
return const AuthCancelled();
|
|
}
|
|
return AuthFailed('E-AUTH-03', error.message);
|
|
} on apple.SignInWithAppleException catch (error) {
|
|
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
|
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.signIn),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
// Apple SDK에는 앱 로컬 로그아웃 API가 없다. Firebase 세션은 AuthSession이 닫는다.
|
|
}
|
|
|
|
bool get _requiresWebAuthentication =>
|
|
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
|
}
|
|
|
|
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
|
|
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
|
///
|
|
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
|
|
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
|
|
/// `signInWithProvider` 하나로 끝난다 — Android 는 Custom Tabs, iOS 는
|
|
/// SFSafariViewController 로 인가 화면을 띄우고 Firebase 핸들러
|
|
/// (`https://<프로젝트>.firebaseapp.com/__/auth/handler`)로 돌아온다.
|
|
/// iOS 콜백 스킴은 Google 로그인용 REVERSED_CLIENT_ID 를 같이 쓴다.
|
|
///
|
|
/// 콘솔에서 제공자를 켜지 않았으면 Firebase 가 `operation-not-allowed` 를 돌려주고
|
|
/// [authCatalogCode] 가 E-AUTH-11 로 옮긴다 — 앱이 따로 플래그를 들 필요가 없다.
|
|
///
|
|
/// [CredentialProvider] 가 아니다. 웹 흐름이 자격증명 발급과 로그인·연결을 한 번에
|
|
/// 끝내서 자격증명만 먼저 받아 볼 수 없다. 계정 연결은 [FirebaseAccountLinker] 가
|
|
/// [authProvider] 로 `linkWithProvider` 를 부른다.
|
|
final class OidcSignInProvider implements SocialAuthProvider {
|
|
const OidcSignInProvider(
|
|
this.provider, {
|
|
required this.providerId,
|
|
this.scopes = const [],
|
|
this.androidParameters = const {},
|
|
this.firebaseAuth,
|
|
});
|
|
|
|
@override
|
|
final SocialProvider provider;
|
|
|
|
/// Firebase 콘솔에 등록한 제공자 ID. `oidc.` 접두는 콘솔이 강제한다.
|
|
final String providerId;
|
|
|
|
/// `openid` 외에 더 받을 항목. Firebase 가 `openid` 는 알아서 붙인다.
|
|
/// **콘솔에 켠 동의 항목과 맞아야 한다** — 카카오는 설정에 없는 scope 를 요청하면
|
|
/// KOE205 로 로그인 자체를 막는다.
|
|
final List<String> scopes;
|
|
|
|
/// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로
|
|
/// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가
|
|
/// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다.
|
|
/// iOS 는 정상이라 건드리지 않는다.
|
|
final Map<String, String> androidParameters;
|
|
|
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
|
final fb.FirebaseAuth? firebaseAuth;
|
|
|
|
@override
|
|
bool get isLive => true;
|
|
|
|
@override
|
|
List<String> get missingConfig =>
|
|
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
|
|
|
|
fb.OAuthProvider get authProvider {
|
|
final p = fb.OAuthProvider(providerId);
|
|
for (final scope in scopes) {
|
|
p.addScope(scope);
|
|
}
|
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
|
p.setCustomParameters(androidParameters);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> signIn() async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
try {
|
|
final result = await untilAbandoned(
|
|
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
|
|
authProvider,
|
|
),
|
|
);
|
|
return AuthSucceeded(_toAccount(result.user, provider));
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.signIn),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
// 카카오·LINE 쪽 세션은 브라우저 쿠키라 앱이 지울 수 없다. Firebase 세션은
|
|
// AuthSession 이 닫는다.
|
|
}
|
|
}
|
|
|
|
/// 카카오 네이티브 앱 키. `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다
|
|
/// (같은 파일을 Android manifestPlaceholders · iOS xcconfig 가 읽는다).
|
|
const kakaoNativeAppKey = String.fromEnvironment('FANUZU_KAKAO_NATIVE_APP_KEY');
|
|
|
|
/// 카카오 — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthKakao`). iOS·Android 같은 경로다.
|
|
///
|
|
/// Firebase OIDC(`oidc.kakao`) 웹 흐름은 Android 에서 「카카오톡으로 로그인」을 고르면
|
|
/// 콜백이 Custom Tab 밖(일반 Chrome)에서 열려 「missing initial state」로 깨진다. 카카오에는
|
|
/// 앱 전환을 막는 파라미터가 없어서, SDK 가 카카오톡(없으면 카카오계정)으로 로그인해 받은
|
|
/// 액세스 토큰을 서버가 카카오 API 로 검증하고 커스텀 토큰을 내준다.
|
|
///
|
|
/// 서버는 사용자 제공자 정보에 `oidc.kakao` 를 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로
|
|
/// 들어오고, [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
|
|
final class KakaoSignInProvider implements SocialAuthProvider {
|
|
KakaoSignInProvider({
|
|
Future<String> Function()? accessToken,
|
|
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
|
|
exchange,
|
|
this.firebaseAuth,
|
|
this.nativeAppKey = kakaoNativeAppKey,
|
|
}) : _accessToken = accessToken ?? _sdkAccessToken,
|
|
_exchange = exchange ?? _callServer;
|
|
|
|
final Future<String> Function() _accessToken;
|
|
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
|
|
|
|
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
|
final fb.FirebaseAuth? firebaseAuth;
|
|
final String nativeAppKey;
|
|
|
|
static Future<void>? _init;
|
|
|
|
@override
|
|
SocialProvider get provider => SocialProvider.kakao;
|
|
|
|
@override
|
|
bool get isLive => true;
|
|
|
|
@override
|
|
List<String> get missingConfig => [
|
|
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
|
if (nativeAppKey.isEmpty) '카카오 네이티브 앱 키',
|
|
];
|
|
|
|
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
|
|
|
|
/// 카카오톡이 있으면 카카오톡, 없거나 실패하면 카카오계정(공식 가이드 순서).
|
|
/// 사용자 취소(`CANCELED`)는 계정 로그인으로 넘기지 않고 그대로 던진다.
|
|
static Future<String> _sdkAccessToken() async {
|
|
await (_init ??= kakao.KakaoSdk.init(nativeAppKey: kakaoNativeAppKey));
|
|
final api = kakao.UserApi.instance;
|
|
if (await kakao.isKakaoTalkInstalled()) {
|
|
try {
|
|
return (await api.loginWithKakaoTalk()).accessToken;
|
|
} on PlatformException catch (error) {
|
|
if (error.code == 'CANCELED') rethrow;
|
|
}
|
|
}
|
|
return (await api.loginWithKakaoAccount()).accessToken;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> _callServer(
|
|
Map<String, dynamic> payload,
|
|
) async {
|
|
final result = await FirebaseFunctions.instanceFor(
|
|
region: Backend.region,
|
|
).httpsCallable('fnAuthKakao').call<Object?>(payload);
|
|
final data = result.data;
|
|
return data is Map ? Map<String, dynamic>.from(data) : const {};
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
|
|
final data = await _exchange({'accessToken': await _accessToken()});
|
|
final result = await _auth.signInWithCustomToken(data['token'] as String);
|
|
return AuthSucceeded(_toAccount(result.user, provider));
|
|
});
|
|
|
|
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
|
|
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다.
|
|
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
|
await _exchange({'accessToken': await _accessToken(), 'link': true});
|
|
await user.reload();
|
|
return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider));
|
|
});
|
|
|
|
Future<AuthOutcome> _run(
|
|
AuthFlow flow,
|
|
Future<AuthOutcome> Function() body,
|
|
) async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
try {
|
|
return await body();
|
|
} on PlatformException catch (error) {
|
|
if (error.code == 'CANCELED') return const AuthCancelled();
|
|
debugPrint('[KakaoSignInProvider] sdk failed: ${error.code}');
|
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
|
} on kakao.KakaoAuthException catch (error) {
|
|
// 동의 화면에서 「취소」.
|
|
if (error.error == kakao.AuthErrorCause.accessDenied) {
|
|
return const AuthCancelled();
|
|
}
|
|
// 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다.
|
|
debugPrint(
|
|
'[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}',
|
|
);
|
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
|
} on kakao.KakaoException catch (error) {
|
|
debugPrint('[KakaoSignInProvider] sdk error: $error');
|
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
|
} on FirebaseFunctionsException catch (error) {
|
|
final details = error.details;
|
|
final code = details is Map && details['code'] is String
|
|
? details['code'] as String
|
|
: 'E-AUTH-03';
|
|
return AuthFailed(code, switch (code) {
|
|
_ when details is Map && details['reason'] == 'session' =>
|
|
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
|
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
|
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
|
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
|
});
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
// SDK 를 한 번도 띄우지 않았으면 지울 카카오 토큰도 없다.
|
|
if (_init == null) return;
|
|
try {
|
|
await kakao.UserApi.instance.logout();
|
|
} on Object catch (error) {
|
|
// 토큰이 이미 만료·폐기됐어도 SDK 는 로컬 토큰을 지운다. 로그아웃을 막을 이유가 없다.
|
|
debugPrint('[KakaoSignInProvider] logout: ${error.runtimeType}');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
|
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
|
error.code == 'web-context-canceled' ||
|
|
error.code == 'web-context-cancelled';
|
|
|
|
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
|
|
///
|
|
/// 콜백이 Custom Tab 밖(일반 Chrome)에서 열리면 Firebase 의 대기 Task 가 영영
|
|
/// 끝나지 않아 로그인 버튼이 잠긴다. 앱이 포그라운드로 돌아온 뒤 [grace] 안에
|
|
/// 결과가 없으면 사용자가 흐름을 버린 것으로 보고 기존 취소 경로([isWebFlowCancelled])로
|
|
/// 넘긴다. 정상 완료는 돌아온 직후 토큰 교환 한 번이라 [grace] 안에 끝난다.
|
|
/// iOS 는 SFSafariViewController 가 취소를 스스로 알려 주므로 그대로 둔다.
|
|
///
|
|
/// ponytail: 고정 대기 시간. 망이 아주 느려 [grace] 를 넘기면 취소로 보이지만
|
|
/// Firebase 세션은 나중에 열리므로 authStateChanges 가 따라잡는다.
|
|
Future<T> untilAbandoned<T>(
|
|
Future<T> flow, {
|
|
Duration grace = const Duration(seconds: 8),
|
|
}) {
|
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return flow;
|
|
final done = Completer<T>();
|
|
Timer? timer;
|
|
final listener = AppLifecycleListener(
|
|
onStateChange: (state) {
|
|
timer?.cancel();
|
|
if (state != AppLifecycleState.resumed) return;
|
|
timer = Timer(grace, () {
|
|
if (done.isCompleted) return;
|
|
done.completeError(
|
|
fb.FirebaseAuthException(code: 'web-context-canceled'),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
flow.then(
|
|
(value) {
|
|
if (!done.isCompleted) done.complete(value);
|
|
},
|
|
onError: (Object error, StackTrace stack) {
|
|
if (!done.isCompleted) done.completeError(error, stack);
|
|
},
|
|
);
|
|
return done.future.whenComplete(() {
|
|
timer?.cancel();
|
|
listener.dispose();
|
|
});
|
|
}
|
|
|
|
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
|
|
uid: user?.uid ?? '',
|
|
provider: provider,
|
|
displayName: user?.displayName,
|
|
email: user?.email,
|
|
);
|
|
|
|
/// 이메일 · 비밀번호 — **QA 계정 전용 내부 경로**.
|
|
///
|
|
/// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고
|
|
/// 이메일/비밀번호 가입을 제외했다. 그래서 이 구현체는
|
|
/// [AuthRepository.providers] 에 들어가지 않는다 — 사용자가 닿을 수 있는 버튼이
|
|
/// 없다. 남겨 둔 이유는 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후
|
|
/// 화면을 확인해야 하기 때문이고, [AuthRepository.qaEmailProvider] 가
|
|
/// **디버그 빌드에서만** 이걸 돌려준다.
|
|
///
|
|
/// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이
|
|
/// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가
|
|
/// 사라졌다」를 겪는다.
|
|
final class EmailPasswordProvider implements EmailAuthProvider {
|
|
const EmailPasswordProvider();
|
|
|
|
@override
|
|
AuthProviderKind get provider => AuthProviderKind.email;
|
|
|
|
@override
|
|
bool get isLive => true;
|
|
|
|
@override
|
|
List<String> get missingConfig =>
|
|
FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels;
|
|
|
|
/// 폼 없이 부르면 아무 일도 하지 않는다. 화면이 입력을 받아
|
|
/// [signInWithEmail] 을 부른다.
|
|
@override
|
|
Future<AuthOutcome> signIn() async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
return const AuthCancelled();
|
|
}
|
|
|
|
@override
|
|
Future<AuthOutcome> signInWithEmail(String email, String password) => _run(
|
|
() => fb.FirebaseAuth.instance.signInWithEmailAndPassword(
|
|
email: email.trim(),
|
|
password: password,
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<AuthOutcome> signUpWithEmail(String email, String password) => _run(
|
|
() => fb.FirebaseAuth.instance.createUserWithEmailAndPassword(
|
|
email: email.trim(),
|
|
password: password,
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<AuthOutcome> sendPasswordReset(String email) async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
try {
|
|
await fb.FirebaseAuth.instance.sendPasswordResetEmail(
|
|
email: email.trim(),
|
|
);
|
|
return const AuthCancelled();
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.signIn),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<AuthOutcome> _run(Future<fb.UserCredential> Function() action) async {
|
|
final missing = missingConfig;
|
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
|
try {
|
|
final result = await action();
|
|
return AuthSucceeded(_toAccount(result.user, provider));
|
|
} on fb.FirebaseAuthException catch (error) {
|
|
return AuthFailed(
|
|
authCatalogCode(error),
|
|
authMessage(error, flow: AuthFlow.signIn),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
// Firebase 세션 종료는 AuthSession 이 한 번만 담당한다.
|
|
}
|
|
}
|
|
|
|
/// SDK 코드를 에러 카탈로그 ID 로 옮긴다.
|
|
///
|
|
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
|
|
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
|
|
String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|
'email-already-in-use' => 'E-AUTH-04',
|
|
'weak-password' => 'E-AUTH-05',
|
|
'invalid-email' => 'E-AUTH-06',
|
|
'too-many-requests' => 'E-AUTH-07',
|
|
// credential-already-in-use 는 계정 연결 경로에서만 나오지만,
|
|
// account-exists-with-different-credential 은 로그인 경로에서도 난다.
|
|
// 카탈로그 ID 는 같고 문구만 경로별로 갈린다(authMessage 참고).
|
|
'credential-already-in-use' ||
|
|
'account-exists-with-different-credential' => 'E-AUTH-08',
|
|
'provider-already-linked' => 'E-AUTH-09',
|
|
'no-such-provider' => 'E-AUTH-10',
|
|
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
|
|
'operation-not-allowed' => 'E-AUTH-11',
|
|
// 현재 계정의 세션이 만료 · 폐기됐다. 다시 로그인해야 한다.
|
|
'requires-recent-login' ||
|
|
'user-token-expired' ||
|
|
'invalid-user-token' ||
|
|
'user-mismatch' ||
|
|
'user-disabled' => 'E-AUTH-12',
|
|
_ => 'E-AUTH-01',
|
|
};
|
|
|
|
/// 오류 문구가 갈리는 경로.
|
|
///
|
|
/// 같은 SDK 코드가 경로에 따라 **다른 사실**을 뜻하므로 호출부가 어느 경로인지
|
|
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
|
|
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
|
|
enum AuthFlow {
|
|
/// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`).
|
|
signIn,
|
|
|
|
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
|
|
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
|
|
link,
|
|
}
|
|
|
|
/// 사용자에게 띄우는 문구.
|
|
///
|
|
/// `account-exists-with-different-credential` 이 경로에 따라 뒤집히는 대표 코드다.
|
|
/// **로그인** 경로에서는 「같은 이메일의 계정이 다른 제공자로 가입돼 있다」이므로
|
|
/// 그 제공자로 들어가 달라고 안내해야 하고, **계정 연결** 경로에서는 「이 소셜
|
|
/// 계정이 다른 FANUZU 계정에 이미 붙어 있다」다. 한 벌로 적으면 로그인에 실패한
|
|
/// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다.
|
|
String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|
switch (error.code) {
|
|
'email-already-in-use' => '이미 가입된 이메일입니다.',
|
|
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.',
|
|
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
|
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
|
'account-exists-with-different-credential' => switch (flow) {
|
|
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
|
|
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
|
},
|
|
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
|
'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
|
'provider-already-linked' => '이미 연결된 계정입니다.',
|
|
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
|
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
|
'requires-recent-login' ||
|
|
'user-token-expired' ||
|
|
'invalid-user-token' ||
|
|
'user-mismatch' ||
|
|
'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
|
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
|
};
|
|
|
|
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
|
///
|
|
/// [email] 은 오류 응답의 힌트다(`FirebaseAuthException.email`). 사용자가 방금
|
|
/// 소셜 인증을 통과해서 받은 **자기 이메일**이라 되돌려 주는 것이 계정 열거가
|
|
/// 아니다. 이걸 빼면 계정이 여러 개인 사용자가 어느 계정으로 들어가야 하는지
|
|
/// 알 수 없다.
|
|
///
|
|
/// ponytail: **기존 제공자 이름은 오류에 없다.** `error.credential` 은 이번에
|
|
/// 시도한 자격증명이고, 기존 제공자를 알려면 `fetchSignInMethodsForEmail` 이
|
|
/// 필요한데 이메일 열거 방지가 켜지면 빈 목록을 돌려준다. 이름까지 띄워야 하면
|
|
/// 서버에서 Admin SDK 로 조회해 내려주는 쪽으로 올린다.
|
|
///
|
|
/// 이메일을 조사 앞에 바로 붙이지 않는다. 도메인 끝 글자가 모음인지 자음인지에
|
|
/// 따라 「은/는」이 갈려 `example.co은` 같은 문구가 나간다. 「계정」을 사이에 두면
|
|
/// 조사가 항상 맞는다.
|
|
String _otherProviderSignInGuide(String? email) =>
|
|
'${email ?? '이'} 계정은 다른 로그인 수단으로 가입돼 있습니다. '
|
|
'처음 가입에 쓰신 수단으로 로그인한 뒤 MY > 설정 > SNS 연동에서 '
|
|
'이 수단을 연결해 주세요.';
|