LINE 로그인을 LINE SDK와 서버 인증 토큰 방식으로 변경 — iOS·Android 웹 로그인 중단 문제 해결
This commit is contained in:
@@ -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