feat: API auth with custom headers
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
/// Implements security requirements from OpenAPI specification
|
||||
class ApiAuthInterceptor extends Interceptor {
|
||||
String? _authToken;
|
||||
final Map<String, String> customHeaders;
|
||||
|
||||
// Callbacks for auth events
|
||||
void Function()? onAuthTokenInvalid;
|
||||
@@ -35,6 +36,7 @@ class ApiAuthInterceptor extends Interceptor {
|
||||
String? authToken,
|
||||
this.onAuthTokenInvalid,
|
||||
this.onTokenInvalidated,
|
||||
this.customHeaders = const {},
|
||||
}) : _authToken = authToken;
|
||||
|
||||
void updateAuthToken(String? token) {
|
||||
@@ -102,6 +104,21 @@ class ApiAuthInterceptor extends Interceptor {
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
}
|
||||
|
||||
// Add custom headers from server config (with safety checks)
|
||||
if (customHeaders.isNotEmpty) {
|
||||
customHeaders.forEach((key, value) {
|
||||
// Don't override critical headers that we manage
|
||||
final lowerKey = key.toLowerCase();
|
||||
if (lowerKey != 'authorization' &&
|
||||
lowerKey != 'content-type' &&
|
||||
lowerKey != 'accept') {
|
||||
options.headers[key] = value;
|
||||
} else {
|
||||
debugPrint('WARNING: Skipping reserved header override attempt: $key');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add other common headers for API consistency
|
||||
options.headers['Content-Type'] ??= 'application/json';
|
||||
options.headers['Accept'] ??= 'application/json';
|
||||
|
||||
@@ -95,8 +95,10 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
final token = await storage.getAuthToken();
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
debugPrint('DEBUG: Found stored token during initialization: ${token.substring(0, 10)}...');
|
||||
// Validate token before setting authenticated state
|
||||
final isValid = await _validateToken(token);
|
||||
debugPrint('DEBUG: Token validation result: $isValid');
|
||||
if (isValid) {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
@@ -112,6 +114,7 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
_loadUserData();
|
||||
} else {
|
||||
// Token is invalid, clear it
|
||||
debugPrint('DEBUG: Token validation failed, deleting token');
|
||||
await storage.deleteAuthToken();
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
@@ -138,6 +141,98 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
debugPrint('DEBUG: 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,
|
||||
@@ -272,8 +367,14 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
return await login(username, password, rememberCredentials: 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');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:crypto/crypto.dart';
|
||||
class TokenValidator {
|
||||
static const Duration _validationTimeout = Duration(seconds: 5);
|
||||
|
||||
/// Validate JWT token format and expiry without network call
|
||||
/// Validate token format (supports both JWT and API key formats)
|
||||
static TokenValidationResult validateTokenFormat(String token) {
|
||||
try {
|
||||
// Basic format check
|
||||
@@ -14,10 +14,20 @@ class TokenValidator {
|
||||
return TokenValidationResult.invalid('Token too short');
|
||||
}
|
||||
|
||||
// Check if it's an API key format (starts with sk- or similar)
|
||||
if (token.startsWith('sk-') || token.startsWith('api-') || token.startsWith('key-')) {
|
||||
// API key format - validate differently
|
||||
if (token.length < 20) {
|
||||
return TokenValidationResult.invalid('API key too short');
|
||||
}
|
||||
return TokenValidationResult.valid('API key format valid');
|
||||
}
|
||||
|
||||
// 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');
|
||||
// Not JWT format, treat as opaque token
|
||||
return TokenValidationResult.valid('Opaque token format valid');
|
||||
}
|
||||
|
||||
// Try to decode the payload to check expiry
|
||||
|
||||
@@ -10,6 +10,7 @@ sealed class ServerConfig with _$ServerConfig {
|
||||
required String name,
|
||||
required String url,
|
||||
String? apiKey,
|
||||
@Default({}) Map<String, String> customHeaders,
|
||||
DateTime? lastConnected,
|
||||
@Default(false) bool isActive,
|
||||
}) = _ServerConfig;
|
||||
|
||||
@@ -38,13 +38,21 @@ class ApiService {
|
||||
followRedirects: true,
|
||||
maxRedirects: 5,
|
||||
validateStatus: (status) => status != null && status < 400,
|
||||
// Add custom headers from server config
|
||||
headers: serverConfig.customHeaders.isNotEmpty
|
||||
? Map<String, String>.from(serverConfig.customHeaders)
|
||||
: null,
|
||||
),
|
||||
) {
|
||||
// Use API key from server config if provided and no explicit auth token
|
||||
final effectiveAuthToken = authToken ?? serverConfig.apiKey;
|
||||
|
||||
// Initialize the consistent auth interceptor
|
||||
_authInterceptor = ApiAuthInterceptor(
|
||||
authToken: authToken,
|
||||
authToken: effectiveAuthToken,
|
||||
onAuthTokenInvalid: onAuthTokenInvalid,
|
||||
onTokenInvalidated: onTokenInvalidated,
|
||||
customHeaders: serverConfig.customHeaders,
|
||||
);
|
||||
|
||||
// Add interceptors in order of priority:
|
||||
|
||||
@@ -27,10 +27,10 @@ class InputValidationService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate URL
|
||||
/// Validate URL (enhanced version for server addresses)
|
||||
static String? validateUrl(String? value, {bool required = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'URL is required' : null;
|
||||
return required ? 'Server address is required' : null;
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
@@ -38,21 +38,58 @@ class InputValidationService {
|
||||
// Add protocol if missing
|
||||
String urlToValidate = trimmed;
|
||||
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||
urlToValidate = 'https://$trimmed';
|
||||
urlToValidate = 'http://$trimmed';
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(urlToValidate);
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return 'Please enter a valid URL';
|
||||
|
||||
// Validate scheme
|
||||
if (!uri.hasScheme || (uri.scheme != 'http' && uri.scheme != 'https')) {
|
||||
return 'Use http:// or https:// only';
|
||||
}
|
||||
|
||||
// Validate host
|
||||
if (!uri.hasAuthority || uri.host.isEmpty) {
|
||||
return 'Please enter a server address (e.g., 192.168.1.10:3000)';
|
||||
}
|
||||
|
||||
// Validate port if specified
|
||||
if (uri.hasPort) {
|
||||
if (uri.port < 1 || uri.port > 65535) {
|
||||
return 'Port must be between 1 and 65535';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate IP address format if it looks like an IP
|
||||
if (_isIPAddress(uri.host) && !_isValidIPAddress(uri.host)) {
|
||||
return 'Invalid IP address format (use 192.168.1.10)';
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
return 'Invalid server address format';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if a string looks like an IP address
|
||||
static bool _isIPAddress(String host) {
|
||||
return RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host);
|
||||
}
|
||||
|
||||
/// Validate IP address format
|
||||
static bool _isValidIPAddress(String ip) {
|
||||
final parts = ip.split('.');
|
||||
if (parts.length != 4) return false;
|
||||
|
||||
for (final part in parts) {
|
||||
final num = int.tryParse(part);
|
||||
if (num == null || num < 0 || num > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Validate password strength
|
||||
static String? validatePassword(String? value, {bool checkStrength = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
|
||||
@@ -28,22 +28,40 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
||||
Object? _error;
|
||||
StackTrace? _stackTrace;
|
||||
bool _hasError = false;
|
||||
void Function(FlutterErrorDetails details)? _previousOnError;
|
||||
|
||||
void _scheduleHandleError(Object error, StackTrace? stack) {
|
||||
// Defer to next frame to avoid setState during build exceptions
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_handleError(error, stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Set up Flutter error handling for this widget
|
||||
final previousOnError = FlutterError.onError;
|
||||
_previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
// Forward to any previously registered handler to avoid interfering
|
||||
if (previousOnError != null) {
|
||||
previousOnError(details);
|
||||
}
|
||||
_handleError(details.exception, details.stack);
|
||||
_previousOnError?.call(details);
|
||||
// Defer handling to avoid setState during build
|
||||
_scheduleHandleError(details.exception, details.stack);
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Restore previous error handler to avoid leaking global state
|
||||
if (FlutterError.onError != _previousOnError) {
|
||||
FlutterError.onError = _previousOnError;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleError(Object error, StackTrace? stack) {
|
||||
// Log error
|
||||
enhancedErrorService.logError(
|
||||
@@ -134,14 +152,16 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||||
_handleError(details.exception, details.stack);
|
||||
// Defer handling to avoid setState during build of error widgets
|
||||
_scheduleHandleError(details.exception, details.stack);
|
||||
return const SizedBox.shrink();
|
||||
};
|
||||
|
||||
try {
|
||||
return widget.child;
|
||||
} catch (error, stack) {
|
||||
_handleError(error, stack);
|
||||
// Defer handling to avoid setState during build
|
||||
_scheduleHandleError(error, stack);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user