From 304ad105d603c2679bb8ec4b3a04a201c2d63908 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 1 Sep 2026 01:03:56 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=88=98=EB=8B=A8=205?= =?UTF-8?q?=EC=A2=85(=EC=9D=B4=EB=A9=94=EC=9D=BC=C2=B7Google=C2=B7Apple?= =?UTF-8?q?=C2=B7Kakao=C2=B7LINE)=20=C2=B7=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EC=B5=9C=EC=86=8C=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?Android=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/BUILD.md | 36 +++ app/android/app/build.gradle.kts | 10 +- app/android/app/src/debug/AndroidManifest.xml | 8 +- .../debug/res/xml/network_security_config.xml | 21 ++ .../app/src/profile/AndroidManifest.xml | 8 +- .../res/xml/network_security_config.xml | 21 ++ app/lib/core/firebase/firebase_boot.dart | 41 +++ app/lib/core/theme/tokens.dart | 7 + app/lib/features/auth/auth.dart | 58 +++-- app/lib/features/auth/auth_repository.dart | 53 +++- .../features/auth/firebase_auth_session.dart | 5 +- app/lib/features/auth/login_screen.dart | 211 ++++++++++++++- app/lib/features/auth/social_providers.dart | 103 +++++++- app/test/auth_email_test.dart | 242 ++++++++++++++++++ 14 files changed, 770 insertions(+), 54 deletions(-) create mode 100644 app/android/app/src/debug/res/xml/network_security_config.xml create mode 100644 app/android/app/src/profile/res/xml/network_security_config.xml create mode 100644 app/test/auth_email_test.dart diff --git a/app/BUILD.md b/app/BUILD.md index 6c000bc..99f79ff 100644 --- a/app/BUILD.md +++ b/app/BUILD.md @@ -96,3 +96,39 @@ iOS 플러그인이 전부 Swift Package 로 옮겨졌는데 프로젝트에 Coc 동작에는 지장이 없으나 빌드 시간이 늘고 구성이 이중이다. `pod deintegrate` 와 `Debug.xcconfig` · `Release.xcconfig` 의 Pods include 제거가 필요하다. **11주차 최적화 구간에서 정리한다.** + +## 서버 없이 로그인을 돌려 보기 + +실 Firebase 프로젝트는 계정 명의가 확정되어야 열 수 있다. +그 전까지는 **에뮬레이터를 물려 실제 왕복을 돈다.** 설정 파일 대신 에뮬레이터를 붙이는 +것이라, 이 절차가 통과하면 설정 파일이 들어오는 날 코드 수정 없이 동작한다. + +### 1. 에뮬레이터 기동 + +``` +cd app/firebase +firebase emulators:start --only auth --project demo-fanuzu +``` + +**JDK 21 이상이 필요하다.** firebase-tools 15 가 그 아래를 거부한다. Gradle 은 Temurin 17 을 +쓰므로 둘이 다르며, 에뮬레이터를 띄울 때만 `JAVA_HOME` 을 21+ 로 바꾼다. + +### 2. 앱을 에뮬레이터에 붙이기 + +``` +flutter run --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 # Android 에뮬레이터 +flutter run --dart-define=FANUZU_EMULATOR_HOST=127.0.0.1 # iOS 시뮬레이터 +``` + +`10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다. + +### 평문 HTTP 예외에 대하여 + +에뮬레이터는 평문 HTTP 로 뜨는데 Android 9 부터 평문이 기본 차단이다. 예외를 +`android/app/src/{debug,profile}/` 에만 두었으므로 **릴리스 빌드에는 들어가지 않는다.** +확인: + +``` +unzip -p build/app/outputs/flutter-apk/app-release.apk AndroidManifest.xml \ + | strings | grep -c networkSecurityConfig # 0 이어야 한다 +``` diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index 1bd09df..7bba993 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -43,9 +43,13 @@ android { // 잠정값. 스토어 등록 명의(2주차_확인요청항목 Q5)가 발주사 명의로 확정되면 교체한다(Q14). // 스토어에 한 번 올리면 변경 불가 — 첫 제출 전에 확정해야 한다. applicationId = fancakeApplicationId - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + // **계약값이다.** 별첨 1 v1.0 이 최소 지원 OS 를 Android 10(API 29)으로 적었고, + // 근거는 2주차 플러그인 선검증 리포트의 바이너리 실측이다. + // + // Flutter 기본값(API 24)을 그대로 두면 우리가 검증하지 않은 기기까지 설치가 + // 열린다 — 스토어에 올라간 뒤에는 그 기기의 크래시가 우리 책임이 된다. + // 플러그인을 늘릴 때 이 값을 재계산하는 절차는 선검증 리포트 §3.5 에 있다. + minSdk = 29 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml index 399f698..8b30855 100644 --- a/app/android/app/src/debug/AndroidManifest.xml +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,13 @@ - + + + diff --git a/app/android/app/src/debug/res/xml/network_security_config.xml b/app/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..713071b --- /dev/null +++ b/app/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,21 @@ + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml index 399f698..8b30855 100644 --- a/app/android/app/src/profile/AndroidManifest.xml +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -1,7 +1,13 @@ - + + + diff --git a/app/android/app/src/profile/res/xml/network_security_config.xml b/app/android/app/src/profile/res/xml/network_security_config.xml new file mode 100644 index 0000000..713071b --- /dev/null +++ b/app/android/app/src/profile/res/xml/network_security_config.xml @@ -0,0 +1,21 @@ + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/app/lib/core/firebase/firebase_boot.dart b/app/lib/core/firebase/firebase_boot.dart index 7c0035d..47ed4e5 100644 --- a/app/lib/core/firebase/firebase_boot.dart +++ b/app/lib/core/firebase/firebase_boot.dart @@ -1,3 +1,4 @@ +import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; @@ -43,7 +44,32 @@ abstract final class FirebaseBoot { /// 화면에 보여 줄 미투입 항목 이름. 내부 경로를 담지 않는다. static const List missingLabels = ['Firebase 설정']; + /// 에뮬레이터 호스트. `--dart-define=FANUZU_EMULATOR_HOST=127.0.0.1` 로 켠다. + /// + /// **왜 두는가** — 실 Firebase 프로젝트가 열리기 전에도 로그인이 + /// 실제로 도는지 확인할 수 있어야 한다. + /// + /// 릴리스 빌드에서는 열리지 않는다. 배포된 앱이 로컬 호스트를 바라보면 + /// 아무것도 동작하지 않고 원인도 보이지 않는다. + static const String emulatorHost = + String.fromEnvironment('FANUZU_EMULATOR_HOST'); + + static bool get useEmulator => emulatorHost.isNotEmpty && !kReleaseMode; + + /// 에뮬레이터 전용 설정. 실 프로젝트 값이 아니며 비밀값도 아니다. + /// `demo-` 로 시작하는 프로젝트 ID 는 에뮬레이터가 외부 접속을 막는다. + static const FirebaseOptions _emulatorOptions = FirebaseOptions( + apiKey: 'demo-key', + appId: '1:0:android:0', + messagingSenderId: '0', + projectId: 'demo-fanuzu', + ); + static Future init() async { + if (useEmulator) { + await _initEmulator(); + return; + } try { await Firebase.initializeApp(); _mode = FirebaseMode.live; @@ -59,6 +85,21 @@ abstract final class FirebaseBoot { if (_detail != null) debugPrint('[FirebaseBoot] detail :: $_detail'); } + static Future _initEmulator() async { + try { + await Firebase.initializeApp(options: _emulatorOptions); + FirebaseAuth.instance.useAuthEmulator(emulatorHost, 9099); + _mode = FirebaseMode.live; + _reason = '로컬 에뮬레이터에 연결했습니다. 실 서버가 아닙니다.'; + _detail = 'emulator host: $emulatorHost'; + } on Object catch (error) { + _mode = FirebaseMode.mock; + _reason = '에뮬레이터에 연결하지 못해 임시(목) 모드로 동작 중입니다.'; + _detail = '원인: $error'; + } + debugPrint('[FirebaseBoot] mode=${_mode.name} :: $_reason'); + } + /// 테스트에서 모드를 고정할 때만 쓴다. @visibleForTesting static void debugSetMode(FirebaseMode mode, {String reason = '테스트 지정'}) { diff --git a/app/lib/core/theme/tokens.dart b/app/lib/core/theme/tokens.dart index 2999ff8..2aed64a 100644 --- a/app/lib/core/theme/tokens.dart +++ b/app/lib/core/theme/tokens.dart @@ -49,8 +49,15 @@ abstract final class AppColors { abstract final class BrandColors { static const kakao = Color(0xFFFEE500); // brand-kakao #FEE500 static const kakaoLabel = Color(0xFF000000); // brand-kakao-label #000000 + // 네이버는 확정 가입 수단 5종에서 빠졌다(정책 §P-01 · 답변서 Q7). 토큰은 + // 남겨 둔다 — 발주사께서 다시 넣으시면 그대로 쓴다. static const naver = Color(0xFF03C75A); // brand-naver #03C75A static const naverLabel = Color(0xFFFFFFFF); // brand-naver-label #FFFFFF + static const line = Color(0xFF06C755); // brand-line #06C755 + static const lineLabel = Color(0xFFFFFFFF); // brand-line-label #FFFFFF + // 이메일은 브랜드가 없다. 앱 강조색을 그대로 쓴다. + static const email = AppColors.cosmicAccent; + static const emailLabel = AppColors.onCosmicAccent; static const apple = Color(0xFF000000); // brand-apple #000000 static const appleLabel = Color(0xFFFFFFFF); // brand-apple-label #FFFFFF static const appleOnlight = Color(0xFFFFFFFF); // brand-apple-onlight #FFFFFF diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index f3f7fde..d5b23a9 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -6,30 +6,32 @@ /// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다. library; -/// 소셜 제공자. **순서와 라벨은 시안 `시안/screens/S-A-LOGIN.html` 의 버튼 그대로다.** -/// 화면명세 `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md` 도 4종으로 같다. -enum SocialProvider { +/// 가입 수단 — **정책 설계서 §P-01 확정값 5종**(답변서 v1.1 Q7). +/// +/// 초안 당시 CD-18 이 미확정이라 소셜 4종(카카오·네이버·Apple·Google)만 두었는데, +/// 답변서가 **이메일 · Google · Apple · Kakao · LINE** 으로 확정했다. +/// **네이버는 확정 목록에 없고, 이메일과 LINE 이 새로 들어왔다.** +/// +/// 앱 Figma 에는 WeChat · X 버튼도 그려져 있으나 답변서가 5종으로 정했으므로 +/// 1차는 5종이다. 추가 시 재작업이 없도록 제공자를 인터페이스 뒤에 둔다. +enum AuthProviderKind { + email('이메일로 시작하기'), kakao('카카오로 시작하기'), - naver('네이버로 시작하기'), + line('LINE으로 시작하기'), apple('Apple로 시작하기'), google('Google로 시작하기'); - const SocialProvider(this.label); + const AuthProviderKind(this.label); - /// 버튼 라벨. 임의로 바꾸지 마라 — 시안 대조 대상이다. + /// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다. final String label; + + /// 이메일은 소셜이 아니라 입력 폼이 붙는다. 화면이 이걸로 갈린다. + bool get needsForm => this == AuthProviderKind.email; } -// 이메일 가입에 대하여 — 기능 플래그를 두지 않았다. -// -// CD-18(가입 수단) 미확정(2주차_확인요청항목 Q2)이라 이메일 경로는 구현하지 않았다. -// 플래그 상수를 하나 선언해 두는 방법도 있었지만, 그 값을 읽는 분기가 없으면 -// 「false 라서 닫혀 있다」는 착각만 만든다 — 누군가 true 로 바꿔도 아무 일이 일어나지 않는다. -// 지금 이메일을 닫고 있는 것은 플래그가 아니라 AuthRepository 의 제공자 목록에 -// 이메일 구현체가 없다는 사실 하나다. -// -// Q2 가 「이메일 병행」으로 오면 할 일: [SocialAuthProvider] 를 구현한 이메일 제공자를 만들고 -// AuthRepository._defaultProviders() 에 추가한다. 화면 수·약관 동선도 함께 바뀐다. +/// 이전 이름. 한 번에 다 바꾸지 않아도 되게 남겨 둔다. +typedef SocialProvider = AuthProviderKind; /// 로그인된 계정. class AuthAccount { @@ -42,7 +44,7 @@ class AuthAccount { }); final String uid; - final SocialProvider? provider; + final AuthProviderKind? provider; final String? displayName; final String? email; @@ -78,17 +80,15 @@ final class AuthFailed extends AuthOutcome { final class AuthNotConfigured extends AuthOutcome { const AuthNotConfigured(this.provider, this.missing); - final SocialProvider provider; + final AuthProviderKind provider; /// 투입해야 하는 것들(파일명·키명). 화면과 README 에 그대로 노출한다. final List missing; } -/// 소셜 로그인 제공자 인터페이스. -/// -/// 이메일 가입이 확정되면(Q2) 같은 인터페이스로 구현체 하나가 더 붙는 자리다. +/// 로그인 제공자 인터페이스. 이메일도 같은 인터페이스를 쓴다. abstract interface class SocialAuthProvider { - SocialProvider get provider; + AuthProviderKind get provider; /// 실 연동 코드 경로를 가지고 있는지. 스텁이면 false. bool get isLive; @@ -101,6 +101,20 @@ abstract interface class SocialAuthProvider { Future signOut(); } +/// 이메일 제공자만 쓰는 확장 계약. +/// +/// 소셜은 외부 창이 열리고 끝이지만 이메일은 **앱 안에서 값을 받아야 한다.** +/// [SocialAuthProvider.signIn] 하나로 묶으면 화면이 제공자 종류를 보고 분기하게 +/// 되므로, 입력이 필요한 쪽만 별도 계약으로 뺀다. +abstract interface class EmailAuthProvider implements SocialAuthProvider { + Future signInWithEmail(String email, String password); + + Future signUpWithEmail(String email, String password); + + /// 비밀번호 재설정 메일 발송. Figma 「비밀번호 재설정」 화면이 이걸 부른다. + Future sendPasswordReset(String email); +} + /// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계. /// /// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에 diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 06a221e..1e4b2e0 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -32,23 +32,54 @@ class AuthRepository extends ChangeNotifier { AuthAccount? get account => _account; bool get isSignedIn => _account != null; + /// 정책 설계서 §P-01 확정 5종. **순서가 화면 버튼 순서다.** + /// + /// 네이버가 빠지고 이메일·LINE 이 들어왔다(답변서 Q7). 카카오·LINE 은 개발자 + /// 콘솔 앱 등록에 발주사 사업자 정보가 필요해 아직 키가 없다. static List _defaultProviders() => [ - const KeylessSocialProvider(SocialProvider.kakao, [ - '카카오 네이티브 앱 키', - '카카오 개발자 콘솔 앱 등록(사업자 정보 필요)', - ]), - const KeylessSocialProvider(SocialProvider.naver, [ - '네이버 client ID / client secret', - '네이버 개발자센터 앱 등록(사업자 정보 필요)', - ]), + const EmailPasswordProvider(), + const GoogleSignInProvider(), AppleSignInProvider( webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), ), - const GoogleSignInProvider(), - // 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다. - // 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고. + const KeylessSocialProvider(AuthProviderKind.kakao, [ + '카카오 네이티브 앱 키', + '카카오 개발자 콘솔 앱 등록(사업자 정보 필요)', + ]), + lineProvider, ]; + /// 이메일 제공자. 화면이 입력 폼을 띄우기 전에 있는지 확인한다. + EmailAuthProvider? get emailProvider { + for (final p in providers) { + if (p is EmailAuthProvider) return p; + } + return null; + } + + /// 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다. + Future signInWithEmail({ + required String email, + required String password, + bool signUp = false, + }) async { + final impl = emailProvider; + if (impl == null) { + return const AuthFailed('E-AUTH-03', '이메일 로그인을 쓸 수 없습니다.'); + } + late final AuthOutcome outcome; + try { + outcome = signUp + ? await impl.signUpWithEmail(email, password) + : await impl.signInWithEmail(email, password); + } on Object catch (error) { + debugPrint('[AuthRepository] email sign-in failed: ${error.runtimeType}'); + return const AuthFailed('E-AUTH-01', '이메일 또는 비밀번호를 확인해 주세요.'); + } + if (outcome is AuthSucceeded) _replaceAccount(outcome.account); + return outcome; + } + Future signIn(SocialProvider provider) async { final impl = providers.firstWhere((p) => p.provider == provider); late final AuthOutcome outcome; diff --git a/app/lib/features/auth/firebase_auth_session.dart b/app/lib/features/auth/firebase_auth_session.dart index 2b08fba..f0d6b12 100644 --- a/app/lib/features/auth/firebase_auth_session.dart +++ b/app/lib/features/auth/firebase_auth_session.dart @@ -40,8 +40,9 @@ SocialProvider? _providerOf(fb.User user) { if (ids.any((id) => id.toLowerCase().contains('kakao'))) { return SocialProvider.kakao; } - if (ids.any((id) => id.toLowerCase().contains('naver'))) { - return SocialProvider.naver; + if (ids.any((id) => id.toLowerCase().contains('line'))) { + return AuthProviderKind.line; } + if (ids.contains('password')) return AuthProviderKind.email; return null; } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index c14472a..d6349c5 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -5,10 +5,13 @@ import '../../core/theme/tokens.dart'; import 'auth.dart'; import 'auth_repository.dart'; -/// S-A-LOGIN 로그인. +/// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`). /// -/// 시안: `시안/screens/S-A-LOGIN.html` · 명세: `wireframe/planning/screen-spec/onboard/S-A-LOGIN.md` -/// 골격 범위라 4개 버튼과 결과 안내까지만 구현했다. 약관 동의(S-A-TERMS) 이후 동선은 3주차다. +/// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google · +/// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는 +/// 버튼 하나로 외부 창을 연다. +/// +/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다. class LoginScreen extends StatefulWidget { const LoginScreen({super.key, required this.auth}); @@ -19,23 +22,77 @@ class LoginScreen extends StatefulWidget { } class _LoginScreenState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _form = GlobalKey(); + String? _notice; bool _busy = false; + /// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다. + bool _emailOpen = false; + + /// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다. + bool _signUp = false; + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _submitEmail() async { + if (!(_form.currentState?.validate() ?? false)) return; + setState(() => _busy = true); + final outcome = await widget.auth.signInWithEmail( + email: _email.text, + password: _password.text, + signUp: _signUp, + ); + if (!mounted) return; + setState(() { + _busy = false; + _notice = _noticeOf(outcome); + }); + } + + Future _resetPassword() async { + final email = _email.text.trim(); + if (email.isEmpty) { + setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.'); + return; + } + final impl = widget.auth.emailProvider; + if (impl == null) return; + setState(() => _busy = true); + final outcome = await impl.sendPasswordReset(email); + if (!mounted) return; + setState(() { + _busy = false; + // 성공해도 계정 존재 여부를 알려 주지 않는다. 가입된 이메일을 훑을 수 있다. + _notice = outcome is AuthFailed + ? _noticeOf(outcome) + : '입력하신 주소로 재설정 안내를 보냈습니다.'; + }); + } + + String? _noticeOf(AuthOutcome outcome) => switch (outcome) { + AuthSucceeded() => null, + AuthCancelled() => null, + AuthFailed(:final code, :final message) => '[$code] $message', + AuthNotConfigured(:final provider, :final missing) => + '${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n' + '필요한 항목: ${missing.join(' · ')}', + }; + Future _signIn(SocialProvider provider) async { setState(() => _busy = true); final outcome = await widget.auth.signIn(provider); if (!mounted) return; setState(() { _busy = false; - _notice = switch (outcome) { - AuthSucceeded() => null, - AuthCancelled() => null, - AuthFailed(:final code, :final message) => '[$code] $message', - AuthNotConfigured(:final provider, :final missing) => - '${provider.label.replaceAll('로 시작하기', '')} 로그인은 아직 준비 중입니다.\n' - '필요한 항목: ${missing.join(' · ')}', - }; + _notice = _noticeOf(outcome); }); } @@ -71,8 +128,23 @@ class _LoginScreenState extends State { _ProviderButton( provider: p.provider, enabled: !_busy, - onPressed: () => _signIn(p.provider), + onPressed: p.provider.needsForm + ? () => setState(() => _emailOpen = !_emailOpen) + : () => _signIn(p.provider), ), + if (p.provider.needsForm && _emailOpen) ...[ + const SizedBox(height: AppSpace.s3), + _EmailForm( + formKey: _form, + email: _email, + password: _password, + signUp: _signUp, + busy: _busy, + onSubmit: _submitEmail, + onToggleMode: () => setState(() => _signUp = !_signUp), + onReset: _resetPassword, + ), + ], const SizedBox(height: AppSpace.s3), ], if (_notice != null) ...[ @@ -110,7 +182,8 @@ class _ProviderButton extends StatelessWidget { Widget build(BuildContext context) { final (fill, label, stroke) = switch (provider) { SocialProvider.kakao => (BrandColors.kakao, BrandColors.kakaoLabel, null), - SocialProvider.naver => (BrandColors.naver, BrandColors.naverLabel, null), + SocialProvider.line => (BrandColors.line, BrandColors.lineLabel, null), + SocialProvider.email => (BrandColors.email, BrandColors.emailLabel, null), SocialProvider.apple => ( BrandColors.apple, BrandColors.appleLabel, @@ -176,3 +249,115 @@ class _MockBanner extends StatelessWidget { ); } } + + +/// 이메일 입력 폼. +/// +/// Figma 「로그인 입력 영역」 명세 — +/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·* +/// *로그인 버튼 - 필수값 입력 시 활성화* +class _EmailForm extends StatelessWidget { + const _EmailForm({ + required this.formKey, + required this.email, + required this.password, + required this.signUp, + required this.busy, + required this.onSubmit, + required this.onToggleMode, + required this.onReset, + }); + + final GlobalKey formKey; + final TextEditingController email; + final TextEditingController password; + final bool signUp; + final bool busy; + final VoidCallback onSubmit; + final VoidCallback onToggleMode; + final VoidCallback onReset; + + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Form( + key: formKey, + child: Container( + padding: const EdgeInsets.all(AppSpace.s4), + decoration: BoxDecoration( + color: AppColors.cosmicSurfaceContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(signUp ? '이메일로 가입' : '이메일로 로그인', style: t.titleMedium), + const SizedBox(height: AppSpace.s3), + TextFormField( + controller: email, + enabled: !busy, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + decoration: const InputDecoration(labelText: '이메일'), + validator: (value) { + final v = (value ?? '').trim(); + if (v.isEmpty) return '이메일을 입력해 주세요.'; + // 형식 검사는 최소한만 한다. 지나치게 엄격한 정규식은 유효한 + // 주소를 막는다 — 실제 검증은 가입 확인 메일이 한다. + if (!v.contains('@') || v.startsWith('@') || v.endsWith('@')) { + return '이메일 형식이 올바르지 않습니다.'; + } + return null; + }, + ), + const SizedBox(height: AppSpace.s3), + TextFormField( + controller: password, + enabled: !busy, + obscureText: true, + autofillHints: const [AutofillHints.password], + decoration: const InputDecoration(labelText: '비밀번호'), + validator: (value) { + final v = value ?? ''; + if (v.isEmpty) return '비밀번호를 입력해 주세요.'; + if (signUp && v.length < EmailFormMetrics.minPasswordLength) { + return '${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.'; + } + return null; + }, + ), + const SizedBox(height: AppSpace.s4), + FilledButton( + onPressed: busy ? null : onSubmit, + child: Text(signUp ? '가입하기' : '로그인'), + ), + const SizedBox(height: AppSpace.s2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: busy ? null : onToggleMode, + child: Text(signUp ? '이미 계정이 있어요' : '계정 만들기'), + ), + if (!signUp) + TextButton( + onPressed: busy ? null : onReset, + child: const Text('비밀번호 재설정'), + ), + ], + ), + ], + ), + ), + ); + } +} + + +/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다. +abstract final class EmailFormMetrics { + /// 비밀번호 최소 길이. Firebase 는 6자를 요구하지만 8자로 올린다 — + /// 6자는 사전 공격에 사실상 무방비다. + static const minPasswordLength = 8; +} diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index ceee328..24cdf37 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -148,7 +148,7 @@ final class AppleSignInProvider implements SocialAuthProvider { kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버. +/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·LINE. /// /// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다. /// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다. @@ -197,3 +197,104 @@ AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount( displayName: user?.displayName, email: user?.email, ); + +/// 이메일 · 비밀번호 — 실 코드 경로. +/// +/// 정책 설계서 §P-01 이 가입 수단 5종에 이메일을 넣어 확정했다(답변서 Q7). +/// 초안 당시 CD-18 미확정이라 비워 두었던 자리다. +/// +/// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이 +/// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가 +/// 사라졌다」를 겪는다. Figma 도 로그인 화면과 회원가입 화면을 나눠 두었다. +final class EmailPasswordProvider implements EmailAuthProvider { + const EmailPasswordProvider(); + + @override + AuthProviderKind get provider => AuthProviderKind.email; + + @override + bool get isLive => true; + + @override + List get missingConfig => + FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels; + + /// 폼 없이 부르면 아무 일도 하지 않는다. 화면이 입력을 받아 + /// [signInWithEmail] 을 부른다. + @override + Future signIn() async { + final missing = missingConfig; + if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); + return const AuthCancelled(); + } + + @override + Future signInWithEmail(String email, String password) => + _run(() => fb.FirebaseAuth.instance.signInWithEmailAndPassword( + email: email.trim(), + password: password, + )); + + @override + Future signUpWithEmail(String email, String password) => + _run(() => fb.FirebaseAuth.instance.createUserWithEmailAndPassword( + email: email.trim(), + password: password, + )); + + @override + Future 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(_code(error), _message(error)); + } + } + + Future _run(Future 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(_code(error), _message(error)); + } + } + + @override + Future signOut() async { + // Firebase 세션 종료는 AuthSession 이 한 번만 담당한다. + } + + /// SDK 코드를 에러 카탈로그 ID 로 옮긴다. + /// + /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 + /// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다. + static String _code(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', + _ => 'E-AUTH-01', + }; + + static String _message(fb.FirebaseAuthException error) => switch (error.code) { + 'email-already-in-use' => '이미 가입된 이메일입니다.', + 'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', + 'invalid-email' => '이메일 형식이 올바르지 않습니다.', + 'too-many-requests' => '잠시 후 다시 시도해 주세요.', + _ => '이메일 또는 비밀번호를 확인해 주세요.', + }; +} + +/// LINE — 앱 키가 없어 아직 붙이지 못했다. +/// +/// 네이버 자리를 대신한다. 정책 §P-01 이 확정한 5종에 LINE 이 있고 네이버는 없다. +const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [ + 'LINE Login 채널 ID / 채널 시크릿', + 'LINE Developers 콘솔 채널 등록(사업자 정보 필요)', +]); diff --git a/app/test/auth_email_test.dart b/app/test/auth_email_test.dart new file mode 100644 index 0000000..493e9d3 --- /dev/null +++ b/app/test/auth_email_test.dart @@ -0,0 +1,242 @@ +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/auth_repository.dart'; +import 'package:fancake/features/auth/login_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 이메일 로그인 — 정책 설계서 §P-01 확정 가입 수단. +/// +/// 초안 당시 CD-18 미확정이라 비워 두었던 자리이며, 답변서 v1.1 Q7 이 +/// **이메일 · Google · Apple · Kakao · LINE 5종**으로 확정했다. + +/// 이메일 제공자 대역. Firebase 없이 결과만 돌려준다. +class _FakeEmail implements EmailAuthProvider { + _FakeEmail({this.result = const AuthCancelled()}); + + AuthOutcome result; + String? lastEmail; + String? lastPassword; + bool lastWasSignUp = false; + String? resetSentTo; + + @override + AuthProviderKind get provider => AuthProviderKind.email; + + @override + bool get isLive => true; + + @override + List get missingConfig => const []; + + @override + Future signIn() async => const AuthCancelled(); + + @override + Future signInWithEmail(String email, String password) async { + lastEmail = email; + lastPassword = password; + lastWasSignUp = false; + return result; + } + + @override + Future signUpWithEmail(String email, String password) async { + lastEmail = email; + lastPassword = password; + lastWasSignUp = true; + return result; + } + + @override + Future sendPasswordReset(String email) async { + resetSentTo = email; + return const AuthCancelled(); + } + + @override + Future signOut() async {} +} + +const _account = AuthAccount( + uid: 'u1', + provider: AuthProviderKind.email, + email: 'fan@fanuzu.co.kr', +); + +/// 화면 밖에 있으면 먼저 굴려 놓고 누른다. 로그인 화면은 세로로 길다. +Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); +} + +void main() { + group('확정 가입 수단 5종', () { + test('네이버가 빠지고 이메일·LINE 이 들어왔다', () { + final kinds = AuthRepository().providers.map((p) => p.provider).toList(); + expect(kinds, hasLength(5)); + expect(kinds, contains(AuthProviderKind.email)); + expect(kinds, contains(AuthProviderKind.line)); + expect(kinds.map((k) => k.name), isNot(contains('naver'))); + }); + + test('이메일만 입력 폼을 요구한다', () { + for (final kind in AuthProviderKind.values) { + expect(kind.needsForm, kind == AuthProviderKind.email); + } + }); + + test('저장소가 이메일 제공자를 찾아낸다', () { + expect(AuthRepository().emailProvider, isNotNull); + }); + }); + + group('저장소 — 이메일', () { + test('로그인에 성공하면 세션이 바뀐다', () async { + final fake = _FakeEmail(result: const AuthSucceeded(_account)); + final auth = AuthRepository(providers: [fake]); + final outcome = await auth.signInWithEmail( + email: 'fan@fanuzu.co.kr', + password: 'password12', + ); + expect(outcome, isA()); + expect(auth.isSignedIn, isTrue); + expect(fake.lastWasSignUp, isFalse); + }); + + test('가입은 로그인과 다른 경로로 간다', () async { + // 한 버튼에 합치면 오타로 친 이메일이 새 계정을 만들어 버린다. + final fake = _FakeEmail(result: const AuthSucceeded(_account)); + final auth = AuthRepository(providers: [fake]); + await auth.signInWithEmail( + email: 'fan@fanuzu.co.kr', + password: 'password12', + signUp: true, + ); + expect(fake.lastWasSignUp, isTrue); + }); + + test('실패하면 세션이 그대로다', () async { + final auth = AuthRepository( + providers: [_FakeEmail(result: const AuthFailed('E-AUTH-01', '확인해 주세요.'))], + ); + final outcome = await auth.signInWithEmail(email: 'a@b.c', password: 'x'); + expect(outcome, isA()); + expect(auth.isSignedIn, isFalse); + }); + + test('이메일 제공자가 없으면 실패로 닫는다', () async { + final auth = AuthRepository(providers: const []); + final outcome = await auth.signInWithEmail(email: 'a@b.c', password: 'x'); + expect(outcome, isA()); + }); + }); + + group('로그인 화면 — 이메일 폼', () { + Future pump(WidgetTester tester, _FakeEmail fake) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + final auth = AuthRepository(providers: [fake]); + await tester.pumpWidget(MaterialApp(home: LoginScreen(auth: auth))); + return auth; + } + + testWidgets('처음에는 폼이 접혀 있고 버튼을 누르면 펼쳐진다', (tester) async { + await pump(tester, _FakeEmail()); + expect(find.text('이메일'), findsNothing); + + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + expect(find.text('이메일'), findsOneWidget); + expect(find.text('비밀번호'), findsOneWidget); + }); + + testWidgets('빈 값으로 제출하면 안내가 뜨고 호출하지 않는다', (tester) async { + final fake = _FakeEmail(); + await pump(tester, fake); + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + + await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); + await tester.pump(); + expect(find.text('이메일을 입력해 주세요.'), findsOneWidget); + expect(fake.lastEmail, isNull); + }); + + testWidgets('이메일 형식이 아니면 막는다', (tester) async { + final fake = _FakeEmail(); + await pump(tester, fake); + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField).first, 'fanuzu.co.kr'); + await tester.enterText(find.byType(TextFormField).last, 'password12'); + await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); + await tester.pump(); + expect(find.text('이메일 형식이 올바르지 않습니다.'), findsOneWidget); + expect(fake.lastEmail, isNull); + }); + + testWidgets('가입 모드에서는 짧은 비밀번호를 막는다', (tester) async { + final fake = _FakeEmail(); + await pump(tester, fake); + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + await tapVisible(tester, find.text('계정 만들기')); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr'); + await tester.enterText(find.byType(TextFormField).last, 'short'); + await tapVisible(tester, find.widgetWithText(FilledButton, '가입하기')); + await tester.pump(); + expect( + find.text('${EmailFormMetrics.minPasswordLength}자 이상으로 정해 주세요.'), + findsOneWidget, + ); + expect(fake.lastEmail, isNull); + }); + + testWidgets('올바른 값이면 제공자를 부른다', (tester) async { + final fake = _FakeEmail(result: const AuthSucceeded(_account)); + await pump(tester, fake); + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField).first, 'fan@fanuzu.co.kr'); + await tester.enterText(find.byType(TextFormField).last, 'password12'); + await tapVisible(tester, find.widgetWithText(FilledButton, '로그인')); + await tester.pump(); + await tester.pump(); + expect(fake.lastEmail, 'fan@fanuzu.co.kr'); + expect(fake.lastPassword, 'password12'); + }); + + testWidgets('재설정은 이메일이 비어 있으면 보내지 않는다', (tester) async { + final fake = _FakeEmail(); + await pump(tester, fake); + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + + await tapVisible(tester, find.text('비밀번호 재설정')); + await tester.pump(); + expect(fake.resetSentTo, isNull); + expect(find.textContaining('먼저 입력해'), findsOneWidget); + }); + + testWidgets('재설정 안내는 계정 존재 여부를 드러내지 않는다', (tester) async { + final fake = _FakeEmail(); + await pump(tester, fake); + await tapVisible(tester, find.text(AuthProviderKind.email.label)); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField).first, 'nobody@fanuzu.co.kr'); + await tapVisible(tester, find.text('비밀번호 재설정')); + await tester.pump(); + await tester.pump(); + expect(fake.resetSentTo, 'nobody@fanuzu.co.kr'); + // 「가입되지 않은 이메일」이라고 알려 주면 가입 목록을 훑을 수 있다. + expect(find.textContaining('보냈습니다'), findsOneWidget); + }); + }); +}