From 7c1dbaf2065e808e5ac913bc6216f8c3c34ec034 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 3 Mar 2026 14:11:19 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B1=8C?= =?UTF-8?q?=EB=A6=B0=EC=A7=80=20=EB=A3=A8=ED=94=84=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/login_challenge_loop_guard.dart | 5 ++ .../login_challenge_loop_guard_base.dart | 5 ++ .../login_challenge_loop_guard_stub.dart | 37 ++++++++++ .../login_challenge_loop_guard_web.dart | 69 +++++++++++++++++++ .../auth/presentation/login_screen.dart | 22 +++++- .../test/login_challenge_loop_guard_test.dart | 33 +++++++++ 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 userfront/lib/core/services/login_challenge_loop_guard.dart create mode 100644 userfront/lib/core/services/login_challenge_loop_guard_base.dart create mode 100644 userfront/lib/core/services/login_challenge_loop_guard_stub.dart create mode 100644 userfront/lib/core/services/login_challenge_loop_guard_web.dart create mode 100644 userfront/test/login_challenge_loop_guard_test.dart diff --git a/userfront/lib/core/services/login_challenge_loop_guard.dart b/userfront/lib/core/services/login_challenge_loop_guard.dart new file mode 100644 index 00000000..d3a6e3d0 --- /dev/null +++ b/userfront/lib/core/services/login_challenge_loop_guard.dart @@ -0,0 +1,5 @@ +import 'login_challenge_loop_guard_base.dart'; +import 'login_challenge_loop_guard_stub.dart' + if (dart.library.js_interop) 'login_challenge_loop_guard_web.dart'; + +final loginChallengeLoopGuard = createLoginChallengeLoopGuard(); diff --git a/userfront/lib/core/services/login_challenge_loop_guard_base.dart b/userfront/lib/core/services/login_challenge_loop_guard_base.dart new file mode 100644 index 00000000..24f42ebe --- /dev/null +++ b/userfront/lib/core/services/login_challenge_loop_guard_base.dart @@ -0,0 +1,5 @@ +abstract class LoginChallengeLoopGuard { + bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}); + void markAutoAcceptAttempt(String loginChallenge); + void clear(String loginChallenge); +} diff --git a/userfront/lib/core/services/login_challenge_loop_guard_stub.dart b/userfront/lib/core/services/login_challenge_loop_guard_stub.dart new file mode 100644 index 00000000..f7429456 --- /dev/null +++ b/userfront/lib/core/services/login_challenge_loop_guard_stub.dart @@ -0,0 +1,37 @@ +import 'login_challenge_loop_guard_base.dart'; + +class _InMemoryLoginChallengeLoopGuard implements LoginChallengeLoopGuard { + final Map _lastAttemptAtMs = {}; + + @override + bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) { + final challenge = loginChallenge.trim(); + if (challenge.isEmpty) { + return false; + } + final nowMs = DateTime.now().millisecondsSinceEpoch; + final lastMs = _lastAttemptAtMs[challenge]; + if (lastMs == null) { + return true; + } + return nowMs - lastMs > cooldownMs; + } + + @override + void markAutoAcceptAttempt(String loginChallenge) { + final challenge = loginChallenge.trim(); + if (challenge.isEmpty) { + return; + } + _lastAttemptAtMs[challenge] = DateTime.now().millisecondsSinceEpoch; + } + + @override + void clear(String loginChallenge) { + _lastAttemptAtMs.remove(loginChallenge.trim()); + } +} + +LoginChallengeLoopGuard createLoginChallengeLoopGuard() { + return _InMemoryLoginChallengeLoopGuard(); +} diff --git a/userfront/lib/core/services/login_challenge_loop_guard_web.dart b/userfront/lib/core/services/login_challenge_loop_guard_web.dart new file mode 100644 index 00000000..0b338e60 --- /dev/null +++ b/userfront/lib/core/services/login_challenge_loop_guard_web.dart @@ -0,0 +1,69 @@ +// ignore_for_file: avoid_web_libraries_in_flutter + +import 'dart:js_interop'; +import 'login_challenge_loop_guard_base.dart'; + +@JS('window.sessionStorage') +external _JSStorage get _sessionStorage; + +@JS() +extension type _JSStorage(JSObject _) implements JSObject { + external String? getItem(String key); + external void setItem(String key, String value); + external void removeItem(String key); +} + +class _WebLoginChallengeLoopGuard implements LoginChallengeLoopGuard { + static const String _keyPrefix = 'baron_oidc_auto_accept_last:'; + + String _key(String challenge) => '$_keyPrefix$challenge'; + + @override + bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) { + final challenge = loginChallenge.trim(); + if (challenge.isEmpty) { + return false; + } + try { + final raw = _sessionStorage.getItem(_key(challenge)); + if (raw == null || raw.isEmpty) { + return true; + } + final lastMs = int.tryParse(raw); + if (lastMs == null) { + return true; + } + final nowMs = DateTime.now().millisecondsSinceEpoch; + return nowMs - lastMs > cooldownMs; + } catch (_) { + return true; + } + } + + @override + void markAutoAcceptAttempt(String loginChallenge) { + final challenge = loginChallenge.trim(); + if (challenge.isEmpty) { + return; + } + try { + final nowMs = DateTime.now().millisecondsSinceEpoch; + _sessionStorage.setItem(_key(challenge), nowMs.toString()); + } catch (_) {} + } + + @override + void clear(String loginChallenge) { + final challenge = loginChallenge.trim(); + if (challenge.isEmpty) { + return; + } + try { + _sessionStorage.removeItem(_key(challenge)); + } catch (_) {} + } +} + +LoginChallengeLoopGuard createLoginChallengeLoopGuard() { + return _WebLoginChallengeLoopGuard(); +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index e1af67f8..906b7ba5 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -9,6 +9,7 @@ import '../../../core/widgets/language_selector.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; +import '../../../core/services/login_challenge_loop_guard.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/oidc_redirect_guard.dart'; import '../../../core/notifiers/auth_notifier.dart'; @@ -143,7 +144,11 @@ class _LoginScreenState extends ConsumerState if (!_verificationOnly) { await _attemptOidcAutoAccept(); if (!mounted) return; - await _tryCookieSession(); + // login_challenge 흐름에서는 auto-accept에서 이미 쿠키 세션까지 확인하므로 + // 동일 프레임에서 중복 체크를 피합니다. + if (!_hasLoginChallenge) { + await _tryCookieSession(); + } } }); } @@ -239,11 +244,19 @@ class _LoginScreenState extends ConsumerState if (loginChallenge == null || loginChallenge.isEmpty) { return; } + if (!loginChallengeLoopGuard.shouldAllowAutoAccept(loginChallenge)) { + debugPrint( + "[Auth] OIDC auto-accept blocked by loop guard for login_challenge", + ); + return; + } + loginChallengeLoopGuard.markAutoAcceptAttempt(loginChallenge); final token = AuthTokenStore.getToken(); if (token != null && token.isNotEmpty) { final accepted = await _acceptOidcLoginAndRedirect(token: token); if (accepted) { + loginChallengeLoopGuard.clear(loginChallenge); return; } } @@ -255,7 +268,11 @@ class _LoginScreenState extends ConsumerState AuthTokenStore.setCookieMode( provider: AuthTokenStore.getProvider() ?? 'ory', ); - await _acceptOidcLoginAndRedirect(); + final accepted = await _acceptOidcLoginAndRedirect(); + if (accepted) { + loginChallengeLoopGuard.clear(loginChallenge); + return; + } } else { debugPrint( "[Auth] OIDC auto-accept: No active session (status: $status)", @@ -1216,6 +1233,7 @@ class _LoginScreenState extends ConsumerState final nextRedirectTo = res['redirectTo'] as String?; if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) { + loginChallengeLoopGuard.clear(loginChallenge); webWindow.redirectTo(nextRedirectTo); // Removed await return; } else {} diff --git a/userfront/test/login_challenge_loop_guard_test.dart b/userfront/test/login_challenge_loop_guard_test.dart new file mode 100644 index 00000000..5f16eba8 --- /dev/null +++ b/userfront/test/login_challenge_loop_guard_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/login_challenge_loop_guard.dart'; + +void main() { + group('login_challenge_loop_guard', () { + test('mark 이후 cooldown 내 재시도는 차단되고 clear 후 허용된다', () { + const challenge = 'loop-guard-test-challenge'; + loginChallengeLoopGuard.clear(challenge); + + expect( + loginChallengeLoopGuard.shouldAllowAutoAccept(challenge), + isTrue, + ); + + loginChallengeLoopGuard.markAutoAcceptAttempt(challenge); + + expect( + loginChallengeLoopGuard.shouldAllowAutoAccept( + challenge, + cooldownMs: 60000, + ), + isFalse, + ); + + loginChallengeLoopGuard.clear(challenge); + + expect( + loginChallengeLoopGuard.shouldAllowAutoAccept(challenge), + isTrue, + ); + }); + }); +}