1
0
forked from baron/baron-sso

테스트 로직 강화, hydra 연동 데이터 용량 제한 완화.

This commit is contained in:
Lectom C Han
2026-02-06 17:21:53 +09:00
parent d3072aceca
commit 3a6ae4948a
4 changed files with 661 additions and 465 deletions

View File

@@ -0,0 +1,238 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import 'models.dart';
String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
Future<List<LinkedRp>> _fetchLinkedRps() async {
final items = await AuthProxyService.fetchLinkedRps();
final result = <LinkedRp>[];
for (final item in items) {
if (item is Map) {
result.add(LinkedRp.fromJson(Map<String, dynamic>.from(item)));
}
}
return result;
}
Future<List<RpHistoryItem>> _fetchRpHistory() async {
final url = Uri.parse('$_baseUrl/api/v1/user/rp/history');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.get(url, headers: headers);
if (response.statusCode != 200) {
throw Exception('Failed to load rp history');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
final result = <RpHistoryItem>[];
for (final item in items) {
if (item is Map) {
result.add(RpHistoryItem.fromJson(Map<String, dynamic>.from(item)));
}
}
return result;
} finally {
client.close();
}
}
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
final queryParameters = <String, String>{
'limit': '20',
};
if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor;
}
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline')
.replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.get(url, headers: headers);
if (response.statusCode != 200) {
throw Exception('Failed to load audit logs');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
final nextCursor = body['next_cursor']?.toString();
final logs = <AuditLogEntry>[];
for (final item in items) {
if (item is Map) {
logs.add(AuditLogEntry.fromJson(Map<String, dynamic>.from(item)));
}
}
return AuditPage(items: logs, nextCursor: nextCursor);
} finally {
client.close();
}
}
final linkedRpsProvider = FutureProvider<List<LinkedRp>>((ref) async {
return _fetchLinkedRps();
});
final rpHistoryProvider = FutureProvider<List<RpHistoryItem>>((ref) async {
return _fetchRpHistory();
});
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
return _fetchAuthTimelinePage;
});
class AuthTimelineState {
final List<AuditLogEntry> items;
final String? nextCursor;
final bool isLoading;
final bool isLoadingMore;
final String? error;
const AuthTimelineState({
required this.items,
this.nextCursor,
this.isLoading = false,
this.isLoadingMore = false,
this.error,
});
const AuthTimelineState.initial()
: items = const [],
nextCursor = null,
isLoading = false,
isLoadingMore = false,
error = null;
AuthTimelineState copyWith({
List<AuditLogEntry>? items,
String? nextCursor,
bool? isLoading,
bool? isLoadingMore,
String? error,
}) {
return AuthTimelineState(
items: items ?? this.items,
nextCursor: nextCursor ?? this.nextCursor,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
error: error,
);
}
}
class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
late final AuthTimelineFetcher _fetchPage;
bool _hasLoaded = false;
@override
AuthTimelineState build() {
_fetchPage = ref.watch(authTimelineFetcherProvider);
if (!_hasLoaded) {
_hasLoaded = true;
Future.microtask(_loadInitial);
}
return const AuthTimelineState.initial();
}
Future<void> refresh() async {
if (state.isLoading) {
return;
}
state = state.copyWith(
items: const [],
nextCursor: null,
isLoading: true,
error: null,
);
await _loadPage(reset: true);
}
Future<void> loadMore() async {
if (state.isLoading || state.isLoadingMore) {
return;
}
if (state.nextCursor == null || state.nextCursor!.isEmpty) {
return;
}
state = state.copyWith(isLoadingMore: true, error: null);
await _loadPage(reset: false);
}
Future<void> _loadInitial() async {
if (state.items.isNotEmpty || state.isLoading) {
return;
}
state = state.copyWith(isLoading: true, error: null);
await _loadPage(reset: true);
}
Future<void> _loadPage({required bool reset}) async {
try {
final page = await _fetchPage(cursor: reset ? null : state.nextCursor);
if (reset) {
state = state.copyWith(
items: page.items,
nextCursor: page.nextCursor,
isLoading: false,
isLoadingMore: false,
error: null,
);
} else {
state = state.copyWith(
items: [...state.items, ...page.items],
nextCursor: page.nextCursor,
isLoading: false,
isLoadingMore: false,
error: null,
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
isLoadingMore: false,
error: '접속이력을 불러오지 못했습니다.',
);
}
}
}
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new,
);

