로그인 4종(Google·Apple·Kakao·LINE) 전환 · 이메일 가입 비노출 · 계정 연결 · STATION 탭 비노출 플래그
This commit is contained in:
@@ -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' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user