forked from baron/baron-sso
로그인 챌린지 루프 방지 가드 추가
This commit is contained in:
@@ -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();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
abstract class LoginChallengeLoopGuard {
|
||||||
|
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000});
|
||||||
|
void markAutoAcceptAttempt(String loginChallenge);
|
||||||
|
void clear(String loginChallenge);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'login_challenge_loop_guard_base.dart';
|
||||||
|
|
||||||
|
class _InMemoryLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
|
||||||
|
final Map<String, int> _lastAttemptAtMs = <String, int>{};
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../../../core/widgets/language_selector.dart';
|
|||||||
import '../../../core/services/web_auth_integration.dart';
|
import '../../../core/services/web_auth_integration.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../core/services/auth_token_store.dart';
|
import '../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../core/services/login_challenge_loop_guard.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/oidc_redirect_guard.dart';
|
import '../../../core/services/oidc_redirect_guard.dart';
|
||||||
import '../../../core/notifiers/auth_notifier.dart';
|
import '../../../core/notifiers/auth_notifier.dart';
|
||||||
@@ -143,7 +144,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (!_verificationOnly) {
|
if (!_verificationOnly) {
|
||||||
await _attemptOidcAutoAccept();
|
await _attemptOidcAutoAccept();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _tryCookieSession();
|
// login_challenge 흐름에서는 auto-accept에서 이미 쿠키 세션까지 확인하므로
|
||||||
|
// 동일 프레임에서 중복 체크를 피합니다.
|
||||||
|
if (!_hasLoginChallenge) {
|
||||||
|
await _tryCookieSession();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,11 +244,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (loginChallenge == null || loginChallenge.isEmpty) {
|
if (loginChallenge == null || loginChallenge.isEmpty) {
|
||||||
return;
|
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();
|
final token = AuthTokenStore.getToken();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
final accepted = await _acceptOidcLoginAndRedirect(token: token);
|
final accepted = await _acceptOidcLoginAndRedirect(token: token);
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
|
loginChallengeLoopGuard.clear(loginChallenge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +268,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
AuthTokenStore.setCookieMode(
|
AuthTokenStore.setCookieMode(
|
||||||
provider: AuthTokenStore.getProvider() ?? 'ory',
|
provider: AuthTokenStore.getProvider() ?? 'ory',
|
||||||
);
|
);
|
||||||
await _acceptOidcLoginAndRedirect();
|
final accepted = await _acceptOidcLoginAndRedirect();
|
||||||
|
if (accepted) {
|
||||||
|
loginChallengeLoopGuard.clear(loginChallenge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"[Auth] OIDC auto-accept: No active session (status: $status)",
|
"[Auth] OIDC auto-accept: No active session (status: $status)",
|
||||||
@@ -1216,6 +1233,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final nextRedirectTo = res['redirectTo'] as String?;
|
final nextRedirectTo = res['redirectTo'] as String?;
|
||||||
|
|
||||||
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
|
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
|
||||||
|
loginChallengeLoopGuard.clear(loginChallenge);
|
||||||
webWindow.redirectTo(nextRedirectTo); // Removed await
|
webWindow.redirectTo(nextRedirectTo); // Removed await
|
||||||
return;
|
return;
|
||||||
} else {}
|
} else {}
|
||||||
|
|||||||
33
userfront/test/login_challenge_loop_guard_test.dart
Normal file
33
userfront/test/login_challenge_loop_guard_test.dart
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user