View File

@@ -0,0 +1,172 @@
import 'dart:convert';
class AuditLogEntry {
final String eventId;
final DateTime timestamp;
final String userId;
final String eventType;
final String status;
final String authMethod;
final String ipAddress;
final String userAgent;
final String sessionId;
final String details;
final String source;
final String clientId;
final String appName;
final String parentSessionId;
AuditLogEntry({
required this.eventId,
required this.timestamp,
required this.userId,
required this.eventType,
required this.status,
required this.authMethod,
required this.ipAddress,
required this.userAgent,
required this.sessionId,
required this.details,
required this.source,
required this.clientId,
required this.appName,
required this.parentSessionId,
});
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
final timestampRaw = json['timestamp']?.toString() ?? '';
DateTime parsedTimestamp;
try {
parsedTimestamp = DateTime.parse(timestampRaw).toLocal();
} catch (_) {
parsedTimestamp = DateTime.now();
}
return AuditLogEntry(
eventId: json['event_id'] ?? '',
timestamp: parsedTimestamp,
userId: json['user_id'] ?? '',
eventType: json['event_type'] ?? '',
status: json['status'] ?? '',
authMethod: json['auth_method'] ?? '',
ipAddress: json['ip_address'] ?? '',
userAgent: json['user_agent'] ?? '',
sessionId: json['session_id'] ?? '',
details: json['details'] ?? '',
source: json['source'] ?? '',
clientId: json['client_id'] ?? '',
appName: json['app_name'] ?? '',
parentSessionId: json['parent_session_id'] ?? '',
);
}
Map<String, dynamic> get detailMap {
if (details.isEmpty) {
return {};
}
try {
return jsonDecode(details) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
String get path {
final detailPath = detailMap['path']?.toString();
if (detailPath != null && detailPath.isNotEmpty) {
return detailPath;
}
final parts = eventType.split(' ');
if (parts.length >= 2) {
return parts.sublist(1).join(' ');
}
return '-';
}
}
class AuditPage {
final List<AuditLogEntry> items;
final String? nextCursor;
const AuditPage({required this.items, this.nextCursor});
}
class LinkedRp {
final String id;
final String name;
final String logo;
final String url;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
LinkedRp({
required this.id,
required this.name,
required this.logo,
required this.url,
required this.status,
required this.scopes,
this.lastAuthenticatedAt,
});
factory LinkedRp.fromJson(Map<String, dynamic> json) {
DateTime? parsedLastAuth;
final rawLastAuth = json['lastAuthenticatedAt']?.toString();
if (rawLastAuth != null && rawLastAuth.isNotEmpty) {
try {
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
} catch (_) {
parsedLastAuth = null;
}
}
return LinkedRp(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
status: json['status']?.toString() ?? '',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,
);
}
}
class RpHistoryItem {
final String clientId;
final String clientName;
final List<String> scopes;
final DateTime? lastApprovedAt;
final DateTime? lastRevokedAt;
final String status;
RpHistoryItem({
required this.clientId,
required this.clientName,
required this.scopes,
this.lastApprovedAt,
this.lastRevokedAt,
required this.status,
});
factory RpHistoryItem.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(String? raw) {
if (raw == null || raw.isEmpty) return null;
try {
return DateTime.parse(raw).toLocal();
} catch (_) {
return null;
}
}
return RpHistoryItem(
clientId: json['client_id']?.toString() ?? '',
clientName: json['client_name']?.toString() ?? '',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastApprovedAt: parseDate(json['last_approved_at']?.toString()),
lastRevokedAt: parseDate(json['last_revoked_at']?.toString()),
status: json['status']?.toString() ?? 'unknown',
);
}
}