721 lines
22 KiB
Dart
721 lines
22 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
// Types are used through app_providers.dart
|
|
import '../providers/app_providers.dart';
|
|
import '../models/user.dart';
|
|
import 'token_validator.dart';
|
|
import 'auth_cache_manager.dart';
|
|
import '../utils/debug_logger.dart';
|
|
|
|
/// Comprehensive auth state representation
|
|
@immutable
|
|
class AuthState {
|
|
const AuthState({
|
|
required this.status,
|
|
this.token,
|
|
this.user,
|
|
this.error,
|
|
this.isLoading = false,
|
|
});
|
|
|
|
final AuthStatus status;
|
|
final String? token;
|
|
final dynamic user; // Replace with proper User type
|
|
final String? error;
|
|
final bool isLoading;
|
|
|
|
bool get isAuthenticated =>
|
|
status == AuthStatus.authenticated && token != null;
|
|
bool get hasValidToken => token != null && token!.isNotEmpty;
|
|
bool get needsLogin =>
|
|
status == AuthStatus.unauthenticated || status == AuthStatus.tokenExpired;
|
|
|
|
AuthState copyWith({
|
|
AuthStatus? status,
|
|
String? token,
|
|
dynamic user,
|
|
String? error,
|
|
bool? isLoading,
|
|
bool clearToken = false,
|
|
bool clearUser = false,
|
|
bool clearError = false,
|
|
}) {
|
|
return AuthState(
|
|
status: status ?? this.status,
|
|
token: clearToken ? null : (token ?? this.token),
|
|
user: clearUser ? null : (user ?? this.user),
|
|
error: clearError ? null : (error ?? this.error),
|
|
isLoading: isLoading ?? this.isLoading,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
if (identical(this, other)) return true;
|
|
return other is AuthState &&
|
|
other.status == status &&
|
|
other.token == token &&
|
|
other.user == user &&
|
|
other.error == error &&
|
|
other.isLoading == isLoading;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(status, token, user, error, isLoading);
|
|
|
|
@override
|
|
String toString() =>
|
|
'AuthState(status: $status, hasToken: ${token != null}, hasUser: ${user != null}, error: $error, isLoading: $isLoading)';
|
|
}
|
|
|
|
enum AuthStatus {
|
|
initial,
|
|
loading,
|
|
authenticated,
|
|
unauthenticated,
|
|
tokenExpired,
|
|
error,
|
|
}
|
|
|
|
/// Unified auth state manager - single source of truth for all auth operations
|
|
class AuthStateManager extends Notifier<AuthState> {
|
|
final AuthCacheManager _cacheManager = AuthCacheManager();
|
|
// Prevent overlapping silent-login attempts from multiple triggers
|
|
Future<bool>? _silentLoginFuture;
|
|
bool _initialized = false;
|
|
|
|
@override
|
|
AuthState build() {
|
|
if (!_initialized) {
|
|
_initialized = true;
|
|
Future.microtask(_initialize);
|
|
}
|
|
|
|
return const AuthState(status: AuthStatus.initial);
|
|
}
|
|
|
|
/// Initialize auth state from storage
|
|
Future<void> _initialize() async {
|
|
state = state.copyWith(status: AuthStatus.loading, isLoading: true);
|
|
|
|
try {
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
final token = await storage.getAuthToken();
|
|
|
|
if (token != null && token.isNotEmpty) {
|
|
DebugLogger.auth('Found stored token during initialization');
|
|
// Fast path: trust token format to avoid blocking startup on network
|
|
final formatOk = _isValidTokenFormat(token);
|
|
if (formatOk) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
token: token,
|
|
isLoading: false,
|
|
clearError: true,
|
|
);
|
|
|
|
// Update API service with token and load user data in background
|
|
_updateApiServiceToken(token);
|
|
_loadUserData();
|
|
|
|
// Background server validation; if it fails, invalidate token gracefully
|
|
Future.microtask(() async {
|
|
try {
|
|
final ok = await _validateToken(token);
|
|
DebugLogger.auth('Deferred token validation result: $ok');
|
|
if (!ok) {
|
|
await onTokenInvalidated();
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
} else {
|
|
// Token format invalid; clear and require login
|
|
DebugLogger.auth('Token format invalid, deleting token');
|
|
await storage.deleteAuthToken();
|
|
state = state.copyWith(
|
|
status: AuthStatus.unauthenticated,
|
|
isLoading: false,
|
|
clearToken: true,
|
|
clearError: true,
|
|
);
|
|
}
|
|
} else {
|
|
state = state.copyWith(
|
|
status: AuthStatus.unauthenticated,
|
|
isLoading: false,
|
|
clearToken: true,
|
|
clearError: true,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('ERROR: Auth initialization failed: $e');
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
error: 'Failed to initialize auth: $e',
|
|
isLoading: false,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Perform login with API key
|
|
Future<bool> loginWithApiKey(
|
|
String apiKey, {
|
|
bool rememberCredentials = false,
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
isLoading: true,
|
|
clearError: true,
|
|
);
|
|
|
|
try {
|
|
// Validate API key format
|
|
if (apiKey.trim().isEmpty) {
|
|
throw Exception('API key cannot be empty');
|
|
}
|
|
|
|
// Ensure API service is available
|
|
await _ensureApiServiceAvailable();
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api == null) {
|
|
throw Exception('No server connection available');
|
|
}
|
|
|
|
// Use API key directly as Bearer token
|
|
final tokenStr = apiKey.trim();
|
|
|
|
// Validate token format (consistent with credentials method)
|
|
if (!_isValidTokenFormat(tokenStr)) {
|
|
throw Exception('Invalid API key format');
|
|
}
|
|
|
|
// Update API service with the API key
|
|
_updateApiServiceToken(tokenStr);
|
|
|
|
// Validate by attempting to fetch user info
|
|
try {
|
|
await api.getCurrentUser(); // Just validate, don't store user data yet
|
|
|
|
// Save token to storage
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
await storage.saveAuthToken(tokenStr);
|
|
|
|
// Save API key if requested (for convenience, though less secure than credentials)
|
|
if (rememberCredentials) {
|
|
final activeServer = await ref.read(activeServerProvider.future);
|
|
if (activeServer != null) {
|
|
// Store API key as a special credential type
|
|
await storage.saveCredentials(
|
|
serverId: activeServer.id,
|
|
username:
|
|
'api_key_user', // Special username to indicate API key auth
|
|
password: tokenStr, // Store API key in password field
|
|
);
|
|
await storage.setRememberCredentials(true);
|
|
}
|
|
}
|
|
|
|
// Update state (without user data initially)
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
token: tokenStr,
|
|
isLoading: false,
|
|
clearError: true,
|
|
);
|
|
|
|
// Update API service with token
|
|
_updateApiServiceToken(tokenStr);
|
|
|
|
// Cache the successful auth state
|
|
_cacheManager.cacheAuthState(state);
|
|
|
|
// Load user data in background (consistent with credentials method)
|
|
_loadUserData();
|
|
|
|
DebugLogger.auth('API key login successful');
|
|
return true;
|
|
} catch (e) {
|
|
// If user fetch fails, the API key might be invalid
|
|
throw Exception('Invalid API key or insufficient permissions');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('ERROR: API key login failed: $e');
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
error: e.toString(),
|
|
isLoading: false,
|
|
clearToken: true,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Perform login with credentials
|
|
Future<bool> login(
|
|
String username,
|
|
String password, {
|
|
bool rememberCredentials = false,
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
isLoading: true,
|
|
clearError: true,
|
|
);
|
|
|
|
try {
|
|
// Ensure API service is available (active server/provider rebuild race)
|
|
await _ensureApiServiceAvailable();
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api == null) {
|
|
throw Exception('No server connection available');
|
|
}
|
|
|
|
// Perform login API call
|
|
final response = await api.login(username, password);
|
|
|
|
// Extract and validate token
|
|
final token = response['token'] ?? response['access_token'];
|
|
if (token == null || token.toString().trim().isEmpty) {
|
|
throw Exception('No authentication token received');
|
|
}
|
|
|
|
final tokenStr = token.toString();
|
|
if (!_isValidTokenFormat(tokenStr)) {
|
|
throw Exception('Invalid authentication token format');
|
|
}
|
|
|
|
// Save token to storage
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
await storage.saveAuthToken(tokenStr);
|
|
|
|
// Save credentials if requested
|
|
if (rememberCredentials) {
|
|
final activeServer = await ref.read(activeServerProvider.future);
|
|
if (activeServer != null) {
|
|
await storage.saveCredentials(
|
|
serverId: activeServer.id,
|
|
username: username,
|
|
password: password,
|
|
);
|
|
await storage.setRememberCredentials(true);
|
|
}
|
|
}
|
|
|
|
// Update state and API service
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
token: tokenStr,
|
|
isLoading: false,
|
|
clearError: true,
|
|
);
|
|
|
|
_updateApiServiceToken(tokenStr);
|
|
|
|
// Cache the successful auth state
|
|
_cacheManager.cacheAuthState(state);
|
|
|
|
// Load user data in background
|
|
_loadUserData();
|
|
|
|
DebugLogger.auth('Login successful');
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('ERROR: Login failed: $e');
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
error: e.toString(),
|
|
isLoading: false,
|
|
clearToken: true,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Wait briefly until the API service becomes available
|
|
Future<void> _ensureApiServiceAvailable({
|
|
Duration timeout = const Duration(seconds: 2),
|
|
}) async {
|
|
final end = DateTime.now().add(timeout);
|
|
while (DateTime.now().isBefore(end)) {
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api != null) return;
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
}
|
|
}
|
|
|
|
/// Perform silent auto-login with saved credentials
|
|
Future<bool> silentLogin() async {
|
|
// Coalesce concurrent calls (e.g., UI + interceptor retry)
|
|
if (_silentLoginFuture != null) {
|
|
return await _silentLoginFuture!;
|
|
}
|
|
final thisAttempt = _performSilentLogin();
|
|
_silentLoginFuture = thisAttempt;
|
|
try {
|
|
return await thisAttempt;
|
|
} finally {
|
|
if (identical(_silentLoginFuture, thisAttempt)) {
|
|
_silentLoginFuture = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _performSilentLogin() async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
isLoading: true,
|
|
clearError: true,
|
|
);
|
|
|
|
try {
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
final savedCredentials = await storage.getSavedCredentials();
|
|
|
|
if (savedCredentials == null) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.unauthenticated,
|
|
isLoading: false,
|
|
clearError: true,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
final serverId = savedCredentials['serverId']!;
|
|
final username = savedCredentials['username']!;
|
|
final password = savedCredentials['password']!;
|
|
|
|
// Ensure the saved server still exists before switching
|
|
final serverConfigs = await ref.read(serverConfigsProvider.future);
|
|
final hasServer = serverConfigs.any((config) => config.id == serverId);
|
|
|
|
if (!hasServer) {
|
|
await storage.deleteSavedCredentials();
|
|
await storage.setActiveServerId(null);
|
|
ref.invalidate(serverConfigsProvider);
|
|
ref.invalidate(activeServerProvider);
|
|
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
error:
|
|
'Saved server configuration is no longer available. Please reconnect.',
|
|
isLoading: false,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Set active server once we know it exists
|
|
await storage.setActiveServerId(serverId);
|
|
ref.invalidate(activeServerProvider);
|
|
|
|
// Wait for server connection
|
|
final activeServer = await ref.read(activeServerProvider.future);
|
|
if (activeServer == null) {
|
|
await storage.setActiveServerId(null);
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
error: 'Server configuration not found',
|
|
isLoading: false,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Attempt login (detect API key vs normal credentials)
|
|
if (username == 'api_key_user') {
|
|
// This is a saved API key
|
|
return await loginWithApiKey(password, rememberCredentials: false);
|
|
} else {
|
|
// Normal username/password credentials
|
|
return await login(username, password, rememberCredentials: false);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('ERROR: Silent login failed: $e');
|
|
|
|
// Clear invalid credentials on auth errors
|
|
if (e.toString().contains('401') ||
|
|
e.toString().contains('403') ||
|
|
e.toString().contains('authentication') ||
|
|
e.toString().contains('unauthorized')) {
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
await storage.deleteSavedCredentials();
|
|
}
|
|
|
|
state = state.copyWith(
|
|
status: AuthStatus.unauthenticated,
|
|
error: e.toString(),
|
|
isLoading: false,
|
|
clearToken: true,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Handle token invalidation (called by API service)
|
|
Future<void> onTokenInvalidated() async {
|
|
// Avoid spamming logs if multiple requests invalidate at once
|
|
final reloginInProgress = _silentLoginFuture != null;
|
|
if (!reloginInProgress) {
|
|
DebugLogger.auth('Auth token invalidated');
|
|
}
|
|
|
|
// Clear token from storage
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
await storage.deleteAuthToken();
|
|
|
|
// Update state
|
|
state = state.copyWith(
|
|
status: AuthStatus.tokenExpired,
|
|
clearToken: true,
|
|
clearUser: true,
|
|
clearError: true,
|
|
);
|
|
|
|
// Attempt silent re-login if credentials are available
|
|
final hasCredentials = await storage.getSavedCredentials() != null;
|
|
if (hasCredentials) {
|
|
if (!reloginInProgress) {
|
|
DebugLogger.auth('Attempting silent re-login after token invalidation');
|
|
}
|
|
await silentLogin();
|
|
}
|
|
}
|
|
|
|
/// Logout user
|
|
Future<void> logout() async {
|
|
state = state.copyWith(status: AuthStatus.loading, isLoading: true);
|
|
|
|
try {
|
|
// Call server logout if possible
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api != null) {
|
|
try {
|
|
await api.logout();
|
|
} catch (e) {
|
|
debugPrint('Warning: Server logout failed: $e');
|
|
}
|
|
}
|
|
|
|
// Clear all local auth data
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
await storage.clearAuthData();
|
|
|
|
// Update state
|
|
state = state.copyWith(
|
|
status: AuthStatus.unauthenticated,
|
|
isLoading: false,
|
|
clearToken: true,
|
|
clearUser: true,
|
|
clearError: true,
|
|
);
|
|
|
|
DebugLogger.auth('Logout complete');
|
|
} catch (e) {
|
|
debugPrint('ERROR: Logout failed: $e');
|
|
// Even if logout fails, clear local state
|
|
state = state.copyWith(
|
|
status: AuthStatus.unauthenticated,
|
|
isLoading: false,
|
|
clearToken: true,
|
|
clearUser: true,
|
|
error: 'Logout error: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Load user data in background with JWT extraction fallback
|
|
Future<void> _loadUserData() async {
|
|
try {
|
|
// First try to extract user info from JWT token if available
|
|
if (state.token != null) {
|
|
final jwtUserInfo = TokenValidator.extractUserInfo(state.token!);
|
|
if (jwtUserInfo != null) {
|
|
DebugLogger.auth('Extracted user info from JWT token');
|
|
state = state.copyWith(user: jwtUserInfo);
|
|
|
|
// Still try to load from server in background for complete data
|
|
Future.microtask(() => _loadServerUserData());
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fall back to server data loading
|
|
await _loadServerUserData();
|
|
} catch (e) {
|
|
debugPrint('Warning: Failed to load user data: $e');
|
|
// Don't update state on user data load failure
|
|
}
|
|
}
|
|
|
|
/// Load complete user data from server
|
|
Future<void> _loadServerUserData() async {
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api != null && state.isAuthenticated) {
|
|
// Check if we already have user data from token validation
|
|
if (state.user != null) {
|
|
debugPrint(
|
|
'DEBUG: User data already available from token validation',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final user = await api.getCurrentUser();
|
|
state = state.copyWith(user: user);
|
|
DebugLogger.auth('Loaded complete user data from server');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Warning: Failed to load server user data: $e');
|
|
// Don't update state on server data load failure - keep JWT data if available
|
|
}
|
|
}
|
|
|
|
/// Update API service with current token
|
|
void _updateApiServiceToken(String token) {
|
|
final api = ref.read(apiServiceProvider);
|
|
api?.updateAuthToken(token);
|
|
}
|
|
|
|
/// Validate token format using advanced validation
|
|
bool _isValidTokenFormat(String token) {
|
|
final result = TokenValidator.validateTokenFormat(token);
|
|
return result.isValid;
|
|
}
|
|
|
|
/// Validate token with comprehensive validation (format + server)
|
|
Future<bool> _validateToken(String token) async {
|
|
// Check cache first
|
|
final cachedResult = TokenValidationCache.getCachedResult(token);
|
|
if (cachedResult != null) {
|
|
DebugLogger.auth(
|
|
'Using cached token validation result: ${cachedResult.isValid}',
|
|
);
|
|
return cachedResult.isValid;
|
|
}
|
|
|
|
// Fast format validation first
|
|
final formatResult = TokenValidator.validateTokenFormat(token);
|
|
if (!formatResult.isValid) {
|
|
debugPrint('DEBUG: Token format invalid: ${formatResult.message}');
|
|
TokenValidationCache.cacheResult(token, formatResult);
|
|
return false;
|
|
}
|
|
|
|
// If format is valid but token is expiring soon, try server validation
|
|
if (formatResult.isExpiringSoon) {
|
|
debugPrint('DEBUG: Token expiring soon, validating with server');
|
|
}
|
|
|
|
// Server validation (async with timeout)
|
|
try {
|
|
final api = ref.read(apiServiceProvider);
|
|
if (api == null) {
|
|
debugPrint('DEBUG: No API service available for token validation');
|
|
return formatResult.isValid; // Fall back to format validation
|
|
}
|
|
|
|
User? validationUser;
|
|
final serverResult = await TokenValidator.validateTokenWithServer(
|
|
token,
|
|
() async {
|
|
// Update API with token for validation
|
|
api.updateAuthToken(token);
|
|
// Try to fetch user data as validation
|
|
validationUser = await api.getCurrentUser();
|
|
return validationUser!;
|
|
},
|
|
);
|
|
|
|
// Store the user data if validation was successful
|
|
if (serverResult.isValid &&
|
|
validationUser != null &&
|
|
state.isAuthenticated) {
|
|
state = state.copyWith(user: validationUser);
|
|
DebugLogger.auth('Cached user data from token validation');
|
|
}
|
|
|
|
TokenValidationCache.cacheResult(token, serverResult);
|
|
|
|
DebugLogger.auth(
|
|
'Server token validation: ${serverResult.isValid} - ${serverResult.message}',
|
|
);
|
|
return serverResult.isValid;
|
|
} catch (e) {
|
|
debugPrint('DEBUG: Token server validation failed: $e');
|
|
// On network error, fall back to format validation if it was valid
|
|
return formatResult.isValid;
|
|
}
|
|
}
|
|
|
|
/// Check if user has saved credentials (with caching)
|
|
Future<bool> hasSavedCredentials() async {
|
|
// Check cache first
|
|
final cachedResult = _cacheManager.getCachedCredentialsExist();
|
|
if (cachedResult != null) {
|
|
return cachedResult;
|
|
}
|
|
|
|
try {
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
final hasCredentials = await storage.hasCredentials();
|
|
|
|
// Cache the result
|
|
_cacheManager.cacheCredentialsExist(hasCredentials);
|
|
|
|
return hasCredentials;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Refresh current auth state
|
|
Future<void> refresh() async {
|
|
// Clear cache before refresh to ensure fresh data
|
|
_cacheManager.clearAuthCache();
|
|
TokenValidationCache.clearCache();
|
|
|
|
await _initialize();
|
|
}
|
|
|
|
/// Clean up expired caches (called periodically)
|
|
void cleanupCaches() {
|
|
_cacheManager.cleanExpiredCache();
|
|
_cacheManager.optimizeCache();
|
|
}
|
|
|
|
/// Get performance statistics
|
|
Map<String, dynamic> getPerformanceStats() {
|
|
return {
|
|
'authCache': _cacheManager.getCacheStats(),
|
|
'tokenValidationCache': 'Managed by TokenValidationCache',
|
|
'storageCache': 'Managed by OptimizedStorageService',
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Provider for the unified auth state manager
|
|
final authStateManagerProvider = NotifierProvider<AuthStateManager, AuthState>(
|
|
AuthStateManager.new,
|
|
);
|
|
|
|
/// Computed providers for common auth state queries
|
|
final isAuthenticatedProvider = Provider<bool>((ref) {
|
|
return ref.watch(
|
|
authStateManagerProvider.select((state) => state.isAuthenticated),
|
|
);
|
|
});
|
|
|
|
final authTokenProvider2 = Provider<String?>((ref) {
|
|
return ref.watch(authStateManagerProvider.select((state) => state.token));
|
|
});
|
|
|
|
final authUserProvider = Provider<dynamic>((ref) {
|
|
return ref.watch(authStateManagerProvider.select((state) => state.user));
|
|
});
|
|
|
|
final authErrorProvider2 = Provider<String?>((ref) {
|
|
return ref.watch(authStateManagerProvider.select((state) => state.error));
|
|
});
|
|
|
|
final isAuthLoadingProvider = Provider<bool>((ref) {
|
|
return ref.watch(authStateManagerProvider.select((state) => state.isLoading));
|
|
});
|