LINE 로그인을 LINE SDK와 서버 인증 토큰 방식으로 변경 — iOS·Android 웹 로그인 중단 문제 해결
This commit is contained in:
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
import 'auth.dart';
|
||||
import 'firebase_account_linker.dart';
|
||||
import 'social_providers.dart';
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
||||
@@ -45,9 +44,7 @@ class AuthRepository extends ChangeNotifier {
|
||||
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
|
||||
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
|
||||
///
|
||||
/// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC
|
||||
/// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를
|
||||
/// 돌려준다.
|
||||
/// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다.
|
||||
static List<SocialAuthProvider> _defaultProviders() => [
|
||||
const GoogleSignInProvider(),
|
||||
// Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 —
|
||||
@@ -63,18 +60,8 @@ class AuthRepository extends ChangeNotifier {
|
||||
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱
|
||||
// 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다.
|
||||
KakaoSignInProvider(),
|
||||
// LINE 동의 항목: profile·email(이메일 권한 신청 승인 후).
|
||||
OidcSignInProvider(
|
||||
AuthProviderKind.line,
|
||||
providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!,
|
||||
scopes: const ['profile', 'email'],
|
||||
// LINE 앱 자동 로그인으로 넘어가면 콜백이 웹 로그인 창 밖에서 열려 로그인이
|
||||
// 깨진다(Android · iOS). LINE Login 공식 파라미터로 끈다.
|
||||
appParameters: const {
|
||||
'disable_auto_login': 'true',
|
||||
'disable_ios_auto_login': 'true',
|
||||
},
|
||||
),
|
||||
// LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다).
|
||||
LineSignInProvider(),
|
||||
];
|
||||
|
||||
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
|
||||
|
||||
@@ -27,7 +27,7 @@ final class FirebaseAccountLinker implements AccountLinker {
|
||||
AuthProviderKind.apple: 'apple.com',
|
||||
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
|
||||
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
|
||||
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다.
|
||||
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao · fnAuthLine)가 같은 ID 로 연결을 남긴다.
|
||||
AuthProviderKind.kakao: 'oidc.kakao',
|
||||
AuthProviderKind.line: 'oidc.line',
|
||||
AuthProviderKind.email: 'password',
|
||||
@@ -58,12 +58,14 @@ final class FirebaseAccountLinker implements AccountLinker {
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
if (impl is! CredentialProvider &&
|
||||
impl is! OidcSignInProvider &&
|
||||
impl is! KakaoSignInProvider) {
|
||||
impl is! KakaoSignInProvider &&
|
||||
impl is! LineSignInProvider) {
|
||||
return AuthNotConfigured(provider, const ['자격증명 구현']);
|
||||
}
|
||||
|
||||
try {
|
||||
if (impl is KakaoSignInProvider) return await impl.link(user);
|
||||
if (impl is LineSignInProvider) return await impl.link(user);
|
||||
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
|
||||
final source = impl as CredentialProvider;
|
||||
final obtained = await source.obtainCredential();
|
||||
@@ -114,7 +116,7 @@ final class FirebaseAccountLinker implements AccountLinker {
|
||||
}
|
||||
}
|
||||
|
||||
/// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
||||
/// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
||||
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
||||
final result = await retryStaleWebContext(
|
||||
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
||||
import 'package:flutter_line_sdk/flutter_line_sdk.dart' as line;
|
||||
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao;
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
||||
|
||||
@@ -203,8 +204,9 @@ final class AppleSignInProvider
|
||||
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
||||
}
|
||||
|
||||
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
|
||||
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
||||
/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이
|
||||
/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀
|
||||
/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
|
||||
///
|
||||
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
|
||||
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
|
||||
@@ -224,7 +226,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
this.provider, {
|
||||
required this.providerId,
|
||||
this.scopes = const [],
|
||||
this.appParameters = const {},
|
||||
this.firebaseAuth,
|
||||
});
|
||||
|
||||
@@ -239,12 +240,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
/// KOE205 로 로그인 자체를 막는다.
|
||||
final List<String> scopes;
|
||||
|
||||
/// 앱(Android · iOS)에서 인가 URL 에 붙일 파라미터. 웹 로그인 도중 제공자 앱(LINE 등)으로
|
||||
/// 넘어갔다 돌아오면 콜백이 원래 브라우저 세션 밖에서 열려 Firebase 핸들러가
|
||||
/// 「missing initial state」로 끝난다(Android 10/5 · iOS 10/11 실기기) — 앱 전환을 막는
|
||||
/// 제공자 공식 파라미터를 여기 둔다.
|
||||
final Map<String, String> appParameters;
|
||||
|
||||
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||
final fb.FirebaseAuth? firebaseAuth;
|
||||
|
||||
@@ -260,7 +255,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
||||
for (final scope in scopes) {
|
||||
p.addScope(scope);
|
||||
}
|
||||
if (!kIsWeb) p.setCustomParameters(appParameters);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -405,17 +399,7 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
||||
debugPrint('[KakaoSignInProvider] sdk error: $error');
|
||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||
} on FirebaseFunctionsException catch (error) {
|
||||
final details = error.details;
|
||||
final code = details is Map && details['code'] is String
|
||||
? details['code'] as String
|
||||
: 'E-AUTH-03';
|
||||
return AuthFailed(code, switch (code) {
|
||||
_ when details is Map && details['reason'] == 'session' =>
|
||||
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
||||
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||
});
|
||||
return _serverFailure(error, flow);
|
||||
} on fb.FirebaseAuthException catch (error) {
|
||||
return AuthFailed(
|
||||
authCatalogCode(error, flow: flow),
|
||||
@@ -437,6 +421,158 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다.
|
||||
AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) {
|
||||
final details = error.details;
|
||||
final code = details is Map && details['code'] is String
|
||||
? details['code'] as String
|
||||
: 'E-AUTH-03';
|
||||
return AuthFailed(code, switch (code) {
|
||||
_ when details is Map && details['reason'] == 'session' =>
|
||||
'보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||
'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||
'E-AUTH-09' => '이미 연결된 계정입니다.',
|
||||
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다.
|
||||
const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID');
|
||||
|
||||
/// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다.
|
||||
typedef LineTokens = ({String? idToken, String accessToken});
|
||||
|
||||
/// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다.
|
||||
///
|
||||
/// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다
|
||||
/// (`disable_ios_auto_login` 을 붙여도 같다). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로 로그인해 받은
|
||||
/// 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다.
|
||||
///
|
||||
/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고,
|
||||
/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
|
||||
///
|
||||
/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에
|
||||
/// 오고, 서버가 그때만 남긴다. 없어도 로그인 · 연결이 된다.
|
||||
final class LineSignInProvider implements SocialAuthProvider {
|
||||
LineSignInProvider({
|
||||
Future<LineTokens> Function()? login,
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
|
||||
exchange,
|
||||
this.firebaseAuth,
|
||||
this.channelId = lineChannelId,
|
||||
}) : _login = login ?? _sdkLogin,
|
||||
_exchange = exchange ?? _callServer;
|
||||
|
||||
final Future<LineTokens> Function() _login;
|
||||
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
|
||||
|
||||
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
|
||||
final fb.FirebaseAuth? firebaseAuth;
|
||||
final String channelId;
|
||||
|
||||
static Future<void>? _setup;
|
||||
|
||||
/// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled).
|
||||
static const _cancelCodes = {'CANCEL', '3003'};
|
||||
|
||||
@override
|
||||
SocialProvider get provider => SocialProvider.line;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => [
|
||||
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
||||
if (channelId.isEmpty) 'LINE 채널 ID',
|
||||
];
|
||||
|
||||
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
|
||||
|
||||
static Future<LineTokens> _sdkLogin() async {
|
||||
await (_setup ??= line.LineSDK.instance.setup(lineChannelId));
|
||||
final result = await line.LineSDK.instance.login(
|
||||
scopes: const ['profile', 'openid', 'email'],
|
||||
);
|
||||
return (
|
||||
idToken: result.accessToken.idTokenRaw,
|
||||
accessToken: result.accessToken.value,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _callServer(
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final result = await FirebaseFunctions.instanceFor(
|
||||
region: Backend.region,
|
||||
).httpsCallable('fnAuthLine').call<Object?>(payload);
|
||||
final data = result.data;
|
||||
return data is Map ? Map<String, dynamic>.from(data) : const {};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _tokens() async {
|
||||
final t = await _login();
|
||||
return {
|
||||
if (t.idToken != null) 'idToken': t.idToken,
|
||||
'accessToken': t.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
|
||||
final data = await _exchange(await _tokens());
|
||||
final result = await _auth.signInWithCustomToken(data['token'] as String);
|
||||
return AuthSucceeded(_toAccount(result.user, provider));
|
||||
});
|
||||
|
||||
/// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는
|
||||
/// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다.
|
||||
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
|
||||
await _exchange({...await _tokens(), 'link': true});
|
||||
await user.reload();
|
||||
return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider));
|
||||
});
|
||||
|
||||
Future<AuthOutcome> _run(
|
||||
AuthFlow flow,
|
||||
Future<AuthOutcome> Function() body,
|
||||
) async {
|
||||
final missing = missingConfig;
|
||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||
try {
|
||||
return await body();
|
||||
} on PlatformException catch (error) {
|
||||
if (_cancelCodes.contains(error.code)) return const AuthCancelled();
|
||||
debugPrint(
|
||||
'[LineSignInProvider] sdk failed: ${error.code} ${error.message}',
|
||||
);
|
||||
return AuthFailed(
|
||||
'E-AUTH-03',
|
||||
flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||
);
|
||||
} on FirebaseFunctionsException catch (error) {
|
||||
return _serverFailure(error, flow);
|
||||
} on fb.FirebaseAuthException catch (error) {
|
||||
return AuthFailed(
|
||||
authCatalogCode(error, flow: flow),
|
||||
authMessage(error, flow: flow),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
// SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다.
|
||||
if (_setup == null) return;
|
||||
try {
|
||||
await line.LineSDK.instance.logout();
|
||||
} on Object catch (error) {
|
||||
// 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다.
|
||||
debugPrint('[LineSignInProvider] logout: ${error.runtimeType}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
||||
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
|
||||
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
||||
@@ -676,8 +812,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
||||
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
||||
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
||||
'account-exists-with-different-credential' => switch (flow) {
|
||||
AuthFlow.signIn || AuthFlow.email =>
|
||||
_otherProviderSignInGuide(error.email),
|
||||
AuthFlow.signIn ||
|
||||
AuthFlow.email => _otherProviderSignInGuide(error.email),
|
||||
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||
},
|
||||
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
||||
|
||||
Reference in New Issue
Block a user