1
0
forked from baron/baron-sso

세션 IP 표시와 로그아웃 처리 보강

This commit is contained in:
2026-04-06 13:25:36 +09:00
parent 6a3bb19e7d
commit 2ca26cafb2
11 changed files with 292 additions and 63 deletions

View File

@@ -264,6 +264,41 @@ class AuthProxyService {
}
}
static Future<String?> fetchCurrentSessionId() async {
final url = Uri.parse('$_baseUrl/api/v1/user/sessions');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(url, headers: headers);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
'활성 세션을 불러오지 못했습니다: {{error}}',
detail: response.body,
);
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? const [];
for (final item in items.whereType<Map<String, dynamic>>()) {
if (item['is_current'] == true) {
final sessionId = item['session_id']?.toString().trim() ?? '';
if (sessionId.isNotEmpty) {
return sessionId;
}
}
}
return null;
} finally {
client.close();
}
}
static Future<Map<String, dynamic>> verifyLoginShortCode(
String shortCode, {
bool verifyOnly = false,

View File

@@ -0,0 +1,39 @@
import '../notifiers/auth_notifier.dart';
import 'auth_proxy_service.dart';
import 'auth_token_store.dart';
typedef CurrentSessionLoader = Future<String?> Function();
typedef SessionRevoker = Future<void> Function(String sessionId);
typedef LogoutCallback = void Function();
class LogoutService {
LogoutService({
CurrentSessionLoader? loadCurrentSessionId,
SessionRevoker? revokeSession,
LogoutCallback? clearAuth,
LogoutCallback? notifyAuthChanged,
}) : _loadCurrentSessionId =
loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId,
_revokeSession = revokeSession ?? AuthProxyService.revokeSession,
_clearAuth = clearAuth ?? AuthTokenStore.clear,
_notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify;
final CurrentSessionLoader _loadCurrentSessionId;
final SessionRevoker _revokeSession;
final LogoutCallback _clearAuth;
final LogoutCallback _notifyAuthChanged;
Future<void> logout() async {
try {
final currentSessionId = await _loadCurrentSessionId();
if (currentSessionId != null && currentSessionId.isNotEmpty) {
await _revokeSession(currentSessionId);
}
} catch (_) {
// 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다.
} finally {
_clearAuth();
_notifyAuthChanged();
}
}
}

View File

@@ -12,6 +12,7 @@ import '../domain/providers/linked_rps_provider.dart';
import '../domain/providers/user_sessions_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/logout_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
@@ -74,8 +75,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Future<void> _logout() async {
AuthTokenStore.clear();
AuthNotifier.instance.notify();
await LogoutService().logout();
}
Future<void> _onRevokeLink(String clientId, String appName) async {

View File

@@ -6,6 +6,7 @@ import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/logout_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
@@ -164,8 +165,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
Future<void> _logout() async {
AuthTokenStore.clear();
AuthNotifier.instance.notify();
await LogoutService().logout();
}
void _ensureControllers(UserProfile profile) {

View File

@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/services/logout_service.dart';
void main() {
test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async {
final events = <String>[];
final service = LogoutService(
loadCurrentSessionId: () async {
events.add('load');
return 'current-sid';
},
revokeSession: (sessionId) async {
events.add('revoke:$sessionId');
},
clearAuth: () {
events.add('clear');
},
notifyAuthChanged: () {
events.add('notify');
},
);
await service.logout();
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
});
test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async {
final events = <String>[];
final service = LogoutService(
loadCurrentSessionId: () async {
events.add('load');
return null;
},
revokeSession: (sessionId) async {
events.add('revoke:$sessionId');
},
clearAuth: () {
events.add('clear');
},
notifyAuthChanged: () {
events.add('notify');
},
);
await service.logout();
expect(events, ['load', 'clear', 'notify']);
});
test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async {
final events = <String>[];
final service = LogoutService(
loadCurrentSessionId: () async {
events.add('load');
return 'current-sid';
},
revokeSession: (sessionId) async {
events.add('revoke:$sessionId');
throw Exception('revoke failed');
},
clearAuth: () {
events.add('clear');
},
notifyAuthChanged: () {
events.add('notify');
},
);
await service.logout();
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
});
}