SNS 계정 연결의 이메일 일치 조건 제거 — 현재 로그인과 새 제공자 인증을 서버에서 확인 · 다른 계정에 연결된 제공자는 거부 · 세션 만료 시 재로그인 안내

This commit is contained in:
Sn-Kinos
2026-10-10 21:14:49 +09:00
parent b66863a149
commit 4d2ce67886
8 changed files with 175 additions and 81 deletions
+5 -10
View File
@@ -133,16 +133,6 @@ abstract interface class AuthSession {
Future<void> signOut();
}
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
///
/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다.
/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이
/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false.
bool sameAccountIdentity(String a, String b) {
final x = a.trim().toLowerCase();
return x.contains('@') && x == b.trim().toLowerCase();
}
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
/// 이메일은 QA 내부 경로라 목록에 없다.
const List<AuthProviderKind> linkableProviders = [
@@ -156,6 +146,11 @@ const List<AuthProviderKind> linkableProviders = [
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
///
/// 연결은 현재 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버가** 모두 확인해 붙인다
/// (Firebase Auth `linkWithCredential` · 카카오는 `fnAuthKakao`). 이메일 일치는 소유 확인이 아니어서
/// 비교하지 않는다 — 이메일을 주지 않는 카카오 · LINE 계정도 붙는다. 그 Provider 가 다른 FANUZU UID 에
/// 붙어 있으면 거부하고(E-AUTH-08) 자동 병합하지 않는다.
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
abstract interface class AccountLinker {
@@ -70,20 +70,9 @@ final class FirebaseAccountLinker implements AccountLinker {
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
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', '가입하신 이메일과 다른 계정입니다.');
}
// 이메일을 비교하지 않는다 — 이메일 일치는 소유 확인이 아니다. Firebase 가 지금 로그인 토큰과
// 새 자격증명을 서버에서 함께 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면
// credential-already-in-use 로 거부한다.
final result = await user.linkWithCredential(obtained.credential);
return AuthSucceeded(_accountOf(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
@@ -125,33 +114,14 @@ final class FirebaseAccountLinker implements AccountLinker {
}
}
/// 카카오·LINE(OIDC) 연결.
///
/// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다.
/// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다.
/// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의
/// blocking 함수(beforeSignIn)로 서버에서 거른다.
/// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
final mine = user.email;
final result = await untilAbandoned(
user.linkWithProvider(impl.authProvider),
);
// 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때).
final theirs = _emailOf(result.user, impl.providerId);
if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) {
await user.unlink(impl.providerId);
return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.');
}
return AuthSucceeded(_accountOf(result.user, impl.provider));
}
static String? _emailOf(fb.User? user, String providerId) {
for (final info in user?.providerData ?? const <fb.UserInfo>[]) {
if (info.providerId == providerId) return info.email;
}
return null;
}
SocialAuthProvider? _implOf(AuthProviderKind provider) {
for (final candidate in _providers) {
if (candidate.provider == provider) return candidate;
+19 -10
View File
@@ -25,12 +25,12 @@ abstract interface class CredentialProvider {
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
///
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
/// 쓰지 않는다(이메일 일치는 소유 확인이 아니다).
Future<SocialCredential?> obtainCredential();
}
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
/// Google 로그인 — 실 코드 경로.
@@ -156,8 +156,7 @@ final class AppleSignInProvider
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
),
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
email: credential.email,
);
}
@@ -372,7 +371,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
});
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다.
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다.
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
await _exchange({'accessToken': await _accessToken(), 'link': true});
await user.reload();
@@ -410,8 +410,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'different-email' =>
'가입하신 이메일과 다른 계정입니다.',
_ when details is Map && details['reason'] == 'session' =>
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
'E-AUTH-09' => '이미 연결된 계정입니다.',
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
@@ -594,7 +594,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
'no-such-provider' => 'E-AUTH-10',
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
'operation-not-allowed' => 'E-AUTH-11',
'requires-recent-login' => 'E-AUTH-12',
// 현재 계정의 세션이 만료 · 폐기됐다. 다시 로그인해야 한다.
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => 'E-AUTH-12',
_ => 'E-AUTH-01',
};
@@ -634,7 +639,11 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'provider-already-linked' => '이미 연결된 계정입니다.',
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
_ => '이메일 또는 비밀번호를 확인해 주세요.',
};