SNS 계정 연결의 이메일 일치 조건 제거 — 현재 로그인과 새 제공자 인증을 서버에서 확인 · 다른 계정에 연결된 제공자는 거부 · 세션 만료 시 재로그인 안내
This commit is contained in:
@@ -35,7 +35,7 @@ export interface KakaoProfile {
|
|||||||
/** 카카오 회원번호. */
|
/** 카카오 회원번호. */
|
||||||
id: string;
|
id: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
/** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */
|
/** 동의했고 유효·인증된 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */
|
||||||
email?: string;
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,25 +110,40 @@ export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않는다.
|
* SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
||||||
*
|
*
|
||||||
* 「동일 이메일만 연동」(명세 `554:22797`)은 붙이기 **전에** 서버에서 본다. 둘 중 하나라도
|
* 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다.
|
||||||
* 이메일이 없으면 건너뛴다(앱과 같은 규칙 — 카카오 이메일은 비즈 앱 동의가 있을 때만 온다).
|
* 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 카카오 계정도 연결된다).
|
||||||
|
* - 새 Provider 자격: [verifyKakaoToken] 이 우리 앱에서 발급된 유효 토큰인지 카카오 API 로 본다.
|
||||||
|
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이
|
||||||
|
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
|
||||||
|
* - 그 카카오 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
|
||||||
|
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
|
||||||
*/
|
*/
|
||||||
export async function linkKakao(uid: string, p: KakaoProfile): Promise<void> {
|
export async function linkKakao(uid: string, p: KakaoProfile, authTime?: number): Promise<void> {
|
||||||
const owner = await ownerOf(p.id);
|
const owner = await ownerOf(p.id);
|
||||||
if (owner === uid) return;
|
if (owner === uid) return;
|
||||||
if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
||||||
const user = await getAuth().getUser(uid);
|
const user = await getAuth().getUser(uid);
|
||||||
|
const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0;
|
||||||
|
if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) {
|
||||||
|
fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' });
|
||||||
|
}
|
||||||
if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) {
|
if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) {
|
||||||
fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.');
|
fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.');
|
||||||
}
|
}
|
||||||
const mine = user.email?.trim().toLowerCase();
|
try {
|
||||||
const theirs = p.email?.trim().toLowerCase();
|
await getAuth().updateUser(uid, { providerToLink: linkInfo(p) });
|
||||||
if (mine && theirs && mine !== theirs) {
|
} catch (e) {
|
||||||
fail(ERR.authCredentialInUse, '같은 이메일의 계정만 연결할 수 있습니다.', { reason: 'different-email' });
|
const code = (e as { code?: string }).code ?? '';
|
||||||
|
if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) {
|
||||||
|
fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if ((await ownerOf(p.id)) !== uid) {
|
||||||
|
fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
||||||
}
|
}
|
||||||
await getAuth().updateUser(uid, { providerToLink: linkInfo(p) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fnAuthKakao = onCall(async (req) => {
|
export const fnAuthKakao = onCall(async (req) => {
|
||||||
@@ -136,7 +151,8 @@ export const fnAuthKakao = onCall(async (req) => {
|
|||||||
if ((req.data as Record<string, unknown>).link === true) {
|
if ((req.data as Record<string, unknown>).link === true) {
|
||||||
const uid = req.auth?.uid;
|
const uid = req.auth?.uid;
|
||||||
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
|
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
|
||||||
await linkKakao(uid, profile);
|
const authTime = req.auth?.token?.auth_time;
|
||||||
|
await linkKakao(uid, profile, typeof authTime === 'number' ? authTime : undefined);
|
||||||
return { linked: true };
|
return { linked: true };
|
||||||
}
|
}
|
||||||
const uid = await resolveKakaoUid(profile);
|
const uid = await resolveKakaoUid(profile);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { HttpsError } from 'firebase-functions/v2/https';
|
|||||||
export const ERR = {
|
export const ERR = {
|
||||||
permissionDenied: 'E-PERM-01',
|
permissionDenied: 'E-PERM-01',
|
||||||
authFailed: 'E-AUTH-03',
|
authFailed: 'E-AUTH-03',
|
||||||
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
|
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「그 Provider 가 다른 계정에 이미 연결」(이메일 비교 없음).
|
||||||
authCredentialInUse: 'E-AUTH-08',
|
authCredentialInUse: 'E-AUTH-08',
|
||||||
authAlreadyLinked: 'E-AUTH-09',
|
authAlreadyLinked: 'E-AUTH-09',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -96,11 +96,38 @@ describe('SNS 연동', () => {
|
|||||||
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
|
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => {
|
it('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다', async () => {
|
||||||
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
stubKakao('other@kakao.com');
|
stubKakao('other@kakao.com');
|
||||||
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email');
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
stubKakao('FAN@fanuzu.co.kr');
|
await getAuth().deleteUser(GOOGLE_UID);
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||||
|
stubKakao();
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
|
const user = await getAuth().getUser(GOOGLE_UID);
|
||||||
|
expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
const before = Math.floor(Date.now() / 1000) - 60;
|
||||||
|
await getAuth().revokeRefreshTokens(GOOGLE_UID);
|
||||||
|
const linkAs = (authTime: number) => fnAuthKakao.run(
|
||||||
|
{ data: { accessToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req,
|
||||||
|
);
|
||||||
|
expect(await codeOf(linkAs(before))).toBe('session');
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { disabled: true });
|
||||||
|
expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session');
|
||||||
|
expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 다른 카카오가 붙어 있으면 E-AUTH-09 · 같은 카카오면 그대로 성공', async () => {
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: '111' } });
|
||||||
|
expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09');
|
||||||
|
await getAuth().deleteUser(GOOGLE_UID);
|
||||||
|
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||||
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,16 +133,6 @@ abstract interface class AuthSession {
|
|||||||
Future<void> signOut();
|
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종.
|
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
|
||||||
/// 이메일은 QA 내부 경로라 목록에 없다.
|
/// 이메일은 QA 내부 경로라 목록에 없다.
|
||||||
const List<AuthProviderKind> linkableProviders = [
|
const List<AuthProviderKind> linkableProviders = [
|
||||||
@@ -156,6 +146,11 @@ const List<AuthProviderKind> linkableProviders = [
|
|||||||
///
|
///
|
||||||
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
|
||||||
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
|
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
|
||||||
|
///
|
||||||
|
/// 연결은 현재 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버가** 모두 확인해 붙인다
|
||||||
|
/// (Firebase Auth `linkWithCredential` · 카카오는 `fnAuthKakao`). 이메일 일치는 소유 확인이 아니어서
|
||||||
|
/// 비교하지 않는다 — 이메일을 주지 않는 카카오 · LINE 계정도 붙는다. 그 Provider 가 다른 FANUZU UID 에
|
||||||
|
/// 붙어 있으면 거부하고(E-AUTH-08) 자동 병합하지 않는다.
|
||||||
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
|
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
|
||||||
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
|
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
|
||||||
abstract interface class AccountLinker {
|
abstract interface class AccountLinker {
|
||||||
|
|||||||
@@ -70,20 +70,9 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
|
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
|
||||||
if (obtained == null) return const AuthCancelled();
|
if (obtained == null) return const AuthCancelled();
|
||||||
|
|
||||||
// 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다.
|
// 이메일을 비교하지 않는다 — 이메일 일치는 소유 확인이 아니다. Firebase 가 지금 로그인 토큰과
|
||||||
// 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다.
|
// 새 자격증명을 서버에서 함께 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면
|
||||||
//
|
// credential-already-in-use 로 거부한다.
|
||||||
// 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);
|
final result = await user.linkWithCredential(obtained.credential);
|
||||||
return AuthSucceeded(_accountOf(result.user, provider));
|
return AuthSucceeded(_accountOf(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
@@ -125,33 +114,14 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 카카오·LINE(OIDC) 연결.
|
/// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
||||||
///
|
|
||||||
/// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다.
|
|
||||||
/// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다.
|
|
||||||
/// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의
|
|
||||||
/// blocking 함수(beforeSignIn)로 서버에서 거른다.
|
|
||||||
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
||||||
final mine = user.email;
|
|
||||||
final result = await untilAbandoned(
|
final result = await untilAbandoned(
|
||||||
user.linkWithProvider(impl.authProvider),
|
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));
|
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) {
|
SocialAuthProvider? _implOf(AuthProviderKind provider) {
|
||||||
for (final candidate in _providers) {
|
for (final candidate in _providers) {
|
||||||
if (candidate.provider == provider) return candidate;
|
if (candidate.provider == provider) return candidate;
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ abstract interface class CredentialProvider {
|
|||||||
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
|
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
|
||||||
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
|
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
|
||||||
///
|
///
|
||||||
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
|
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
|
||||||
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
|
/// 쓰지 않는다(이메일 일치는 소유 확인이 아니다).
|
||||||
Future<SocialCredential?> obtainCredential();
|
Future<SocialCredential?> obtainCredential();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
|
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
|
||||||
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
|
typedef SocialCredential = ({fb.AuthCredential credential, String? email});
|
||||||
|
|
||||||
/// Google 로그인 — 실 코드 경로.
|
/// Google 로그인 — 실 코드 경로.
|
||||||
@@ -156,8 +156,7 @@ final class AppleSignInProvider
|
|||||||
accessToken: credential.authorizationCode,
|
accessToken: credential.authorizationCode,
|
||||||
rawNonce: rawNonce,
|
rawNonce: rawNonce,
|
||||||
),
|
),
|
||||||
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
|
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
|
||||||
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
|
|
||||||
email: credential.email,
|
email: credential.email,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -372,7 +371,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
|||||||
return AuthSucceeded(_toAccount(result.user, provider));
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다.
|
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
|
||||||
|
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다.
|
||||||
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
||||||
await _exchange({'accessToken': await _accessToken(), 'link': true});
|
await _exchange({'accessToken': await _accessToken(), 'link': true});
|
||||||
await user.reload();
|
await user.reload();
|
||||||
@@ -410,8 +410,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
|||||||
? details['code'] as String
|
? details['code'] as String
|
||||||
: 'E-AUTH-03';
|
: 'E-AUTH-03';
|
||||||
return AuthFailed(code, switch (code) {
|
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-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
||||||
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||||
@@ -594,7 +594,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|||||||
'no-such-provider' => 'E-AUTH-10',
|
'no-such-provider' => 'E-AUTH-10',
|
||||||
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
|
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
|
||||||
'operation-not-allowed' => 'E-AUTH-11',
|
'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',
|
_ => 'E-AUTH-01',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -634,7 +639,11 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|||||||
'provider-already-linked' => '이미 연결된 계정입니다.',
|
'provider-already-linked' => '이미 연결된 계정입니다.',
|
||||||
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
'no-such-provider' => '연결되어 있지 않은 계정입니다.',
|
||||||
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.',
|
||||||
'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
'requires-recent-login' ||
|
||||||
|
'user-token-expired' ||
|
||||||
|
'invalid-user-token' ||
|
||||||
|
'user-mismatch' ||
|
||||||
|
'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||||
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -109,20 +109,31 @@ void main() {
|
|||||||
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
expect((outcome as AuthSucceeded).account.uid, 'u-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('다른 이메일이면 E-AUTH-08 · 이메일 안내 문구', () async {
|
test('다른 계정에 붙은 카카오면 E-AUTH-08 · 세션 만료면 다시 로그인 안내', () async {
|
||||||
final auth = MockFirebaseAuth(
|
final auth = MockFirebaseAuth(
|
||||||
signedIn: true,
|
signedIn: true,
|
||||||
mockUser: MockUser(uid: 'u-1'),
|
mockUser: MockUser(uid: 'u-1'),
|
||||||
);
|
);
|
||||||
final outcome = await kakao(
|
final inUse = await kakao(
|
||||||
auth,
|
auth,
|
||||||
serverError: FirebaseFunctionsException(
|
serverError: FirebaseFunctionsException(
|
||||||
code: 'already-exists',
|
code: 'already-exists',
|
||||||
message: 'x',
|
message: 'x',
|
||||||
details: {'code': 'E-AUTH-08', 'reason': 'different-email'},
|
details: {'code': 'E-AUTH-08'},
|
||||||
),
|
),
|
||||||
).link(auth.currentUser!);
|
).link(auth.currentUser!);
|
||||||
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
expect((inUse as AuthFailed).code, 'E-AUTH-08');
|
||||||
|
expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||||
|
|
||||||
|
final expired = await kakao(
|
||||||
|
auth,
|
||||||
|
serverError: FirebaseFunctionsException(
|
||||||
|
code: 'permission-denied',
|
||||||
|
message: 'x',
|
||||||
|
details: {'code': 'E-AUTH-03', 'reason': 'session'},
|
||||||
|
),
|
||||||
|
).link(auth.currentUser!);
|
||||||
|
expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,8 +127,27 @@ void main() {
|
|||||||
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
|
test('이메일이 다르거나 없는 LINE 계정도 붙는다 — 이메일 일치는 소유 확인이 아니다', () async {
|
||||||
final user = _LinkingUser(linkedEmail: 'someone@else.com');
|
for (final email in ['someone@else.com', null]) {
|
||||||
|
final user = _LinkingUser(linkedEmail: email);
|
||||||
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
|
final linker = FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
|
||||||
|
expect(outcome, isA<AuthSucceeded>(), reason: '$email');
|
||||||
|
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('다른 FANUZU 계정에 붙은 LINE 은 Firebase 거부를 E-AUTH-08 로 · 연결되지 않는다', () async {
|
||||||
|
final user = _LinkingUser(
|
||||||
|
linkedEmail: null,
|
||||||
|
error: fb.FirebaseAuthException(code: 'credential-already-in-use'),
|
||||||
|
);
|
||||||
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
final linker = FirebaseAccountLinker(
|
final linker = FirebaseAccountLinker(
|
||||||
providers: [line(auth)],
|
providers: [line(auth)],
|
||||||
@@ -138,14 +157,59 @@ void main() {
|
|||||||
final outcome = await linker.link(AuthProviderKind.line);
|
final outcome = await linker.link(AuthProviderKind.line);
|
||||||
|
|
||||||
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
expect((outcome as AuthFailed).code, 'E-AUTH-08');
|
||||||
expect(
|
expect(outcome.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.');
|
||||||
linker.linkedAccounts.keys,
|
expect(linker.linkedAccounts.keys, isNot(contains(AuthProviderKind.line)));
|
||||||
isNot(contains(AuthProviderKind.line)),
|
});
|
||||||
|
|
||||||
|
test('Google · Apple(자격증명 경로)도 이메일이 달라도 붙는다', () async {
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'u-1', email: 'fan@fanuzu.co.kr'),
|
||||||
);
|
);
|
||||||
|
final outcome = await FirebaseAccountLinker(
|
||||||
|
providers: [const _FakeCredential('other@gmail.com')],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
).link(AuthProviderKind.google);
|
||||||
|
expect(outcome, isA<AuthSucceeded>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('세션이 만료됐으면 다시 로그인 안내(E-AUTH-12)', () async {
|
||||||
|
final user = _LinkingUser(
|
||||||
|
linkedEmail: null,
|
||||||
|
error: fb.FirebaseAuthException(code: 'user-token-expired'),
|
||||||
|
);
|
||||||
|
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
|
||||||
|
final outcome = await FirebaseAccountLinker(
|
||||||
|
providers: [line(auth)],
|
||||||
|
firebaseAuth: auth,
|
||||||
|
).link(AuthProviderKind.line);
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-12');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 이메일을 함께 주는 자격증명 제공자(Google 흉내).
|
||||||
|
final class _FakeCredential implements SocialAuthProvider, CredentialProvider {
|
||||||
|
const _FakeCredential(this.email);
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthProviderKind get provider => AuthProviderKind.google;
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => const [];
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() async => const AuthCancelled();
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {}
|
||||||
|
@override
|
||||||
|
Future<SocialCredential?> obtainCredential() async => (
|
||||||
|
credential: fb.GoogleAuthProvider.credential(idToken: 'id'),
|
||||||
|
email: email,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _ThrowingAuth extends MockFirebaseAuth {
|
class _ThrowingAuth extends MockFirebaseAuth {
|
||||||
_ThrowingAuth(this.code);
|
_ThrowingAuth(this.code);
|
||||||
final String code;
|
final String code;
|
||||||
@@ -165,17 +229,19 @@ class _HangingAuth extends MockFirebaseAuth {
|
|||||||
/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다.
|
/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다.
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class _LinkingUser extends MockUser {
|
class _LinkingUser extends MockUser {
|
||||||
_LinkingUser({required this.linkedEmail})
|
_LinkingUser({required this.linkedEmail, this.error})
|
||||||
: super(
|
: super(
|
||||||
uid: 'u-1',
|
uid: 'u-1',
|
||||||
email: 'fan@fanuzu.co.kr',
|
email: 'fan@fanuzu.co.kr',
|
||||||
providerData: [_info('google.com', 'fan@fanuzu.co.kr')],
|
providerData: [_info('google.com', 'fan@fanuzu.co.kr')],
|
||||||
);
|
);
|
||||||
|
|
||||||
final String linkedEmail;
|
final String? linkedEmail;
|
||||||
|
final fb.FirebaseAuthException? error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<fb.UserCredential> linkWithProvider(fb.AuthProvider provider) async {
|
Future<fb.UserCredential> linkWithProvider(fb.AuthProvider provider) async {
|
||||||
|
if (error case final e?) throw e;
|
||||||
final result = await super.linkWithProvider(provider);
|
final result = await super.linkWithProvider(provider);
|
||||||
providerData
|
providerData
|
||||||
..removeLast()
|
..removeLast()
|
||||||
@@ -184,7 +250,7 @@ class _LinkingUser extends MockUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fb.UserInfo _info(String providerId, String email) =>
|
fb.UserInfo _info(String providerId, String? email) =>
|
||||||
// ignore: invalid_use_of_protected_member
|
// ignore: invalid_use_of_protected_member
|
||||||
fb.UserInfo.fromJson({
|
fb.UserInfo.fromJson({
|
||||||
'uid': 'p-$providerId',
|
'uid': 'p-$providerId',
|
||||||
|
|||||||
Reference in New Issue
Block a user