feat(auth): Add LDAP and SSO authentication support
This commit is contained in:
@@ -8,6 +8,7 @@ import '../models/user.dart';
|
||||
import '../services/optimized_storage_service.dart';
|
||||
import 'token_validator.dart';
|
||||
import 'auth_cache_manager.dart';
|
||||
import 'webview_cookie_helper.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
import '../utils/user_avatar_utils.dart';
|
||||
import '../../features/tools/providers/tools_providers.dart';
|
||||
@@ -288,11 +289,17 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform login with JWT token
|
||||
/// Perform login with JWT token.
|
||||
///
|
||||
/// Note: API keys (sk-...) are not supported for streaming.
|
||||
///
|
||||
/// [authType] specifies the source of the token for credential storage:
|
||||
/// - 'token': Manual JWT entry (default)
|
||||
/// - 'sso': Token obtained via SSO/OAuth flow
|
||||
Future<bool> loginWithApiKey(
|
||||
String apiKey, {
|
||||
bool rememberCredentials = false,
|
||||
String authType = 'token',
|
||||
}) async {
|
||||
_update(
|
||||
(current) => current.copyWith(
|
||||
@@ -347,6 +354,7 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
serverId: activeServer.id,
|
||||
username: 'jwt_user', // Special username to indicate JWT auth
|
||||
password: tokenStr, // Store JWT in password field
|
||||
authType: authType, // 'token' for manual entry, 'sso' for OAuth
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -486,6 +494,117 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform login with LDAP credentials.
|
||||
///
|
||||
/// LDAP uses username (not email) for authentication.
|
||||
/// The server must have LDAP enabled, otherwise this will throw an error.
|
||||
Future<bool> ldapLogin(
|
||||
String username,
|
||||
String password, {
|
||||
bool rememberCredentials = false,
|
||||
}) async {
|
||||
_update(
|
||||
(current) => current.copyWith(
|
||||
status: AuthStatus.loading,
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Ensure API service is available
|
||||
await _ensureApiServiceAvailable();
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
throw Exception('No server connection available');
|
||||
}
|
||||
|
||||
// Perform LDAP login API call
|
||||
final response = await api.ldapLogin(username, password);
|
||||
|
||||
// Check if notifier is still mounted after async call
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
// 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);
|
||||
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
// Save JWT token for re-authentication if requested
|
||||
// We store the token (not the raw LDAP password) for security:
|
||||
// - JWT tokens can be revoked server-side
|
||||
// - Avoids storing the user's directory password
|
||||
// - Consistent with SSO token storage approach
|
||||
if (rememberCredentials) {
|
||||
final activeServer = await ref.read(activeServerProvider.future);
|
||||
if (!ref.mounted) return false;
|
||||
if (activeServer != null) {
|
||||
await storage.saveCredentials(
|
||||
serverId: activeServer.id,
|
||||
// Prefix with ldap: to preserve original username for debugging
|
||||
// while indicating this is token-based auth
|
||||
username: 'ldap:$username',
|
||||
password: tokenStr, // Store JWT token, not LDAP password
|
||||
authType: 'ldap', // Track that this originated from LDAP login
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
// Update state and API service
|
||||
_update(
|
||||
(current) => current.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
token: tokenStr,
|
||||
isLoading: false,
|
||||
clearError: true,
|
||||
),
|
||||
cache: true,
|
||||
);
|
||||
|
||||
_updateApiServiceToken(tokenStr);
|
||||
_preloadDefaultModel();
|
||||
|
||||
// Load user data in background
|
||||
_loadUserData();
|
||||
_prefetchConversations();
|
||||
|
||||
DebugLogger.auth('LDAP login successful');
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
DebugLogger.error(
|
||||
'ldap-login-failed',
|
||||
scope: 'auth/state',
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
if (ref.mounted) {
|
||||
_update(
|
||||
(current) => current.copyWith(
|
||||
status: AuthStatus.error,
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait briefly until the API service becomes available
|
||||
Future<void> _ensureApiServiceAvailable({
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
@@ -582,12 +701,27 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attempt login (detect API key vs normal credentials)
|
||||
if (username == 'api_key_user' || username == 'jwt_user') {
|
||||
// This is a saved JWT token (or legacy API key)
|
||||
return await loginWithApiKey(password, rememberCredentials: false);
|
||||
// Attempt login based on auth type
|
||||
final authType = savedCredentials['authType'] ?? 'credentials';
|
||||
|
||||
// Handle JWT token-based authentication (includes legacy prefixes)
|
||||
// LDAP now also stores JWT tokens for re-auth (not raw passwords)
|
||||
if (username == 'api_key_user' ||
|
||||
username == 'jwt_user' ||
|
||||
username.startsWith('ldap:') ||
|
||||
authType == 'token' ||
|
||||
authType == 'sso' ||
|
||||
authType == 'ldap') {
|
||||
// This is a saved JWT token (manual entry, SSO, or LDAP-obtained)
|
||||
// For LDAP, we store the JWT token returned by the server, not the
|
||||
// original password, for security reasons
|
||||
return await loginWithApiKey(
|
||||
password, // This is the JWT token
|
||||
rememberCredentials: false,
|
||||
authType: authType,
|
||||
);
|
||||
} else {
|
||||
// Normal username/password credentials
|
||||
// Standard credentials login (default)
|
||||
return await login(username, password, rememberCredentials: false);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
@@ -805,6 +939,20 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
await storage.clearAuthData();
|
||||
_updateApiServiceToken(null);
|
||||
|
||||
// Clear WebView cookies to ensure fresh SSO sessions on next login
|
||||
try {
|
||||
final cleared = await WebViewCookieHelper.clearCookies();
|
||||
if (cleared) {
|
||||
DebugLogger.auth('WebView cookies cleared');
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.warning(
|
||||
'webview-cookie-clear-failed',
|
||||
scope: 'auth/state',
|
||||
data: {'error': e.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
// Keep active server ID so router redirects to sign-in page, not server
|
||||
// connection page. Users can navigate to server settings if they need to
|
||||
// change server configuration.
|
||||
|
||||
33
lib/core/auth/webview_cookie_helper.dart
Normal file
33
lib/core/auth/webview_cookie_helper.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Check if WebView is supported on the current platform.
|
||||
///
|
||||
/// webview_flutter only supports iOS and Android.
|
||||
bool get isWebViewSupported =>
|
||||
!kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||
|
||||
/// Helper for clearing WebView cookies on supported platforms.
|
||||
///
|
||||
/// This is isolated in its own file to prevent platform coupling issues
|
||||
/// when the webview_flutter package isn't available.
|
||||
class WebViewCookieHelper {
|
||||
/// Clears all WebView cookies.
|
||||
///
|
||||
/// Returns true if cookies were cleared, false if not supported or failed.
|
||||
/// Checks platform support internally, so safe to call on any platform.
|
||||
static Future<bool> clearCookies() async {
|
||||
// Only supported on mobile platforms
|
||||
if (!isWebViewSupported) return false;
|
||||
|
||||
try {
|
||||
return await WebViewCookieManager().clearCookies();
|
||||
} catch (e) {
|
||||
// Silently fail - WebView may not be available
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../features/auth/views/authentication_page.dart';
|
||||
import '../../features/auth/views/connect_signin_page.dart';
|
||||
import '../../features/auth/views/connection_issue_page.dart';
|
||||
import '../../features/auth/views/server_connection_page.dart';
|
||||
import '../../features/auth/views/sso_auth_page.dart';
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
import '../../features/navigation/views/splash_launcher_page.dart';
|
||||
import '../../features/notes/views/notes_list_page.dart';
|
||||
@@ -237,6 +238,16 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.ssoAuth,
|
||||
name: RouteNames.ssoAuth,
|
||||
builder: (context, state) {
|
||||
final config = state.extra;
|
||||
return SsoAuthPage(
|
||||
serverConfig: config is ServerConfig ? config : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.profile,
|
||||
name: RouteNames.profile,
|
||||
|
||||
@@ -355,6 +355,46 @@ class ApiService {
|
||||
await _dio.get('/api/v1/auths/signout');
|
||||
}
|
||||
|
||||
/// LDAP authentication - uses username instead of email.
|
||||
///
|
||||
/// Returns the same response format as regular login:
|
||||
/// `{"token": "...", "token_type": "Bearer", "id": "...", ...}`
|
||||
///
|
||||
/// Throws an exception if LDAP is not enabled on the server (400 response).
|
||||
Future<Map<String, dynamic>> ldapLogin(
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/v1/auths/ldap',
|
||||
data: {'user': username, 'password': password},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
if (e is DioException) {
|
||||
// Handle LDAP not enabled
|
||||
if (e.response?.statusCode == 400) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['detail'] != null) {
|
||||
throw Exception(data['detail']);
|
||||
}
|
||||
}
|
||||
// Handle specific redirect cases
|
||||
if (e.response?.statusCode == 307 || e.response?.statusCode == 308) {
|
||||
final location = e.response?.headers.value('location');
|
||||
if (location != null) {
|
||||
throw Exception(
|
||||
'Server redirect detected. Please check your server URL configuration. Redirect to: $location',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// User info
|
||||
Future<User> getCurrentUser() async {
|
||||
final response = await _dio.get('/api/v1/auths/');
|
||||
|
||||
@@ -101,6 +101,7 @@ class Routes {
|
||||
static const String serverConnection = '/server-connection';
|
||||
static const String connectionIssue = '/connection-issue';
|
||||
static const String authentication = '/authentication';
|
||||
static const String ssoAuth = '/sso-auth';
|
||||
static const String profile = '/profile';
|
||||
static const String appCustomization = '/profile/customization';
|
||||
static const String notes = '/notes';
|
||||
@@ -115,6 +116,7 @@ class RouteNames {
|
||||
static const String serverConnection = 'server-connection';
|
||||
static const String connectionIssue = 'connection-issue';
|
||||
static const String authentication = 'authentication';
|
||||
static const String ssoAuth = 'sso-auth';
|
||||
static const String profile = 'profile';
|
||||
static const String appCustomization = 'app-customization';
|
||||
static const String notes = 'notes';
|
||||
|
||||
@@ -132,12 +132,14 @@ class OptimizedStorageService {
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
String authType = 'credentials',
|
||||
}) async {
|
||||
try {
|
||||
await _secureCredentialStorage.saveCredentials(
|
||||
serverId: serverId,
|
||||
username: username,
|
||||
password: password,
|
||||
authType: authType,
|
||||
);
|
||||
|
||||
_cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl);
|
||||
|
||||
@@ -40,11 +40,18 @@ class SecureCredentialStorage {
|
||||
);
|
||||
}
|
||||
|
||||
/// Save user credentials securely
|
||||
/// Save user credentials securely.
|
||||
///
|
||||
/// [authType] identifies the authentication method:
|
||||
/// - 'credentials': Standard email/password login (default)
|
||||
/// - 'ldap': LDAP directory authentication
|
||||
/// - 'token': Manual JWT token entry
|
||||
/// - 'sso': JWT token obtained via SSO/OAuth flow
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
String authType = 'credentials',
|
||||
}) async {
|
||||
try {
|
||||
// First check if secure storage is available
|
||||
@@ -57,9 +64,10 @@ class SecureCredentialStorage {
|
||||
'serverId': serverId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'authType': authType,
|
||||
'savedAt': DateTime.now().toIso8601String(),
|
||||
'deviceId': await _getDeviceFingerprint(),
|
||||
'version': '2.0', // Version for migration purposes
|
||||
'version': '2.1', // Version for migration purposes
|
||||
};
|
||||
|
||||
final encryptedData = await _encryptData(jsonEncode(credentials));
|
||||
@@ -76,7 +84,7 @@ class SecureCredentialStorage {
|
||||
DebugLogger.storage(
|
||||
'save-ok',
|
||||
scope: 'credentials',
|
||||
data: {'version': '2.0'},
|
||||
data: {'version': '2.1'},
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.error('save-failed', scope: 'credentials', error: e);
|
||||
@@ -156,6 +164,7 @@ class SecureCredentialStorage {
|
||||
'username': decoded['username']?.toString() ?? '',
|
||||
'password': decoded['password']?.toString() ?? '',
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
'authType': decoded['authType']?.toString() ?? 'credentials',
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLogger.error('read-failed', scope: 'credentials', error: e);
|
||||
@@ -355,7 +364,9 @@ class SecureCredentialStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate from old storage format if needed
|
||||
/// Migrate from old storage format if needed.
|
||||
///
|
||||
/// Preserves the [authType] if present in old credentials.
|
||||
Future<void> migrateFromOldStorage(
|
||||
Map<String, String>? oldCredentials,
|
||||
) async {
|
||||
@@ -366,6 +377,7 @@ class SecureCredentialStorage {
|
||||
serverId: oldCredentials['serverId'] ?? '',
|
||||
username: oldCredentials['username'] ?? '',
|
||||
password: oldCredentials['password'] ?? '',
|
||||
authType: oldCredentials['authType'] ?? 'credentials',
|
||||
);
|
||||
DebugLogger.storage('migrate-ok', scope: 'credentials');
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user