첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'models.dart';
|
||||
|
||||
String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
final nextCursor = state.nextCursor;
|
||||
if (nextCursor == null || 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: tr('msg.userfront.dashboard.timeline.load_error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final authTimelineProvider =
|
||||
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||
AuthTimelineNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'providers/linked_rps_provider.dart';
|
||||
|
||||
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
||||
final normalizedStatus = rp.status.trim().toLowerCase();
|
||||
final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active';
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rp.autoLoginSupported) {
|
||||
final autoLoginUrl = rp.autoLoginUrl.trim();
|
||||
if (autoLoginUrl.isNotEmpty) {
|
||||
return autoLoginUrl;
|
||||
}
|
||||
final initUrl = rp.initUrl.trim();
|
||||
if (initUrl.isNotEmpty) {
|
||||
return initUrl;
|
||||
}
|
||||
}
|
||||
|
||||
final url = rp.url.trim();
|
||||
if (url.isNotEmpty) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
237
baron-sso/userfront/lib/features/dashboard/domain/models.dart
Normal file
237
baron-sso/userfront/lib/features/dashboard/domain/models.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
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 initUrl;
|
||||
final bool autoLoginSupported;
|
||||
final String autoLoginUrl;
|
||||
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.initUrl,
|
||||
required this.autoLoginSupported,
|
||||
required this.autoLoginUrl,
|
||||
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() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
autoLoginSupported: json['auto_login_supported'] == true,
|
||||
autoLoginUrl: json['auto_login_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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSessionSummary {
|
||||
final String sessionId;
|
||||
final DateTime? authenticatedAt;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? issuedAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final bool isCurrent;
|
||||
final bool isActive;
|
||||
|
||||
UserSessionSummary({
|
||||
required this.sessionId,
|
||||
this.authenticatedAt,
|
||||
this.expiresAt,
|
||||
this.issuedAt,
|
||||
this.lastSeenAt,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.isCurrent,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory UserSessionSummary.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(dynamic raw) {
|
||||
final value = raw?.toString();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return UserSessionSummary(
|
||||
sessionId: json['session_id']?.toString() ?? '',
|
||||
authenticatedAt: parseDate(json['authenticated_at']),
|
||||
expiresAt: parseDate(json['expires_at']),
|
||||
issuedAt: parseDate(json['issued_at']),
|
||||
lastSeenAt: parseDate(json['last_seen_at']),
|
||||
ipAddress: json['ip_address']?.toString() ?? '',
|
||||
userAgent: json['user_agent']?.toString() ?? '',
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
appName: json['app_name']?.toString() ?? '',
|
||||
isCurrent: json['is_current'] == true,
|
||||
isActive: json['is_active'] != false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
import 'package:userfront/core/services/http_client.dart';
|
||||
import 'package:userfront/core/services/runtime_env.dart';
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final bool autoLoginSupported;
|
||||
final String autoLoginUrl;
|
||||
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.initUrl,
|
||||
required this.autoLoginSupported,
|
||||
required this.autoLoginUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
|
||||
DateTime? parsedLastAuth;
|
||||
if (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() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
autoLoginSupported: json['auto_login_supported'] == true,
|
||||
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
@override
|
||||
Future<List<LinkedRp>> build() async {
|
||||
return _fetchLinkedRps();
|
||||
}
|
||||
|
||||
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
try {
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load linked rps: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => _fetchLinkedRps());
|
||||
}
|
||||
|
||||
Future<void> revokeRp(String clientId) async {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final linkedRpsProvider =
|
||||
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||
return LinkedRpsNotifier();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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 '../../../../core/services/runtime_env.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
|
||||
@override
|
||||
Future<List<UserSessionSummary>> build() async {
|
||||
return _fetchSessions();
|
||||
}
|
||||
|
||||
Future<List<UserSessionSummary>> _fetchSessions() async {
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/sessions');
|
||||
|
||||
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 sessions: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserSessionSummary.fromJson)
|
||||
.toList();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(_fetchSessions);
|
||||
}
|
||||
|
||||
Future<void> revokeSession(String sessionId) async {
|
||||
await AuthProxyService.revokeSession(sessionId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final userSessionsProvider =
|
||||
AsyncNotifierProvider<UserSessionsNotifier, List<UserSessionSummary>>(() {
|
||||
return UserSessionsNotifier();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../profile/data/models/user_profile_model.dart';
|
||||
|
||||
DateTime? resolveDashboardSessionIssuedAt({
|
||||
String? token,
|
||||
UserProfile? profile,
|
||||
}) {
|
||||
final tokenIssuedAt = _getJwtIssuedAt(token);
|
||||
if (tokenIssuedAt != null) {
|
||||
return tokenIssuedAt;
|
||||
}
|
||||
return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt);
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt(String? token) {
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
final iatValue = data['iat'] ?? data['auth_time'];
|
||||
if (iatValue is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
iatValue.toInt() * 1000,
|
||||
).toLocal();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseSessionAuthenticatedAt(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||
|
||||
const headlessServerUserAgentSentinel = '__headless_server__';
|
||||
|
||||
bool looksLikeInternalAuditUserAgent(String userAgent) {
|
||||
final lower = userAgent.trim().toLowerCase();
|
||||
return lower.startsWith('go-http-client/') ||
|
||||
lower.startsWith('fasthttp') ||
|
||||
lower.startsWith('fiber') ||
|
||||
lower.startsWith('undici') ||
|
||||
lower.startsWith('node');
|
||||
}
|
||||
|
||||
String preferredAuditLogUserAgent(AuditLogEntry log) {
|
||||
final userAgent = log.userAgent.trim();
|
||||
final path = log.path.toLowerCase();
|
||||
|
||||
final isHeadlessLinkLog =
|
||||
path.contains('/api/v1/auth/magic-link/verify') ||
|
||||
path.contains('/api/v1/auth/login/code/verify');
|
||||
final isHeadlessPasswordLog = path.contains(
|
||||
'/api/v1/auth/headless/password/login',
|
||||
);
|
||||
|
||||
if ((isHeadlessLinkLog || isHeadlessPasswordLog) &&
|
||||
looksLikeInternalAuditUserAgent(userAgent)) {
|
||||
return headlessServerUserAgentSentinel;
|
||||
}
|
||||
|
||||
return userAgent;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user