chore: initial release
This commit is contained in:
146
lib/core/auth/api_auth_interceptor.dart
Normal file
146
lib/core/auth/api_auth_interceptor.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Consistent authentication interceptor for all API requests
|
||||
/// Implements security requirements from OpenAPI specification
|
||||
class ApiAuthInterceptor extends Interceptor {
|
||||
String? _authToken;
|
||||
|
||||
// Callbacks for auth events
|
||||
void Function()? onAuthTokenInvalid;
|
||||
Future<void> Function()? onTokenInvalidated;
|
||||
|
||||
// Public endpoints that don't require authentication
|
||||
static const Set<String> _publicEndpoints = {
|
||||
'/health',
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signup',
|
||||
'/api/v1/auths/signup/enabled',
|
||||
'/api/v1/auths/trusted-header-auth',
|
||||
'/ollama/api/ps',
|
||||
'/ollama/api/version',
|
||||
'/docs',
|
||||
'/openapi.json',
|
||||
'/swagger',
|
||||
'/api/docs',
|
||||
};
|
||||
|
||||
// Endpoints that have optional authentication (work without but better with)
|
||||
static const Set<String> _optionalAuthEndpoints = {
|
||||
'/api/models',
|
||||
'/api/v1/configs/models',
|
||||
};
|
||||
|
||||
ApiAuthInterceptor({
|
||||
String? authToken,
|
||||
this.onAuthTokenInvalid,
|
||||
this.onTokenInvalidated,
|
||||
}) : _authToken = authToken;
|
||||
|
||||
void updateAuthToken(String? token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
String? get authToken => _authToken;
|
||||
|
||||
/// Check if endpoint requires authentication based on OpenAPI spec
|
||||
bool _requiresAuth(String path) {
|
||||
// Direct public endpoint match
|
||||
if (_publicEndpoints.contains(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for partial matches (e.g., /ollama/* endpoints)
|
||||
for (final publicPattern in _publicEndpoints) {
|
||||
if (publicPattern.endsWith('*') &&
|
||||
path.startsWith(
|
||||
publicPattern.substring(0, publicPattern.length - 1),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All other endpoints require authentication per OpenAPI spec
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if endpoint is better with auth but works without
|
||||
bool _hasOptionalAuth(String path) {
|
||||
return _optionalAuthEndpoints.contains(path);
|
||||
}
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final path = options.path;
|
||||
final requiresAuth = _requiresAuth(path);
|
||||
final hasOptionalAuth = _hasOptionalAuth(path);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Auth interceptor for $path - requires: $requiresAuth, optional: $hasOptionalAuth, token present: ${_authToken != null}',
|
||||
);
|
||||
|
||||
if (requiresAuth) {
|
||||
// Strictly required authentication
|
||||
if (_authToken == null || _authToken!.isEmpty) {
|
||||
final error = DioException(
|
||||
requestOptions: options,
|
||||
response: Response(
|
||||
requestOptions: options,
|
||||
statusCode: 401,
|
||||
data: {'detail': 'Authentication required for this endpoint'},
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
handler.reject(error);
|
||||
return;
|
||||
}
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
} else if (hasOptionalAuth &&
|
||||
_authToken != null &&
|
||||
_authToken!.isNotEmpty) {
|
||||
// Optional authentication - add if available
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
}
|
||||
|
||||
// Add other common headers for API consistency
|
||||
options.headers['Content-Type'] ??= 'application/json';
|
||||
options.headers['Accept'] ??= 'application/json';
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final statusCode = err.response?.statusCode;
|
||||
final path = err.requestOptions.path;
|
||||
|
||||
// Handle authentication errors consistently
|
||||
if (statusCode == 401) {
|
||||
// 401 always indicates invalid/expired auth token
|
||||
debugPrint('DEBUG: 401 Unauthorized on $path - clearing auth token');
|
||||
_clearAuthToken();
|
||||
} else if (statusCode == 403) {
|
||||
// 403 on protected endpoints indicates insufficient permissions or invalid token
|
||||
final requiresAuth = _requiresAuth(path);
|
||||
final optionalAuth = _hasOptionalAuth(path);
|
||||
if (requiresAuth && !optionalAuth) {
|
||||
debugPrint(
|
||||
'DEBUG: 403 Forbidden on protected endpoint $path - clearing auth token',
|
||||
);
|
||||
_clearAuthToken();
|
||||
} else {
|
||||
debugPrint(
|
||||
'DEBUG: 403 Forbidden on public/optional endpoint $path - keeping auth token',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _clearAuthToken() {
|
||||
_authToken = null;
|
||||
onAuthTokenInvalid?.call();
|
||||
onTokenInvalidated?.call();
|
||||
}
|
||||
}
|
||||
194
lib/core/auth/auth_cache_manager.dart
Normal file
194
lib/core/auth/auth_cache_manager.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'auth_state_manager.dart';
|
||||
|
||||
/// Comprehensive caching manager for auth-related operations
|
||||
/// Reduces redundant operations and improves app performance
|
||||
class AuthCacheManager {
|
||||
static final AuthCacheManager _instance = AuthCacheManager._internal();
|
||||
factory AuthCacheManager() => _instance;
|
||||
AuthCacheManager._internal();
|
||||
|
||||
// Cache for various auth-related operations
|
||||
final Map<String, dynamic> _cache = {};
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
|
||||
// Cache timeouts for different types of data
|
||||
static const Duration _shortCache = Duration(
|
||||
minutes: 2,
|
||||
); // For frequently changing data
|
||||
static const Duration _mediumCache = Duration(
|
||||
minutes: 5,
|
||||
); // For moderately stable data
|
||||
static const Duration _longCache = Duration(minutes: 15); // For stable data
|
||||
|
||||
// Cache keys
|
||||
static const String _userDataKey = 'user_data';
|
||||
static const String _serverConnectionKey = 'server_connection';
|
||||
static const String _credentialsExistKey = 'credentials_exist';
|
||||
static const String _serverConfigsKey = 'server_configs';
|
||||
|
||||
/// Cache user data with medium timeout
|
||||
void cacheUserData(dynamic userData) {
|
||||
_cache[_userDataKey] = userData;
|
||||
_cacheTimestamps[_userDataKey] = DateTime.now();
|
||||
debugPrint('DEBUG: User data cached');
|
||||
}
|
||||
|
||||
/// Get cached user data
|
||||
dynamic getCachedUserData() {
|
||||
if (_isCacheValid(_userDataKey, _mediumCache)) {
|
||||
debugPrint('DEBUG: Using cached user data');
|
||||
return _cache[_userDataKey];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Cache server connection status with short timeout
|
||||
void cacheServerConnection(bool isConnected) {
|
||||
_cache[_serverConnectionKey] = isConnected;
|
||||
_cacheTimestamps[_serverConnectionKey] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get cached server connection status
|
||||
bool? getCachedServerConnection() {
|
||||
if (_isCacheValid(_serverConnectionKey, _shortCache)) {
|
||||
return _cache[_serverConnectionKey] as bool?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Cache credentials existence with medium timeout
|
||||
void cacheCredentialsExist(bool exist) {
|
||||
_cache[_credentialsExistKey] = exist;
|
||||
_cacheTimestamps[_credentialsExistKey] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get cached credentials existence
|
||||
bool? getCachedCredentialsExist() {
|
||||
if (_isCacheValid(_credentialsExistKey, _mediumCache)) {
|
||||
return _cache[_credentialsExistKey] as bool?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Cache server configurations with long timeout
|
||||
void cacheServerConfigs(List<dynamic> configs) {
|
||||
_cache[_serverConfigsKey] = configs;
|
||||
_cacheTimestamps[_serverConfigsKey] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get cached server configurations
|
||||
List<dynamic>? getCachedServerConfigs() {
|
||||
if (_isCacheValid(_serverConfigsKey, _longCache)) {
|
||||
return _cache[_serverConfigsKey] as List<dynamic>?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if cache entry is valid
|
||||
bool _isCacheValid(String key, Duration timeout) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return false;
|
||||
|
||||
return DateTime.now().difference(timestamp) < timeout;
|
||||
}
|
||||
|
||||
/// Clear specific cache entry
|
||||
void clearCacheEntry(String key) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
debugPrint('DEBUG: Cache entry cleared: $key');
|
||||
}
|
||||
|
||||
/// Clear all auth-related cache
|
||||
void clearAuthCache() {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
debugPrint('DEBUG: All auth cache cleared');
|
||||
}
|
||||
|
||||
/// Clear expired cache entries
|
||||
void cleanExpiredCache() {
|
||||
final now = DateTime.now();
|
||||
final expiredKeys = <String>[];
|
||||
|
||||
for (final entry in _cacheTimestamps.entries) {
|
||||
// Use the longest timeout for cleanup to be conservative
|
||||
if (now.difference(entry.value) > _longCache) {
|
||||
expiredKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in expiredKeys) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
if (expiredKeys.isNotEmpty) {
|
||||
debugPrint('DEBUG: Cleaned ${expiredKeys.length} expired cache entries');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics for monitoring
|
||||
Map<String, dynamic> getCacheStats() {
|
||||
final now = DateTime.now();
|
||||
final stats = <String, dynamic>{};
|
||||
|
||||
stats['totalEntries'] = _cache.length;
|
||||
stats['entries'] = <String, Map<String, dynamic>>{};
|
||||
|
||||
for (final key in _cache.keys) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp != null) {
|
||||
stats['entries'][key] = {
|
||||
'age': now.difference(timestamp).inSeconds,
|
||||
'hasData': _cache[key] != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// Optimize cache by removing least recently used entries if cache gets too large
|
||||
void optimizeCache() {
|
||||
const maxCacheSize = 20; // Reasonable limit for auth cache
|
||||
|
||||
if (_cache.length <= maxCacheSize) return;
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
final sortedEntries = _cacheTimestamps.entries.toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
|
||||
// Remove oldest entries
|
||||
final entriesToRemove = sortedEntries.length - maxCacheSize;
|
||||
for (int i = 0; i < entriesToRemove; i++) {
|
||||
final key = sortedEntries[i].key;
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Cache optimized, removed $entriesToRemove old entries');
|
||||
}
|
||||
|
||||
/// Cache state from AuthState for quick access
|
||||
void cacheAuthState(AuthState authState) {
|
||||
if (authState.user != null) {
|
||||
cacheUserData(authState.user);
|
||||
}
|
||||
|
||||
// Don't cache loading or error states
|
||||
if (authState.status == AuthStatus.authenticated) {
|
||||
_cache['auth_status'] = authState.status;
|
||||
_cacheTimestamps['auth_status'] = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached auth status
|
||||
AuthStatus? getCachedAuthStatus() {
|
||||
if (_isCacheValid('auth_status', _shortCache)) {
|
||||
return _cache['auth_status'] as AuthStatus?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
562
lib/core/auth/auth_state_manager.dart
Normal file
562
lib/core/auth/auth_state_manager.dart
Normal file
@@ -0,0 +1,562 @@
|
||||
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';
|
||||
|
||||
/// 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 StateNotifier<AuthState> {
|
||||
AuthStateManager(this._ref)
|
||||
: super(const AuthState(status: AuthStatus.initial)) {
|
||||
_initialize();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
final AuthCacheManager _cacheManager = AuthCacheManager();
|
||||
|
||||
/// 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) {
|
||||
// Validate token before setting authenticated state
|
||||
final isValid = await _validateToken(token);
|
||||
if (isValid) {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
token: token,
|
||||
isLoading: false,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
// Update API service with token
|
||||
_updateApiServiceToken(token);
|
||||
|
||||
// Load user data in background
|
||||
_loadUserData();
|
||||
} else {
|
||||
// Token is invalid, clear it
|
||||
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 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();
|
||||
|
||||
debugPrint('DEBUG: 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 {
|
||||
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']!;
|
||||
|
||||
// Set active server if needed
|
||||
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
|
||||
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 {
|
||||
debugPrint('DEBUG: 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) {
|
||||
debugPrint('DEBUG: 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,
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: 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) {
|
||||
debugPrint('DEBUG: 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);
|
||||
debugPrint('DEBUG: 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) {
|
||||
debugPrint(
|
||||
'DEBUG: 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);
|
||||
debugPrint('DEBUG: Cached user data from token validation');
|
||||
}
|
||||
|
||||
TokenValidationCache.cacheResult(token, serverResult);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: 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 =
|
||||
StateNotifierProvider<AuthStateManager, AuthState>((ref) {
|
||||
return AuthStateManager(ref);
|
||||
});
|
||||
|
||||
/// 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));
|
||||
});
|
||||
259
lib/core/auth/token_validator.dart
Normal file
259
lib/core/auth/token_validator.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// JWT token validation utilities
|
||||
class TokenValidator {
|
||||
static const Duration _validationTimeout = Duration(seconds: 5);
|
||||
|
||||
/// Validate JWT token format and expiry without network call
|
||||
static TokenValidationResult validateTokenFormat(String token) {
|
||||
try {
|
||||
// Basic format check
|
||||
if (token.isEmpty || token.length < 10) {
|
||||
return TokenValidationResult.invalid('Token too short');
|
||||
}
|
||||
|
||||
// Check if it looks like a JWT (has at least 2 dots)
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 3) {
|
||||
return TokenValidationResult.invalid('Invalid JWT format');
|
||||
}
|
||||
|
||||
// Try to decode the payload to check expiry
|
||||
try {
|
||||
final payload = _decodeJWTPayload(parts[1]);
|
||||
final exp = payload['exp'] as int?;
|
||||
|
||||
if (exp != null) {
|
||||
final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (expiryTime.isBefore(now)) {
|
||||
return TokenValidationResult.expired('Token expired');
|
||||
}
|
||||
|
||||
// Check if token expires soon (within 5 minutes)
|
||||
final fiveMinutesFromNow = now.add(const Duration(minutes: 5));
|
||||
if (expiryTime.isBefore(fiveMinutesFromNow)) {
|
||||
return TokenValidationResult.expiringSoon(
|
||||
'Token expires soon',
|
||||
expiryTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return TokenValidationResult.valid(
|
||||
'Token format valid',
|
||||
expiryData: exp != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(exp * 1000)
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
// If we can't decode JWT, treat as opaque token
|
||||
debugPrint(
|
||||
'DEBUG: Could not decode JWT payload, treating as opaque token: $e',
|
||||
);
|
||||
return TokenValidationResult.valid('Opaque token format valid');
|
||||
}
|
||||
} catch (e) {
|
||||
return TokenValidationResult.invalid('Token validation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate token with server (async with timeout)
|
||||
static Future<TokenValidationResult> validateTokenWithServer(
|
||||
String token,
|
||||
Future<dynamic> Function() serverValidationCall,
|
||||
) async {
|
||||
try {
|
||||
// First check format
|
||||
final formatResult = validateTokenFormat(token);
|
||||
if (!formatResult.isValid) {
|
||||
return formatResult;
|
||||
}
|
||||
|
||||
// If format is good, try server validation with timeout
|
||||
final validationFuture = serverValidationCall();
|
||||
|
||||
final result = await validationFuture.timeout(
|
||||
_validationTimeout,
|
||||
onTimeout: () => throw Exception('Token validation timeout'),
|
||||
);
|
||||
|
||||
return TokenValidationResult.valid(
|
||||
'Server validation successful',
|
||||
serverData: result,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.toString().contains('timeout')) {
|
||||
return TokenValidationResult.networkError(
|
||||
'Validation timeout - using cached result',
|
||||
);
|
||||
} else if (e.toString().contains('401') || e.toString().contains('403')) {
|
||||
return TokenValidationResult.invalid('Server rejected token');
|
||||
} else {
|
||||
return TokenValidationResult.networkError(
|
||||
'Network error during validation: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode JWT payload (without signature verification)
|
||||
static Map<String, dynamic> _decodeJWTPayload(String base64Payload) {
|
||||
// Add padding if needed
|
||||
String padded = base64Payload;
|
||||
while (padded.length % 4 != 0) {
|
||||
padded += '=';
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
final decoded = base64Url.decode(padded);
|
||||
final jsonString = utf8.decode(decoded);
|
||||
|
||||
return jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Extract user information from JWT token (if available)
|
||||
static Map<String, dynamic>? extractUserInfo(String token) {
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
final payload = _decodeJWTPayload(parts[1]);
|
||||
|
||||
// Extract common user fields
|
||||
return {
|
||||
'sub': payload['sub'], // Subject (user ID)
|
||||
'username':
|
||||
payload['username'] ??
|
||||
payload['name'] ??
|
||||
payload['preferred_username'],
|
||||
'email': payload['email'],
|
||||
'roles': payload['roles'] ?? payload['groups'],
|
||||
'exp': payload['exp'],
|
||||
'iat': payload['iat'], // Issued at
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Could not extract user info from token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a cache key for token validation results
|
||||
static String generateCacheKey(String token) {
|
||||
final bytes = utf8.encode(token);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString().substring(
|
||||
0,
|
||||
16,
|
||||
); // Use first 16 chars as cache key
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of token validation
|
||||
class TokenValidationResult {
|
||||
const TokenValidationResult._(
|
||||
this.isValid,
|
||||
this.status,
|
||||
this.message, {
|
||||
this.expiryData,
|
||||
this.serverData,
|
||||
});
|
||||
|
||||
const TokenValidationResult.valid(
|
||||
String message, {
|
||||
DateTime? expiryData,
|
||||
dynamic serverData,
|
||||
}) : this._(
|
||||
true,
|
||||
TokenValidationStatus.valid,
|
||||
message,
|
||||
expiryData: expiryData,
|
||||
serverData: serverData,
|
||||
);
|
||||
|
||||
const TokenValidationResult.invalid(String message)
|
||||
: this._(false, TokenValidationStatus.invalid, message);
|
||||
|
||||
const TokenValidationResult.expired(String message)
|
||||
: this._(false, TokenValidationStatus.expired, message);
|
||||
|
||||
const TokenValidationResult.expiringSoon(String message, DateTime expiryTime)
|
||||
: this._(
|
||||
true,
|
||||
TokenValidationStatus.expiringSoon,
|
||||
message,
|
||||
expiryData: expiryTime,
|
||||
);
|
||||
|
||||
const TokenValidationResult.networkError(String message)
|
||||
: this._(false, TokenValidationStatus.networkError, message);
|
||||
|
||||
final bool isValid;
|
||||
final TokenValidationStatus status;
|
||||
final String message;
|
||||
final DateTime? expiryData;
|
||||
final dynamic serverData;
|
||||
|
||||
bool get isExpired => status == TokenValidationStatus.expired;
|
||||
bool get isExpiringSoon => status == TokenValidationStatus.expiringSoon;
|
||||
bool get hasNetworkError => status == TokenValidationStatus.networkError;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TokenValidationResult(isValid: $isValid, status: $status, message: $message)';
|
||||
}
|
||||
|
||||
enum TokenValidationStatus {
|
||||
valid,
|
||||
invalid,
|
||||
expired,
|
||||
expiringSoon,
|
||||
networkError,
|
||||
}
|
||||
|
||||
/// Cache for token validation results
|
||||
class TokenValidationCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
||||
|
||||
static void cacheResult(String token, TokenValidationResult result) {
|
||||
final key = TokenValidator.generateCacheKey(token);
|
||||
_cache[key] = _CacheEntry(result, DateTime.now());
|
||||
|
||||
// Clean old entries
|
||||
_cleanCache();
|
||||
}
|
||||
|
||||
static TokenValidationResult? getCachedResult(String token) {
|
||||
final key = TokenValidator.generateCacheKey(token);
|
||||
final entry = _cache[key];
|
||||
|
||||
if (entry != null &&
|
||||
DateTime.now().difference(entry.timestamp) < _cacheTimeout) {
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
static void _cleanCache() {
|
||||
final now = DateTime.now();
|
||||
_cache.removeWhere(
|
||||
(key, entry) => now.difference(entry.timestamp) > _cacheTimeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
const _CacheEntry(this.result, this.timestamp);
|
||||
|
||||
final TokenValidationResult result;
|
||||
final DateTime timestamp;
|
||||
}
|
||||
Reference in New Issue
Block a user