로그인 4종(Google·Apple·Kakao·LINE) 전환 · 이메일 가입 비노출 · 계정 연결 · STATION 탭 비노출 플래그

This commit is contained in:
Sn-Kinos
2026-09-11 02:56:41 +09:00
parent 4f2974b88c
commit d9b6b7c7a0
10 changed files with 910 additions and 259 deletions
+86 -15
View File
@@ -1,19 +1,20 @@
/// 인증 계약 — 제공자 인터페이스와 결과 타입.
///
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** 카카오·네이버는 발주사 사업자 정보로
/// 개발자 콘솔에 앱을 등록해야 키가 나오고(Q3), Google·Apple 은 Firebase 설정 파일이
/// 있어야 한다(S1·S2). 둘 다 "아직 준비가 안 됐다"이지 "지원하지 않는다"가 아니므로
/// 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. 화면은 이걸 안내로 보여 준다.
/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자
/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지
/// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다.
/// 화면은 이걸 안내 다이얼로그로 보여 준다.
library;
/// 가입 수단 — **정책 설계서 §P-01 확정값 5종**(답변서 v1.1 Q7).
/// 로그인 수단 — **별첨 B v1.4 확정값**.
///
/// 초안 당시 CD-18 이 미확정이라 소셜 4종(카카오·네이버·Apple·Google)만 두었는데,
/// 답변서가 **이메일 · Google · Apple · Kakao · LINE** 으로 확정했다.
/// **네이버는 확정 목록에 없고, 이메일과 LINE 이 새로 들어왔다.**
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
/// > X 로그인은 제외합니다. 하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.
///
/// 앱 Figma 에는 WeChat · X 버튼도 그려져 있으나 답변서가 5종으로 정했으므로
/// 1차는 5종이다. 추가 시 재작업이 없도록 제공자를 인터페이스 뒤에 둔다.
/// 답변서 v1.1 Q7 은 이메일을 포함한 5종이었으나 별첨 B v1.4 가 이를 뒤집었다.
/// [email] 값이 남아 있는 것은 **QA 계정(qa-test@fanuzu.co.kr) 내부 경로** 하나
/// 때문이다 — 사용자에게 노출되는 목록([AuthRepository.providers])에는 들어가지
/// 않는다. X(트위터)는 확정 목록에 없어 값 자체를 두지 않는다.
enum AuthProviderKind {
email('이메일로 시작하기'),
kakao('카카오로 시작하기'),
@@ -26,7 +27,7 @@ enum AuthProviderKind {
/// 버튼 라벨. 임의로 바꾸지 마라 — 화면 대조 대상이다.
final String label;
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. 화면이 이걸로 갈린다.
/// 이메일은 소셜이 아니라 입력 폼이 붙는다. QA 내부 경로만 이걸 쓴다.
bool get needsForm => this == AuthProviderKind.email;
}
@@ -101,11 +102,12 @@ abstract interface class SocialAuthProvider {
Future<void> signOut();
}
/// 이메일 제공자만 쓰는 확장 계약.
/// 이메일 제공자 — **QA 계정 전용 내부 경로**.
///
/// 소셜은 외부 창이 열리고 끝이지만 이메일은 **앱 안에서 값을 받아야 한다.**
/// [SocialAuthProvider.signIn] 하나로 묶으면 화면이 제공자 종류를 보고 분기하게
/// 되므로, 입력이 필요한 쪽만 별도 계약으로 뺀다.
/// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 코드를 지우지 않은 것은
/// 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후 화면을 확인해야 하기
/// 때문이며, [AuthRepository.qaEmailProvider] 가 **디버그 빌드에서만** 이 구현체를
/// 돌려준다. 릴리스 빌드에는 부를 수 있는 경로가 없다.
abstract interface class EmailAuthProvider implements SocialAuthProvider {
Future<AuthOutcome> signInWithEmail(String email, String password);
@@ -126,3 +128,72 @@ abstract interface class AuthSession {
Future<void> signOut();
}
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
///
/// **로컬 파트만 비교한다.** 명세의 예시가 `test123@naver.com` 과
/// `test123@gmail.com` 을 같다고 보므로 도메인은 보지 않는다. 전체 문자열을
/// 비교하면 명세가 (O) 로 적은 사례가 막힌다.
bool sameAccountIdentity(String a, String b) => _localPart(a) == _localPart(b);
String _localPart(String email) => email.trim().toLowerCase().split('@').first;
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
/// 이메일은 QA 내부 경로라 목록에 없다.
const List<AuthProviderKind> linkableProviders = [
AuthProviderKind.google,
AuthProviderKind.apple,
AuthProviderKind.kakao,
AuthProviderKind.line,
];
/// 하나의 FANUZU UID 에 소셜 Provider 를 더 붙이거나 떼는 경계.
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
abstract interface class AccountLinker {
/// 지금 UID 에 붙어 있는 Provider → 그 Provider 가 준 이메일(모르면 null).
///
/// 설정 화면의 「SNS 연동」 목록이 이걸 그대로 그린다.
Map<AuthProviderKind, String?> get linkedAccounts;
Future<AuthOutcome> link(AuthProviderKind provider);
/// 마지막 하나 남은 Provider 는 뗄 수 없다 — 떼면 로그인 수단이 사라진다.
Future<AuthOutcome> unlink(AuthProviderKind provider);
}
/// 설정 파일·키가 없어 실제로는 붙일 수 없는 상태의 연동 경계.
///
/// 목 모드에서도 「SNS 연동」 화면 자체는 열려야 한다(별첨2 확인 항목의 화면 동선).
/// 상태를 메모리에만 들고 있고 UID 에는 아무 일도 하지 않는다.
final class SampleAccountLinker implements AccountLinker {
SampleAccountLinker({Map<AuthProviderKind, String?>? seed})
: _linked = {...?seed};
final Map<AuthProviderKind, String?> _linked;
@override
Map<AuthProviderKind, String?> get linkedAccounts => {..._linked};
@override
Future<AuthOutcome> link(AuthProviderKind provider) async {
_linked[provider] = 'fan@fanuzu.co.kr';
return AuthSucceeded(
AuthAccount(uid: 'mock-local', provider: provider, isMock: true),
);
}
@override
Future<AuthOutcome> unlink(AuthProviderKind provider) async {
if (_linked.length <= 1) {
return const AuthFailed('E-AUTH-13', '마지막 로그인 수단은 연결을 해제할 수 없습니다.');
}
_linked.remove(provider);
return AuthSucceeded(
AuthAccount(uid: 'mock-local', provider: provider, isMock: true),
);
}
}
+33 -15
View File
@@ -11,8 +11,10 @@ class AuthRepository extends ChangeNotifier {
AuthRepository({
List<SocialAuthProvider>? providers,
AuthSession? session,
EmailAuthProvider? qaEmailProvider,
bool? releaseMode,
}) : providers = providers ?? _defaultProviders(),
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
_session = session,
_account = session?.currentAccount,
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
@@ -23,21 +25,28 @@ class AuthRepository extends ChangeNotifier {
final bool _releaseMode;
final AuthSession? _session;
final EmailAuthProvider _qaEmail;
StreamSubscription<AuthAccount?>? _sessionSubscription;
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
/// 로그인 화면에 그릴 제공자 목록. **여기 없는 수단은 사용자가 닿을 수 없다.**
final List<SocialAuthProvider> providers;
AuthAccount? _account;
AuthAccount? get account => _account;
bool get isSignedIn => _account != null;
/// 정책 설계서 §P-01 확정 5종. **순서가 화면 버튼 순서다.**
/// **별첨 B v1.4 확정 4종.** 순서가 회신 문구 순서이자 화면 버튼 순서다.
///
/// 네이버가 빠지고 이메일·LINE 이 들어왔다(답변서 Q7). 카카오·LINE 은 개발자
/// 콘솔 앱 등록에 발주사 사업자 정보가 필요해 아직 키가 없다.
/// > 1차 로그인은 Google / Apple / Kakao / LINE 4종입니다. 이메일/비밀번호 가입 및
/// > X 로그인은 제외합니다.
///
/// **이메일은 여기 없다.** 목록에 없으면 화면이 버튼을 그리지 않고, 버튼이 없으면
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
///
/// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라
/// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다.
static List<SocialAuthProvider> _defaultProviders() => [
const EmailPasswordProvider(),
const GoogleSignInProvider(),
AppleSignInProvider(
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
@@ -49,21 +58,23 @@ class AuthRepository extends ChangeNotifier {
lineProvider,
];
/// 이메일 제공자. 화면이 입력 폼을 띄우기 전에 있는지 확인한다.
EmailAuthProvider? get emailProvider {
for (final p in providers) {
if (p is EmailAuthProvider) return p;
}
return null;
}
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
///
/// 별첨 B v1.4 가 이메일/비밀번호 가입을 1차 범위에서 뺐다. 그래도 발주사가
/// 로그인 이후 화면을 확인하려면 실 계정 하나로 들어갈 수단이 필요해 코드를
/// 남겼고, 릴리스 빌드에서는 null 이라 부를 수 있는 경로 자체가 없다.
///
/// [isMockSessionAvailable] 과 같은 이유로 빌드 모드로 막는다 — 릴리스
/// 산출물에 내부 우회 입구가 새어 나가면 안 된다.
EmailAuthProvider? get qaEmailProvider => _releaseMode ? null : _qaEmail;
/// 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다.
/// QA 이메일 로그인·가입. 결과가 성공이면 세션을 갈아 끼운다.
Future<AuthOutcome> signInWithEmail({
required String email,
required String password,
bool signUp = false,
}) async {
final impl = emailProvider;
final impl = qaEmailProvider;
if (impl == null) {
return const AuthFailed('E-AUTH-03', '이메일 로그인을 쓸 수 없습니다.');
}
@@ -81,7 +92,14 @@ class AuthRepository extends ChangeNotifier {
}
Future<AuthOutcome> signIn(SocialProvider provider) async {
final impl = providers.firstWhere((p) => p.provider == provider);
final impl = providers.firstWhere(
(p) => p.provider == provider,
orElse: () => throw ArgumentError.value(
provider,
'provider',
'노출 목록에 없는 로그인 수단이다 (별첨 B v1.4 확정 4종)',
),
);
late final AuthOutcome outcome;
try {
outcome = await impl.signIn();
@@ -0,0 +1,131 @@
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 콘솔 등록 시 강제되는 형식이다.
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);
// 키 대기 제공자(카카오·LINE)는 자격증명을 만들 수 없다.
if (impl is! CredentialProvider) {
return AuthNotConfigured(provider, const ['자격증명 구현']);
}
final source = impl as CredentialProvider;
try {
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) {
return AuthFailed(authCatalogCode(error), authMessage(error));
} 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));
}
}
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,
);
}
+118 -55
View File
@@ -5,11 +5,15 @@ import '../../core/theme/tokens.dart';
import 'auth.dart';
import 'auth_repository.dart';
/// 로그인 — Figma `03_Wireframe` 「로그인」(`510:86330`).
/// 로그인 — Figma `03_Wireframe` 「로그인」(`173:818` · `510:86330`).
///
/// **가입 수단은 정책 설계서 §P-01 확정 5종**이다(답변서 Q7) — 이메일 · Google ·
/// Apple · Kakao · LINE. 이메일만 앱 안에서 값을 받으므로 폼이 붙고, 나머지는
/// 버튼 하나로 외부 창을 연다.
/// **로그인 수단은 별첨 B v1.4 확정 4종**이다 — Google · Apple · Kakao · LINE.
/// 같은 회신이 이메일/비밀번호 가입과 X 로그인을 1차 범위에서 뺐으므로 이 화면에
/// 이메일 입력 폼으로 가는 **사용자 경로가 없다.** Figma 로그인 화면군의
/// 「회원가입」·「비밀번호 재설정」 동선도 같은 이유로 그리지 않는다.
///
/// 이메일 폼 자체는 발주사 QA 계정(qa-test@fanuzu.co.kr) 확인용으로 남아 있으나
/// **디버그 빌드에서 로고를 길게 눌러야만** 열린다([_openQaForm]).
///
/// 약관 동의(S-A-TERMS) 이후 동선은 09-10 제출분이다.
class LoginScreen extends StatefulWidget {
@@ -29,8 +33,8 @@ class _LoginScreenState extends State<LoginScreen> {
String? _notice;
bool _busy = false;
/// 이메일 폼이 펼쳐졌는가. 처음에는 접어 둔다 — 소셜이 주 경로다.
bool _emailOpen = false;
/// QA 이메일 폼이 열렸는가. 숨은 입구로만 열린다(별첨 B v1.4 — 이메일 가입 제외).
bool _qaFormOpen = false;
/// 가입 모드인가. 로그인과 가입을 한 버튼에 합치지 않는다.
bool _signUp = false;
@@ -63,7 +67,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _notice = '재설정 메일을 받으실 이메일을 먼저 입력해 주세요.');
return;
}
final impl = widget.auth.emailProvider;
final impl = widget.auth.qaEmailProvider;
if (impl == null) return;
setState(() => _busy = true);
final outcome = await impl.sendPasswordReset(email);
@@ -90,12 +94,46 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider);
if (!mounted) return;
setState(() {
_busy = false;
_notice = _noticeOf(outcome);
});
setState(() => _busy = false);
// 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면
// 사용자는 앱이 멈춘 줄 안다.
if (outcome is AuthNotConfigured) {
await _showNotConfigured(outcome);
return;
}
if (!mounted) return;
setState(() => _notice = _noticeOf(outcome));
}
Future<void> _showNotConfigured(AuthNotConfigured outcome) async {
final name = outcome.provider.label.replaceAll('로 시작하기', '');
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text('$name 로그인 준비 중'),
content: Text(
'$name 로그인은 발주사 키가 들어오면 열립니다.\n\n'
'필요한 항목\n· ${outcome.missing.join('\n· ')}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('확인'),
),
],
),
);
}
/// QA 계정 전용 숨은 입구 — **디버그 빌드에서 로고 길게 누르기**.
///
/// 별첨 B v1.4 가 이메일 가입을 뺐으므로 눈에 보이는 진입점을 두지 않는다.
/// 디버그 APK 도 발주사가 설치해 보는 산출물이라, 보이는 「QA 로그인」 버튼은
/// 그 자체로 「이메일 가입이 살아 있다」로 읽힌다.
VoidCallback? get _openQaForm => widget.auth.qaEmailProvider == null
? null
: () => setState(() => _qaFormOpen = !_qaFormOpen);
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
@@ -110,20 +148,23 @@ class _LoginScreenState extends State<LoginScreen> {
// 시안 로그인(173:1905)은 화면 위쪽 가운데에 핑크 원(로고 자리) 하나만 둔다.
// 로고 원본은 미확정(Q6)이라 원 안에 워드마크를 텍스트로 넣는다.
Center(
child: Container(
width: 120,
height: 120,
decoration: const BoxDecoration(
color: AppColors.cosmicAccent,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
'FANUZU',
style: text.titleLarge?.copyWith(
color: AppColors.onCosmicAccent,
fontFamily: AppFonts.display,
fontWeight: FontWeight.w700,
child: GestureDetector(
onLongPress: _openQaForm,
child: Container(
width: 120,
height: 120,
decoration: const BoxDecoration(
color: AppColors.cosmicAccent,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
'FANUZU',
style: text.titleLarge?.copyWith(
color: AppColors.onCosmicAccent,
fontFamily: AppFonts.display,
fontWeight: FontWeight.w700,
),
),
),
),
@@ -131,44 +172,46 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: AppSpace.s6),
Text(
'당신의 팬 활동을\n하나의 행성으로',
style: text.headlineSmall?.copyWith(fontWeight: FontWeight.w500),
style: text.headlineSmall?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpace.s3),
Text(
'로그인하면 이용약관과 개인정보 처리방침에 동의하는 절차로 이동합니다.',
style: text.bodySmall?.copyWith(color: AppColors.onCosmicVariant),
style: text.bodySmall?.copyWith(
color: AppColors.onCosmicVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpace.s8),
Text('계정으로 시작하기', style: text.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(
'사용 중인 계정 하나를 선택해 주세요.',
style: text.bodySmall,
),
Text('사용 중인 계정 하나를 선택해 주세요.', style: text.bodySmall),
const SizedBox(height: AppSpace.s4),
for (final p in widget.auth.providers) ...[
_ProviderButton(
provider: p.provider,
// 발주사 키가 아직 없는 제공자. 눌러도 안내 다이얼로그로 닫힌다.
awaitingKeys: p.missingConfig.isNotEmpty,
enabled: !_busy,
onPressed: p.provider.needsForm
? () => setState(() => _emailOpen = !_emailOpen)
: () => _signIn(p.provider),
onPressed: () => _signIn(p.provider),
),
const SizedBox(height: AppSpace.s3),
],
if (_qaFormOpen) ...[
const SizedBox(height: AppSpace.s2),
_EmailForm(
formKey: _form,
email: _email,
password: _password,
signUp: _signUp,
busy: _busy,
onSubmit: _submitEmail,
onToggleMode: () => setState(() => _signUp = !_signUp),
onReset: _resetPassword,
),
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) ...[
@@ -194,11 +237,17 @@ class _LoginScreenState extends State<LoginScreen> {
class _ProviderButton extends StatelessWidget {
const _ProviderButton({
required this.provider,
required this.awaitingKeys,
required this.enabled,
required this.onPressed,
});
final SocialProvider provider;
/// 「키 대기」 배지를 붙일지. **버튼을 비활성으로 만들지 않는다** — 눌러야
/// 무엇이 없어서 못 쓰는지 안내가 뜬다. 회색 버튼은 사유를 말해 주지 않는다.
final bool awaitingKeys;
final bool enabled;
final VoidCallback onPressed;
@@ -227,7 +276,23 @@ class _ProviderButton extends StatelessWidget {
disabledBackgroundColor: fill.withValues(alpha: 0.4),
side: stroke == null ? null : BorderSide(color: stroke),
),
child: Text(provider.label),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(provider.label, overflow: TextOverflow.ellipsis),
),
if (awaitingKeys) ...[
const SizedBox(width: AppSpace.s2),
Text(
keyPendingBadge,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: label.withValues(alpha: 0.7),
),
),
],
],
),
);
}
}
@@ -265,17 +330,13 @@ class _MockBanner extends StatelessWidget {
const SizedBox(height: AppSpace.s2),
Text(FirebaseBoot.reason, style: text.bodySmall),
const SizedBox(height: AppSpace.s3),
OutlinedButton(
onPressed: onEnter,
child: const Text('목 세션으로 탭 둘러보기'),
),
OutlinedButton(onPressed: onEnter, child: const Text('목 세션으로 탭 둘러보기')),
],
);
}
}
/// 이메일 입력 폼.
/// QA 계정 전용 이메일 입력 폼 — 숨은 입구로만 열린다.
///
/// Figma 「로그인 입력 영역」 명세 —
/// *이메일 입력 필드 - 이메일 형식 입력 · 비밀번호 입력 필드 - 입력 시 마스킹 처리 ·*
@@ -301,7 +362,6 @@ class _EmailForm extends StatelessWidget {
final VoidCallback onToggleMode;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
@@ -378,6 +438,9 @@ class _EmailForm extends StatelessWidget {
}
}
/// 발주사 키를 기다리는 제공자 버튼에 붙는 배지.
/// 「미지원」이 아니라 「대기」다 — 상태이지 결론이 아니다.
const String keyPendingBadge = '키 대기';
/// 이메일 폼이 쓰는 수치. 테스트가 문구를 손으로 적지 않게 밖으로 뺀다.
abstract final class EmailFormMetrics {
+125 -62
View File
@@ -9,11 +9,30 @@ import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
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] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
Future<SocialCredential?> obtainCredential();
}
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
/// Google 로그인 — 실 코드 경로.
///
/// Firebase 설정 파일이 없으면 SDK 를 부르기 전에 [AuthNotConfigured] 로 끊는다.
/// 파일이 투입되면(S2) 이 클래스는 수정 없이 그대로 동작한다.
final class GoogleSignInProvider implements SocialAuthProvider {
final class GoogleSignInProvider
implements SocialAuthProvider, CredentialProvider {
const GoogleSignInProvider();
static Future<void>? _initialization;
@@ -30,23 +49,32 @@ final class GoogleSignInProvider implements SocialAuthProvider {
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 signIn = gsi.GoogleSignIn.instance;
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
await (_initialization ??= signIn.initialize());
final account = await signIn.authenticate();
final idToken = account.authentication.idToken;
if (idToken == null) {
final obtained = await obtainCredential();
if (obtained == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
final result = await fb.FirebaseAuth.instance.signInWithCredential(
credential,
obtained.credential,
);
return AuthSucceeded(_toAccount(result.user, provider));
} on gsi.GoogleSignInException catch (error) {
@@ -55,7 +83,7 @@ final class GoogleSignInProvider implements SocialAuthProvider {
}
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -73,7 +101,8 @@ final class GoogleSignInProvider implements SocialAuthProvider {
/// iOS 는 Xcode 에서 「Sign in with Apple」 Capability 를, Android·Web 은
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
final class AppleSignInProvider implements SocialAuthProvider {
final class AppleSignInProvider
implements SocialAuthProvider, CredentialProvider {
const AppleSignInProvider({this.webAuthenticationOptions});
/// Android·Web에서 필요한 Service ID와 검증된 callback URL.
@@ -94,6 +123,36 @@ final class AppleSignInProvider implements SocialAuthProvider {
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;
@@ -103,28 +162,13 @@ final class AppleSignInProvider implements SocialAuthProvider {
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
}
try {
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) {
final obtained = await obtainCredential();
if (obtained == null) {
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
}
final oauth = fb.OAuthProvider('apple.com').credential(
idToken: idToken,
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
final result = await fb.FirebaseAuth.instance.signInWithCredential(
obtained.credential,
);
final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth);
return AuthSucceeded(_toAccount(result.user, provider));
} on apple.SignInWithAppleAuthorizationException catch (error) {
if (error.code == apple.AuthorizationErrorCode.canceled) {
@@ -135,7 +179,7 @@ final class AppleSignInProvider implements SocialAuthProvider {
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
} on fb.FirebaseAuthException catch (error) {
return AuthFailed('E-AUTH-03', error.message ?? error.code);
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -148,10 +192,11 @@ final class AppleSignInProvider implements SocialAuthProvider {
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·LINE.
/// 발주사 키를 기다리는 제공자 — 카카오·LINE.
///
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려준다.
/// 키가 들어오면 이 자리에 실 구현체를 넣고 [AuthRepository] 목록만 바꾸면 된다.
/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려주고
/// 로그인 화면이 그걸 안내 다이얼로그로 띄운다. 키가 들어오면 이 자리에 실 구현체를
/// 넣고 [AuthRepository] 목록만 바꾸면 된다 — 화면·라우터는 그대로다.
final class KeylessSocialProvider implements SocialAuthProvider {
const KeylessSocialProvider(this.provider, this.missingConfig);
@@ -198,14 +243,18 @@ AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
email: user?.email,
);
/// 이메일 · 비밀번호 — 실 코드 경로.
/// 이메일 · 비밀번호 — **QA 계정 전용 내부 경로**.
///
/// 정책 설계서 §P-01 이 가입 수단 5종에 이메일을 넣어 확정했다(답변서 Q7).
/// 초안 당시 CD-18 미확정이라 비워 두었던 자리다.
/// 별첨 B v1.4 가 1차 로그인을 Google/Apple/Kakao/LINE 4종으로 확정하고
/// 이메일/비밀번호 가입을 제외했다. 그래서 이 구현체는
/// [AuthRepository.providers] 에 들어가지 않는다 — 사용자가 닿을 수 있는 버튼이
/// 없다. 남겨 둔 이유는 발주사 QA 계정(qa-test@fanuzu.co.kr)으로 로그인 이후
/// 화면을 확인해야 하기 때문이고, [AuthRepository.qaEmailProvider] 가
/// **디버그 빌드에서만** 이걸 돌려준다.
///
/// **가입과 로그인을 한 버튼에 합치지 않는다.** 합치면 오타로 친 이메일이
/// 새 계정을 만들어 버리고, 사용자는 「비밀번호가 틀렸다」가 아니라 「내 데이터가
/// 사라졌다」를 겪는다. Figma 도 로그인 화면과 회원가입 화면을 나눠 두었다.
/// 사라졌다」를 겪는다.
final class EmailPasswordProvider implements EmailAuthProvider {
const EmailPasswordProvider();
@@ -250,7 +299,7 @@ final class EmailPasswordProvider implements EmailAuthProvider {
await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim());
return const AuthCancelled();
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(_code(error), _message(error));
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -261,7 +310,7 @@ final class EmailPasswordProvider implements EmailAuthProvider {
final result = await action();
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(_code(error), _message(error));
return AuthFailed(authCatalogCode(error), authMessage(error));
}
}
@@ -269,32 +318,46 @@ final class EmailPasswordProvider implements EmailAuthProvider {
Future<void> 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 — 앱 키가 없어 아직 붙이지 못했다.
/// LINE — 발주사 키 대기.
///
/// 네이버 자리를 대신한다. 정책 §P-01 이 확정한 5종에 LINE 이 있고 네이버는 없다.
/// 별첨 B v1.4 확정 4종에 LINE 이 있고 네이버·X 는 없다.
const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [
'LINE Login 채널 ID / 채널 시크릿',
'LINE Developers 콘솔 채널 등록(사업자 정보 필요)',
]);
/// SDK 코드를 에러 카탈로그(`wireframe/planning/error-catalog.yaml`) 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',
// 계정 연결(별첨 B v1.4) 경로에서만 나오는 코드들.
'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' => 'E-AUTH-12',
_ => 'E-AUTH-01',
};
String authMessage(fb.FirebaseAuthException error) => switch (error.code) {
'email-already-in-use' => '이미 가입된 이메일입니다.',
'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.',
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
'credential-already-in-use' || 'account-exists-with-different-credential' =>
'다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
'provider-already-linked' => '이미 연결된 계정입니다.',
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
_ => '이메일 또는 비밀번호를 확인해 주세요.',
};