From bc197971587f345ecc0d1ba7e2bbf075fe602cc2 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sat, 10 Oct 2026 21:23:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EC=9B=B9=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=A4=91=EB=8B=A8=C2=B7=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=AC=B8=EA=B5=AC=EB=A1=9C=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=C2=B7=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=95=88=EB=82=B4=20=C2=B7=20Android=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=EB=90=9C=20=EC=9B=B9=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=ED=9B=84=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/android/app/build.gradle.kts | 6 ++ .../kotlin/kr/co/fanuzu/app/MainActivity.kt | 26 +++++- .../auth/firebase_account_linker.dart | 8 +- app/lib/features/auth/login_screen.dart | 4 +- app/lib/features/auth/social_providers.dart | 90 +++++++++++++++---- app/test/auth_error_message_test.dart | 50 ++++++++++- app/test/auth_oidc_test.dart | 60 +++++++++++++ 7 files changed, 219 insertions(+), 25 deletions(-) diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index 639f9e6..db53af9 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -104,3 +104,9 @@ android { flutter { source = "../.." } + +dependencies { + // MainActivity 의 「버려진 Firebase 웹 로그인 정리」(kr.co.fanuzu.app/auth) — firebase-auth 가 이미 쓰는 + // 라이브러리라 새로 들어오는 것은 없다. 앱 모듈에서 직접 부르려고 컴파일 경로에 올린다. + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") +} diff --git a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt index 84b6255..ce1bf8b 100644 --- a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt +++ b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt @@ -1,5 +1,29 @@ package kr.co.fanuzu.app +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + // 버려진 Firebase 웹 로그인(LINE · Apple OIDC) 정리 — Dart `clearStaleWebSignIn`. + // 인가 중 다른 앱(LINE 본인 확인 등)으로 갔다 돌아오면 Firebase 의 대기 작업이 결과 없이 남아, + // 다음 시도가 `web-context-already-presented` 로 막힌다. Firebase 의 인가 화면이 「닫힘」을 알릴 때 + // 보내는 것과 같은 취소 신호를 보내 대기 작업을 취소로 끝내고 다음 시도를 연다. 대기 작업이 없으면 아무 일도 없다. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/auth") + .setMethodCallHandler { call, result -> + if (call.method != "cancelPendingWebSignIn") { + result.notImplemented() + return@setMethodCallHandler + } + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast( + Intent("com.google.firebase.auth.ACTION_RECEIVE_FIREBASE_AUTH_INTENT") + .putExtra("com.google.firebase.auth.internal.EXTRA_CANCELED", true) + ) + result.success(null) + } + } +} diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index c1f177c..01f3f1b 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -78,7 +78,7 @@ final class FirebaseAccountLinker implements AccountLinker { } on fb.FirebaseAuthException catch (error) { if (isWebFlowCancelled(error)) return const AuthCancelled(); return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.link), authMessage(error, flow: AuthFlow.link), ); } on Object catch (error) { @@ -108,7 +108,7 @@ final class FirebaseAccountLinker implements AccountLinker { return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider)); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.link), authMessage(error, flow: AuthFlow.link), ); } @@ -116,8 +116,8 @@ final class FirebaseAccountLinker implements AccountLinker { /// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { - final result = await untilAbandoned( - user.linkWithProvider(impl.authProvider), + final result = await retryStaleWebContext( + () => untilAbandoned(user.linkWithProvider(impl.authProvider)), ); return AuthSucceeded(_accountOf(result.user, impl.provider)); } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index f714f75..18b530f 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -369,9 +369,11 @@ const _names = { AuthProviderKind.email: '이메일로', }; +/// 로그인 결과 안내. 취소 · 중단도 안내한다 — 로그인 성공으로 처리하지 않고 다시 시도하게 한다. +/// 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다. String? authNotice(AuthOutcome outcome) => switch (outcome) { AuthSucceeded() => null, - AuthCancelled() => null, + AuthCancelled() => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.', AuthFailed(:final code, :final message) => '[$code] $message', AuthNotConfigured(:final provider, :final missing) => '${provider.shortName} 로그인은 아직 준비 중입니다.\n' diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index a2671e7..42db9e5 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -90,7 +90,7 @@ final class GoogleSignInProvider return AuthFailed('E-AUTH-03', error.description ?? error.code.name); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } @@ -188,7 +188,7 @@ final class AppleSignInProvider return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } @@ -271,16 +271,18 @@ final class OidcSignInProvider implements SocialAuthProvider { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { - final result = await untilAbandoned( - (firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider( - authProvider, + final result = await retryStaleWebContext( + () => 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), + authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } @@ -417,7 +419,10 @@ final class KakaoSignInProvider implements SocialAuthProvider { _ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', }); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow)); + return AuthFailed( + authCatalogCode(error, flow: flow), + authMessage(error, flow: flow), + ); } } @@ -435,9 +440,39 @@ final class KakaoSignInProvider implements SocialAuthProvider { } /// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. +/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름). bool isWebFlowCancelled(fb.FirebaseAuthException error) => error.code == 'web-context-canceled' || - error.code == 'web-context-cancelled'; + error.code == 'web-context-cancelled' || + error.code == 'user-cancelled'; + +const _authChannel = MethodChannel('kr.co.fanuzu.app/auth'); + +/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`). +/// +/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을 +/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(다시 눌러도 로그인이 안 됨). +/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다. +Future clearStaleWebSignIn() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + try { + await _authChannel.invokeMethod('cancelPendingWebSignIn'); + } on Object catch (error) { + debugPrint('[Auth] clear stale web sign-in skipped: $error'); + } +} + +/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다. +/// 취소 · 실패 뒤에도 정상적으로 다시 시도할 수 있어야 한다. +Future retryStaleWebContext(Future Function() attempt) async { + try { + return await attempt(); + } on fb.FirebaseAuthException catch (error) { + if (error.code != 'web-context-already-presented') rethrow; + await clearStaleWebSignIn(); + return attempt(); + } +} /// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다. /// @@ -462,6 +497,8 @@ Future untilAbandoned( if (state != AppLifecycleState.resumed) return; timer = Timer(grace, () { if (done.isCompleted) return; + // Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다. + unawaited(clearStaleWebSignIn()); done.completeError( fb.FirebaseAuthException(code: 'web-context-canceled'), ); @@ -550,8 +587,8 @@ final class EmailPasswordProvider implements EmailAuthProvider { return const AuthCancelled(); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), - authMessage(error, flow: AuthFlow.signIn), + authCatalogCode(error, flow: AuthFlow.email), + authMessage(error, flow: AuthFlow.email), ); } } @@ -564,8 +601,8 @@ final class EmailPasswordProvider implements EmailAuthProvider { return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), - authMessage(error, flow: AuthFlow.signIn), + authCatalogCode(error, flow: AuthFlow.email), + authMessage(error, flow: AuthFlow.email), ); } } @@ -580,7 +617,14 @@ final class EmailPasswordProvider implements EmailAuthProvider { /// /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 /// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다. -String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { +/// +/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은 +/// 소셜 실패 E-AUTH-03 이다. 하나로 E-AUTH-01 이면 LINE 웹 로그인이 끊길 때(`web-*`) +/// 「이메일 또는 비밀번호를 확인해 주세요」가 뜬다. +String authCatalogCode( + fb.FirebaseAuthException error, { + required AuthFlow flow, +}) => switch (error.code) { 'email-already-in-use' => 'E-AUTH-04', 'weak-password' => 'E-AUTH-05', 'invalid-email' => 'E-AUTH-06', @@ -600,7 +644,7 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { 'invalid-user-token' || 'user-mismatch' || 'user-disabled' => 'E-AUTH-12', - _ => 'E-AUTH-01', + _ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03', }; /// 오류 문구가 갈리는 경로. @@ -609,9 +653,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { /// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지 /// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다. enum AuthFlow { - /// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`). + /// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`). signIn, + /// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다. + email, + /// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로 /// (`linkWithCredential` · `unlink`) — 별첨 B v1.4. link, @@ -631,7 +678,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => 'invalid-email' => '이메일 형식이 올바르지 않습니다.', 'too-many-requests' => '잠시 후 다시 시도해 주세요.', 'account-exists-with-different-credential' => switch (flow) { - AuthFlow.signIn => _otherProviderSignInGuide(error.email), + AuthFlow.signIn || AuthFlow.email => + _otherProviderSignInGuide(error.email), AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', }, // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. @@ -644,7 +692,15 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => 'invalid-user-token' || 'user-mismatch' || 'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', - _ => '이메일 또는 비밀번호를 확인해 주세요.', + // 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다. + 'web-context-already-presented' => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.', + 'web-network-request-failed' || + 'network-request-failed' => '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + _ => switch (flow) { + AuthFlow.email => '이메일 또는 비밀번호를 확인해 주세요.', + AuthFlow.signIn => '소셜 로그인에 실패했습니다.', + AuthFlow.link => '계정 연결에 실패했습니다.', + }, }; /// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내. diff --git a/app/test/auth_error_message_test.dart b/app/test/auth_error_message_test.dart index 8fb61bc..89a957b 100644 --- a/app/test/auth_error_message_test.dart +++ b/app/test/auth_error_message_test.dart @@ -1,3 +1,5 @@ +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/login_screen.dart'; import 'package:fancake/features/auth/social_providers.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter_test/flutter_test.dart'; @@ -61,7 +63,10 @@ void main() { test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () { // 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다. expect( - authCatalogCode(_exception('account-exists-with-different-credential')), + authCatalogCode( + _exception('account-exists-with-different-credential'), + flow: AuthFlow.signIn, + ), 'E-AUTH-08', ); }); @@ -85,13 +90,54 @@ void main() { // 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다. for (final code in ['user-not-found', 'wrong-password']) { expect( - authMessage(_exception(code), flow: AuthFlow.signIn), + authMessage(_exception(code), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.', reason: code, ); } }); }); + + group('소셜 웹 로그인 실패 · 중단 — 이메일 문구를 쓰지 않는다', () { + const webCodes = [ + 'web-context-already-presented', + 'web-network-request-failed', + 'web-internal-error', + 'internal-error', + 'invalid-credential', + ]; + + test('소셜 로그인 · 연결의 알 수 없는 실패는 E-AUTH-03 · 소셜 문구', () { + for (final code in webCodes) { + for (final flow in [AuthFlow.signIn, AuthFlow.link]) { + expect(authCatalogCode(_exception(code), flow: flow), 'E-AUTH-03', reason: '$code $flow'); + expect( + authMessage(_exception(code), flow: flow), + isNot('이메일 또는 비밀번호를 확인해 주세요.'), + reason: '$code $flow', + ); + } + } + expect( + authMessage(_exception('web-context-already-presented'), flow: AuthFlow.signIn), + '로그인이 완료되지 않았습니다. 다시 시도해 주세요.', + ); + expect( + authMessage(_exception('web-network-request-failed'), flow: AuthFlow.signIn), + '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + ); + expect(authMessage(_exception('internal-error'), flow: AuthFlow.signIn), '소셜 로그인에 실패했습니다.'); + }); + + test('이메일 폼만 E-AUTH-01 · 「이메일 또는 비밀번호」', () { + expect(authCatalogCode(_exception('internal-error'), flow: AuthFlow.email), 'E-AUTH-01'); + expect(authMessage(_exception('internal-error'), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.'); + }); + + test('취소는 성공이 아니고 다시 시도하라고 안내한다', () { + expect(authNotice(const AuthCancelled()), '로그인이 완료되지 않았습니다. 다시 시도해 주세요.'); + }); + }); } /// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면 diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index 49aa62f..6094fe1 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -8,6 +8,7 @@ import 'package:fancake/features/auth/social_providers.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -112,6 +113,52 @@ void main() { expect(outcome, isA()); }); + group('웹 흐름 중단 뒤 재시도', () { + late List cleared; + setUp(() { + cleared = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), (call) async { + cleared.add(call.method); + return null; + }); + }); + tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), null)); + + test('앞선 흐름이 남아 막히면(web-context-already-presented) 정리하고 한 번 다시 열어 로그인된다', () async { + final auth = _StaleThenOkAuth(); + final outcome = await line(auth).signIn(); + expect(outcome, isA()); + expect(auth.calls, 2); + expect(cleared, ['cancelPendingWebSignIn']); + }); + + test('정리 뒤에도 막히면 소셜 문구로 실패 — 「이메일 또는 비밀번호」가 아니다', () async { + final outcome = await line(_ThrowingAuth('web-context-already-presented')).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(outcome.message, '로그인이 완료되지 않았습니다. 다시 시도해 주세요.'); + }); + + test('망 오류도 소셜 경로 문구', () async { + final outcome = await line(_ThrowingAuth('web-network-request-failed')).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(outcome.message, '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'); + }); + + testWidgets('Android: 결과 없이 돌아와 취소로 풀릴 때 Firebase 대기도 정리한다', (tester) async { + late AuthOutcome outcome; + line(_HangingAuth()).signIn().then((o) => outcome = o); + for (final s in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused, + AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) { + tester.binding.handleAppLifecycleStateChanged(s); + } + await tester.pump(const Duration(seconds: 9)); + expect(outcome, isA()); + expect(cleared, ['cancelPendingWebSignIn']); + }); + }); + group('계정 연결', () { test('같은 UID 에 oidc.line 을 붙인다', () async { final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); @@ -219,6 +266,19 @@ class _ThrowingAuth extends MockFirebaseAuth { throw fb.FirebaseAuthException(code: code); } +/// 앞선 웹 흐름이 남아 첫 시도가 막히는 Firebase(Android `web-context-already-presented`). +class _StaleThenOkAuth extends MockFirebaseAuth { + _StaleThenOkAuth() : super(mockUser: MockUser(uid: 'line-1')); + var calls = 0; + + @override + Future signInWithProvider(fb.AuthProvider provider) { + calls += 1; + if (calls == 1) throw fb.FirebaseAuthException(code: 'web-context-already-presented'); + return super.signInWithProvider(provider); + } +} + /// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task. class _HangingAuth extends MockFirebaseAuth { @override