mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 15:13:18 +08:00
feat: native 端支持自签证书
This commit is contained in:
@@ -14,7 +14,7 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
|
||||
PODFILE CHECKSUM: 288656d4464589360115d6f81dd5d8f559352730
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'core/api/cookie_store.dart';
|
||||
import 'core/api/api_result.dart';
|
||||
import 'core/storage/app_storage.dart';
|
||||
import 'core/storage/secure_storage.dart';
|
||||
import 'core/security/server_certificate_trust.dart';
|
||||
import 'features/auth/auth_session.dart';
|
||||
import 'features/auth/login_controller.dart';
|
||||
import 'features/auth/login_page.dart';
|
||||
@@ -42,6 +43,7 @@ class _Bootstrap {
|
||||
required this.initialPassword,
|
||||
required this.initialAuthState,
|
||||
required this.initialIpAccessDenied,
|
||||
required this.certificateTrust,
|
||||
});
|
||||
|
||||
final AppStorage appStorage;
|
||||
@@ -52,6 +54,7 @@ class _Bootstrap {
|
||||
final String initialPassword;
|
||||
final AuthState initialAuthState;
|
||||
final bool initialIpAccessDenied;
|
||||
final ServerCertificateTrustStore certificateTrust;
|
||||
}
|
||||
|
||||
class EasyNodeApp extends StatelessWidget {
|
||||
@@ -65,6 +68,7 @@ class EasyNodeApp extends StatelessWidget {
|
||||
final secureWrapper = SecureAppStorage(secure);
|
||||
final appStorage = AppStorage(prefs);
|
||||
final cookieStore = SessionCookieStore(secureWrapper);
|
||||
final certificateTrust = ServerCertificateTrustStore(secureWrapper);
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appVersion = packageInfo.buildNumber.isEmpty
|
||||
? packageInfo.version
|
||||
@@ -96,11 +100,13 @@ class EasyNodeApp extends StatelessWidget {
|
||||
deviceId.isNotEmpty;
|
||||
|
||||
if (hasStoredLogin) {
|
||||
await certificateTrust.prepare(appStorage.serverAddress);
|
||||
final api = ApiClient(
|
||||
serverAddress: appStorage.serverAddress,
|
||||
cookieStore: cookieStore,
|
||||
token: token,
|
||||
appVersion: appVersion,
|
||||
certificateTrust: certificateTrust,
|
||||
);
|
||||
try {
|
||||
final pubKey = await api.getPublicKey();
|
||||
@@ -150,6 +156,7 @@ class EasyNodeApp extends StatelessWidget {
|
||||
initialPassword: initialPassword,
|
||||
initialAuthState: initialAuthState,
|
||||
initialIpAccessDenied: initialIpAccessDenied,
|
||||
certificateTrust: certificateTrust,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -161,6 +168,7 @@ class EasyNodeApp extends StatelessWidget {
|
||||
appStorageProvider.overrideWithValue(_b.appStorage),
|
||||
secureStorageProvider.overrideWithValue(_b.secureStorage),
|
||||
cookieStoreProvider.overrideWithValue(_b.cookieStore),
|
||||
certificateTrustProvider.overrideWithValue(_b.certificateTrust),
|
||||
authProvider.overrideWith(
|
||||
(ref) => AuthNotifier(ref, _b.initialAuthState),
|
||||
),
|
||||
@@ -210,6 +218,7 @@ class _AppRootState extends ConsumerState<_AppRoot> {
|
||||
super.initState();
|
||||
_loginController = LoginController(
|
||||
apiClientFactory: _buildAnonymousApiClient,
|
||||
certificateTrust: ref.read(certificateTrustProvider),
|
||||
)..onLoginSuccess(_onLoginSuccess);
|
||||
// The restored client is constructed before ProviderScope exists. Bind
|
||||
// its session-failure handler once the global coordinator is available.
|
||||
@@ -239,6 +248,7 @@ class _AppRootState extends ConsumerState<_AppRoot> {
|
||||
cookieStore: ref.read(cookieStoreProvider),
|
||||
token: token,
|
||||
appVersion: widget.appVersion,
|
||||
certificateTrust: ref.read(certificateTrustProvider),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show HandshakeException, Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'api_result.dart';
|
||||
import 'cookie_store.dart';
|
||||
import '../security/server_certificate_trust.dart';
|
||||
|
||||
const String _fallbackNativeAppVersion = 'unknown';
|
||||
const String ipAccessDeniedCode = 'IP_ACCESS_DENIED';
|
||||
@@ -125,7 +126,10 @@ class ApiClient {
|
||||
SessionFailureHandler? onSessionFailure,
|
||||
String? appVersion,
|
||||
Dio? dio,
|
||||
}) : _cookieStore = cookieStore,
|
||||
ServerCertificateTrustStore? certificateTrust,
|
||||
}) : _serverAddress = serverAddress,
|
||||
_certificateTrust = certificateTrust,
|
||||
_cookieStore = cookieStore,
|
||||
_token = token,
|
||||
_onSessionFailure = onSessionFailure,
|
||||
_dio =
|
||||
@@ -140,6 +144,9 @@ class ApiClient {
|
||||
},
|
||||
),
|
||||
) {
|
||||
if (dio == null && certificateTrust != null) {
|
||||
_dio.httpClientAdapter = certificateTrust.createDioAdapter();
|
||||
}
|
||||
if (kDebugMode) {
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
@@ -195,6 +202,8 @@ class ApiClient {
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final String _serverAddress;
|
||||
final ServerCertificateTrustStore? _certificateTrust;
|
||||
final SessionCookieStore _cookieStore;
|
||||
SessionFailureHandler? _onSessionFailure;
|
||||
String? _token;
|
||||
@@ -255,6 +264,14 @@ class ApiClient {
|
||||
try {
|
||||
return _asJson(await send());
|
||||
} on DioException catch (error) {
|
||||
final certificate = _certificateTrust?.pendingCertificateFor(
|
||||
_serverAddress,
|
||||
);
|
||||
if (certificate != null &&
|
||||
(error.type == DioExceptionType.badCertificate ||
|
||||
error.error is HandshakeException)) {
|
||||
throw UntrustedServerCertificateException(certificate);
|
||||
}
|
||||
final failure = apiFailureFromDioException(error);
|
||||
if (failure is ApiSessionFailure) {
|
||||
final handler = _onSessionFailure;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:web_socket/io_web_socket.dart';
|
||||
import 'package:web_socket/web_socket.dart' as ws;
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
class PresentedServerCertificate {
|
||||
const PresentedServerCertificate({
|
||||
required this.origin,
|
||||
required this.fingerprint,
|
||||
this.previousFingerprint,
|
||||
});
|
||||
|
||||
final String origin;
|
||||
final String fingerprint;
|
||||
final String? previousFingerprint;
|
||||
|
||||
String get displayFingerprint => formatCertificateFingerprint(fingerprint);
|
||||
bool get replacesTrustedCertificate => previousFingerprint != null;
|
||||
}
|
||||
|
||||
class UntrustedServerCertificateException implements Exception {
|
||||
const UntrustedServerCertificateException(this.certificate);
|
||||
|
||||
final PresentedServerCertificate certificate;
|
||||
|
||||
@override
|
||||
String toString() => 'Untrusted certificate for ${certificate.origin}';
|
||||
}
|
||||
|
||||
String canonicalServerOrigin(String address) {
|
||||
final uri = Uri.parse(address);
|
||||
final scheme = switch (uri.scheme.toLowerCase()) {
|
||||
'wss' => 'https',
|
||||
'ws' => 'http',
|
||||
final value => value,
|
||||
};
|
||||
return Uri(
|
||||
scheme: scheme,
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
).origin;
|
||||
}
|
||||
|
||||
String formatCertificateFingerprint(String fingerprint) {
|
||||
final normalized = fingerprint.replaceAll(':', '').toUpperCase();
|
||||
final pairs = <String>[];
|
||||
for (var index = 0; index < normalized.length; index += 2) {
|
||||
pairs.add(normalized.substring(index, index + 2));
|
||||
}
|
||||
return pairs.join(':');
|
||||
}
|
||||
|
||||
class ServerCertificateTrustStore {
|
||||
ServerCertificateTrustStore(this._storage);
|
||||
|
||||
final SecureAppStorage _storage;
|
||||
final Map<String, String> _trustedFingerprints = {};
|
||||
final Map<String, PresentedServerCertificate> _pendingCertificates = {};
|
||||
final Set<String> _loadedOrigins = {};
|
||||
HttpClient? _systemWebSocketClient;
|
||||
HttpClient? _pinnedWebSocketClient;
|
||||
|
||||
Future<void> prepare(String serverAddress) async {
|
||||
final origin = canonicalServerOrigin(serverAddress);
|
||||
if (_loadedOrigins.contains(origin)) return;
|
||||
final stored = await _storage.readServerCertificateFingerprint(origin);
|
||||
if (stored != null && stored.isNotEmpty) {
|
||||
final normalized = _normalizeFingerprint(stored);
|
||||
if (_isValidFingerprint(normalized)) {
|
||||
_trustedFingerprints[origin] = normalized;
|
||||
}
|
||||
}
|
||||
_loadedOrigins.add(origin);
|
||||
}
|
||||
|
||||
PresentedServerCertificate? pendingCertificateFor(String serverAddress) {
|
||||
return _pendingCertificates[canonicalServerOrigin(serverAddress)];
|
||||
}
|
||||
|
||||
Future<void> trust(PresentedServerCertificate certificate) async {
|
||||
await _storage.writeServerCertificateFingerprint(
|
||||
certificate.origin,
|
||||
certificate.fingerprint,
|
||||
);
|
||||
_trustedFingerprints[certificate.origin] = certificate.fingerprint;
|
||||
_loadedOrigins.add(certificate.origin);
|
||||
_pendingCertificates.remove(certificate.origin);
|
||||
}
|
||||
|
||||
IOHttpClientAdapter createDioAdapter() {
|
||||
return IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
final client = HttpClient();
|
||||
client.badCertificateCallback = _acceptBadCertificate;
|
||||
return client;
|
||||
},
|
||||
validateCertificate: _validateCertificate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ws.WebSocket> connectWebSocket(
|
||||
Uri uri, {
|
||||
Iterable<String>? protocols,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
final origin = canonicalServerOrigin(uri.toString());
|
||||
final hasPinnedCertificate = _trustedFingerprints.containsKey(origin);
|
||||
final client = _webSocketClient(pinned: hasPinnedCertificate);
|
||||
final rawSocket = await WebSocket.connect(
|
||||
uri.toString(),
|
||||
protocols: protocols,
|
||||
headers: headers,
|
||||
customClient: client,
|
||||
);
|
||||
return IOWebSocket.fromWebSocket(rawSocket);
|
||||
}
|
||||
|
||||
HttpClient _webSocketClient({required bool pinned}) {
|
||||
if (pinned) {
|
||||
return _pinnedWebSocketClient ??= HttpClient(
|
||||
context: SecurityContext(withTrustedRoots: false),
|
||||
)..badCertificateCallback = _acceptBadCertificate;
|
||||
}
|
||||
return _systemWebSocketClient ??= HttpClient()
|
||||
..badCertificateCallback = _acceptBadCertificate;
|
||||
}
|
||||
|
||||
bool _acceptBadCertificate(
|
||||
X509Certificate certificate,
|
||||
String host,
|
||||
int port,
|
||||
) {
|
||||
final presented = _presentedCertificate(certificate, host, port);
|
||||
final trusted = _trustedFingerprints[presented.origin];
|
||||
if (trusted == presented.fingerprint) {
|
||||
_pendingCertificates.remove(presented.origin);
|
||||
return true;
|
||||
}
|
||||
_pendingCertificates[presented.origin] = presented;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _validateCertificate(
|
||||
X509Certificate? certificate,
|
||||
String host,
|
||||
int port,
|
||||
) {
|
||||
// Plain HTTP responses do not have a peer certificate. The TLS trust
|
||||
// policy must not change the app's existing HTTP behaviour.
|
||||
if (certificate == null) return true;
|
||||
final presented = _presentedCertificate(certificate, host, port);
|
||||
final trusted = _trustedFingerprints[presented.origin];
|
||||
if (trusted == null) {
|
||||
_pendingCertificates.remove(presented.origin);
|
||||
return true;
|
||||
}
|
||||
if (trusted == presented.fingerprint) {
|
||||
_pendingCertificates.remove(presented.origin);
|
||||
return true;
|
||||
}
|
||||
_pendingCertificates[presented.origin] = presented;
|
||||
return false;
|
||||
}
|
||||
|
||||
PresentedServerCertificate _presentedCertificate(
|
||||
X509Certificate certificate,
|
||||
String host,
|
||||
int port,
|
||||
) {
|
||||
final origin = Uri(
|
||||
scheme: 'https',
|
||||
host: host,
|
||||
port: port == 443 ? null : port,
|
||||
).origin;
|
||||
final fingerprint = sha256.convert(certificate.der).toString();
|
||||
return PresentedServerCertificate(
|
||||
origin: origin,
|
||||
fingerprint: fingerprint,
|
||||
previousFingerprint: _trustedFingerprints[origin],
|
||||
);
|
||||
}
|
||||
|
||||
String _normalizeFingerprint(String value) =>
|
||||
value.replaceAll(':', '').trim().toLowerCase();
|
||||
|
||||
bool _isValidFingerprint(String value) =>
|
||||
RegExp(r'^[0-9a-f]{64}$').hasMatch(value);
|
||||
}
|
||||
@@ -37,4 +37,21 @@ class SecureAppStorage {
|
||||
Future<void> writeDeviceId(String value) =>
|
||||
_storage.write(key: 'loginDeviceId', value: value);
|
||||
Future<void> deleteDeviceId() => _storage.delete(key: 'loginDeviceId');
|
||||
|
||||
String _certificateFingerprintKey(String serverOrigin) =>
|
||||
'serverCertificateFingerprint:$serverOrigin';
|
||||
|
||||
Future<String?> readServerCertificateFingerprint(String serverOrigin) {
|
||||
return _storage.read(key: _certificateFingerprintKey(serverOrigin));
|
||||
}
|
||||
|
||||
Future<void> writeServerCertificateFingerprint(
|
||||
String serverOrigin,
|
||||
String fingerprint,
|
||||
) {
|
||||
return _storage.write(
|
||||
key: _certificateFingerprintKey(serverOrigin),
|
||||
value: fingerprint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as sio;
|
||||
|
||||
import '../../core/api/cookie_store.dart';
|
||||
import '../../core/security/server_certificate_trust.dart';
|
||||
import '../auth/auth_session.dart';
|
||||
import 'agent_models.dart';
|
||||
|
||||
@@ -38,11 +39,14 @@ class AgentSocketClient {
|
||||
AgentSocketClient({
|
||||
required AuthSession authSession,
|
||||
required SessionCookieStore cookieStore,
|
||||
required ServerCertificateTrustStore certificateTrust,
|
||||
}) : _authSession = authSession,
|
||||
_cookieStore = cookieStore;
|
||||
_cookieStore = cookieStore,
|
||||
_certificateTrust = certificateTrust;
|
||||
|
||||
final AuthSession _authSession;
|
||||
final SessionCookieStore _cookieStore;
|
||||
final ServerCertificateTrustStore _certificateTrust;
|
||||
final _events = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _connections = StreamController<AgentConnectionStatus>.broadcast();
|
||||
final _errors = StreamController<String>.broadcast();
|
||||
@@ -92,6 +96,7 @@ class AgentSocketClient {
|
||||
'Cookie': cookie,
|
||||
'Origin': _authSession.serverAddress,
|
||||
})
|
||||
.setWebSocketConnector(_certificateTrust.connectWebSocket)
|
||||
.disableAutoConnect()
|
||||
.disableReconnection()
|
||||
.build();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/api/api_result.dart';
|
||||
import '../../core/crypto/rsa_crypto.dart';
|
||||
import '../../core/security/server_certificate_trust.dart';
|
||||
import '../../core/utils/jwt_expiry.dart';
|
||||
import '../../core/utils/validators.dart';
|
||||
import 'auth_session.dart';
|
||||
@@ -13,6 +14,7 @@ class LoginResult {
|
||||
this.message = '',
|
||||
this.messageKey,
|
||||
this.session,
|
||||
this.certificate,
|
||||
});
|
||||
|
||||
/// `true` only when login fully succeeded.
|
||||
@@ -32,6 +34,10 @@ class LoginResult {
|
||||
final String? messageKey;
|
||||
|
||||
final AuthSession? session;
|
||||
|
||||
final PresentedServerCertificate? certificate;
|
||||
|
||||
bool get requiresCertificateConfirmation => certificate != null;
|
||||
}
|
||||
|
||||
/// Builds an [ApiClient] for a given server address. Allows tests to inject
|
||||
@@ -44,9 +50,13 @@ typedef LoginSuccessHandler =
|
||||
|
||||
/// Orchestrates the native client login flow.
|
||||
class LoginController {
|
||||
LoginController({required ApiClientFactory apiClientFactory, RsaCrypto? rsa})
|
||||
: _apiClientFactory = apiClientFactory,
|
||||
_rsa = rsa ?? RsaCrypto();
|
||||
LoginController({
|
||||
required ApiClientFactory apiClientFactory,
|
||||
RsaCrypto? rsa,
|
||||
ServerCertificateTrustStore? certificateTrust,
|
||||
}) : _apiClientFactory = apiClientFactory,
|
||||
_rsa = rsa ?? RsaCrypto(),
|
||||
_certificateTrust = certificateTrust;
|
||||
|
||||
/// Test factory that returns a controller without a real HTTP client. Real
|
||||
/// network calls will fail because the factory throws when invoked.
|
||||
@@ -57,6 +67,15 @@ class LoginController {
|
||||
|
||||
final ApiClientFactory _apiClientFactory;
|
||||
final RsaCrypto _rsa;
|
||||
final ServerCertificateTrustStore? _certificateTrust;
|
||||
|
||||
Future<void> trustCertificate(PresentedServerCertificate certificate) async {
|
||||
final trust = _certificateTrust;
|
||||
if (trust == null) {
|
||||
throw StateError('Certificate trust is not configured');
|
||||
}
|
||||
await trust.trust(certificate);
|
||||
}
|
||||
|
||||
Future<LoginResult> login({
|
||||
required String serverAddress,
|
||||
@@ -93,8 +112,9 @@ class LoginController {
|
||||
);
|
||||
}
|
||||
|
||||
final api = _apiClientFactory(normalized);
|
||||
try {
|
||||
await _certificateTrust?.prepare(normalized);
|
||||
final api = _apiClientFactory(normalized);
|
||||
final publicKey = await api.getPublicKey();
|
||||
final ciphertext = _rsa.encryptPassword(publicKey, password);
|
||||
final response = await api.postJson('/login', {
|
||||
@@ -130,6 +150,8 @@ class LoginController {
|
||||
await onLoginSuccess(session, savePassword ? password : null);
|
||||
}
|
||||
return LoginResult(success: true, session: session);
|
||||
} on UntrustedServerCertificateException catch (error) {
|
||||
return LoginResult(certificate: error.certificate);
|
||||
} on ApiFailure catch (error) {
|
||||
return LoginResult(message: error.message);
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/ui/app_color_theme.dart';
|
||||
import '../../core/utils/jwt_expiry.dart';
|
||||
import '../../core/utils/validators.dart';
|
||||
import '../../core/security/server_certificate_trust.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../state/package_info_provider.dart';
|
||||
import 'auth_session.dart';
|
||||
@@ -115,6 +116,20 @@ class _LoginPageState extends State<LoginPage> {
|
||||
await _submit();
|
||||
return;
|
||||
}
|
||||
if (result.requiresCertificateConfirmation) {
|
||||
setState(() => _submitting = false);
|
||||
final certificate = result.certificate!;
|
||||
final accepted = await _confirmCertificate(certificate);
|
||||
if (!mounted || accepted != true) return;
|
||||
try {
|
||||
await widget.controller.trustCertificate(certificate);
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _errorMessage = error.toString());
|
||||
return;
|
||||
}
|
||||
await _submit();
|
||||
return;
|
||||
}
|
||||
if (!result.success || result.session == null) {
|
||||
setState(() => _errorMessage = _resolveErrorMessage(result, l));
|
||||
return;
|
||||
@@ -146,6 +161,69 @@ class _LoginPageState extends State<LoginPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _confirmCertificate(PresentedServerCertificate certificate) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final previous = certificate.previousFingerprint;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(
|
||||
l.tr(
|
||||
certificate.replacesTrustedCertificate
|
||||
? 'login.certificateChangedTitle'
|
||||
: 'login.certificateTitle',
|
||||
),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.tr(
|
||||
certificate.replacesTrustedCertificate
|
||||
? 'login.certificateChangedBody'
|
||||
: 'login.certificateBody',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l.tr('login.certificateServer')),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(certificate.origin),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.tr('login.certificateFingerprint')),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
certificate.displayFingerprint,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
if (previous != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(l.tr('login.certificatePreviousFingerprint')),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
formatCertificateFingerprint(previous),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l.tr('common.cancel')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l.tr('login.trustCertificate')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show ChangeNotifier;
|
||||
|
||||
import '../../core/api/cookie_store.dart';
|
||||
import '../../core/security/server_certificate_trust.dart';
|
||||
import '../auth/auth_session.dart';
|
||||
import '../servers/server_model.dart';
|
||||
import 'docker_container.dart';
|
||||
@@ -61,6 +62,7 @@ class DockerSessionManager extends ChangeNotifier {
|
||||
required ServerModel server,
|
||||
required AuthSession authSession,
|
||||
required SessionCookieStore cookieStore,
|
||||
required ServerCertificateTrustStore certificateTrust,
|
||||
}) async {
|
||||
final existing = _sessions[server.id];
|
||||
if (existing != null) {
|
||||
@@ -78,6 +80,7 @@ class DockerSessionManager extends ChangeNotifier {
|
||||
final client = DockerSocketClient(
|
||||
authSession: authSession,
|
||||
cookieStore: cookieStore,
|
||||
certificateTrust: certificateTrust,
|
||||
hostId: server.id,
|
||||
);
|
||||
final session = DockerSessionState(server: server, client: client);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as sio;
|
||||
|
||||
import '../../core/api/cookie_store.dart';
|
||||
import '../../core/security/server_certificate_trust.dart';
|
||||
import '../auth/auth_session.dart';
|
||||
import 'docker_container.dart';
|
||||
|
||||
@@ -12,12 +13,15 @@ class DockerSocketClient {
|
||||
DockerSocketClient({
|
||||
required AuthSession authSession,
|
||||
required SessionCookieStore cookieStore,
|
||||
required ServerCertificateTrustStore certificateTrust,
|
||||
required this.hostId,
|
||||
}) : _authSession = authSession,
|
||||
_cookieStore = cookieStore;
|
||||
_cookieStore = cookieStore,
|
||||
_certificateTrust = certificateTrust;
|
||||
|
||||
final AuthSession _authSession;
|
||||
final SessionCookieStore _cookieStore;
|
||||
final ServerCertificateTrustStore _certificateTrust;
|
||||
final String hostId;
|
||||
|
||||
final StreamController<List<DockerContainer>> _containersController =
|
||||
@@ -72,6 +76,7 @@ class DockerSocketClient {
|
||||
'Cookie': cookie,
|
||||
'Origin': _authSession.serverAddress,
|
||||
})
|
||||
.setWebSocketConnector(_certificateTrust.connectWebSocket)
|
||||
.disableAutoConnect()
|
||||
.disableReconnection()
|
||||
.build();
|
||||
|
||||
@@ -113,6 +113,7 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
|
||||
server: server,
|
||||
authSession: authSession,
|
||||
cookieStore: ref.read(cookieStoreProvider),
|
||||
certificateTrust: ref.read(certificateTrustProvider),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -44,6 +44,17 @@ const Map<String, String> stringsEn = {
|
||||
'login.httpRiskTitle': 'HTTP is not encrypted',
|
||||
'login.httpRiskBody':
|
||||
'All your data may be stolen. It is recommended to use HTTPS(only in intranet environments).',
|
||||
'login.certificateTitle': 'Cannot verify server certificate',
|
||||
'login.certificateBody':
|
||||
'This certificate is not trusted by the system. Verify the server and fingerprint before permanently trusting it.',
|
||||
'login.certificateChangedTitle': 'Server certificate changed',
|
||||
'login.certificateChangedBody':
|
||||
'The server certificate differs from the certificate trusted previously. Cancel unless you know the server certificate was replaced.',
|
||||
'login.certificateServer': 'Server',
|
||||
'login.certificateFingerprint': 'New SHA-256 fingerprint',
|
||||
'login.certificatePreviousFingerprint':
|
||||
'Previously trusted SHA-256 fingerprint',
|
||||
'login.trustCertificate': 'Trust this certificate',
|
||||
'login.errEmptyUsername': 'Please enter a username',
|
||||
'login.errEmptyPassword': 'Please enter a password',
|
||||
'login.errInvalidServer': 'Please enter a valid server address',
|
||||
|
||||
@@ -43,6 +43,14 @@ const Map<String, String> stringsZh = {
|
||||
'login.failed': '登录失败',
|
||||
'login.httpRiskTitle': 'HTTP 连接未加密',
|
||||
'login.httpRiskBody': 'http协议下存在数据泄露风险,公网使用请配置https协议。是否继续?',
|
||||
'login.certificateTitle': '无法验证服务器证书',
|
||||
'login.certificateBody': '该证书不是系统信任的证书。确认服务器和指纹无误后,可以永久信任此证书。',
|
||||
'login.certificateChangedTitle': '服务器证书已发生变化',
|
||||
'login.certificateChangedBody': '服务器返回的证书与此前信任的证书不同。除非你确认服务器已更换证书,否则请取消连接。',
|
||||
'login.certificateServer': '服务器',
|
||||
'login.certificateFingerprint': '新证书 SHA-256 指纹',
|
||||
'login.certificatePreviousFingerprint': '此前信任的 SHA-256 指纹',
|
||||
'login.trustCertificate': '信任此证书',
|
||||
'login.errEmptyUsername': '请输入用户名',
|
||||
'login.errEmptyPassword': '请输入密码',
|
||||
'login.errInvalidServer': '请输入有效的服务端地址',
|
||||
|
||||
@@ -191,6 +191,7 @@ final agentControllerProvider =
|
||||
final socket = AgentSocketClient(
|
||||
authSession: session,
|
||||
cookieStore: ref.watch(cookieStoreProvider),
|
||||
certificateTrust: ref.watch(certificateTrustProvider),
|
||||
);
|
||||
return AgentStateNotifier(
|
||||
repository: ref.watch(agentRepositoryProvider),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/cookie_store.dart';
|
||||
import '../core/storage/app_storage.dart';
|
||||
import '../core/storage/secure_storage.dart';
|
||||
import '../core/security/server_certificate_trust.dart';
|
||||
|
||||
/// All four storage providers are bootstrap-only — `EasyNodeApp.bootstrap`
|
||||
/// constructs the concrete instances once and overrides them on the root
|
||||
@@ -18,3 +19,7 @@ final secureStorageProvider = Provider<SecureAppStorage>((ref) {
|
||||
final cookieStoreProvider = Provider<SessionCookieStore>((ref) {
|
||||
throw UnimplementedError('cookieStoreProvider must be overridden');
|
||||
});
|
||||
|
||||
final certificateTrustProvider = Provider<ServerCertificateTrustStore>((ref) {
|
||||
throw UnimplementedError('certificateTrustProvider must be overridden');
|
||||
});
|
||||
|
||||
+2
-2
@@ -122,7 +122,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
@@ -1037,7 +1037,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
dio: ^5.7.0
|
||||
crypto: ^3.0.7
|
||||
cookie_jar: ^4.0.8
|
||||
dio_cookie_manager: ^3.1.1
|
||||
flutter_secure_storage: ^9.2.2
|
||||
@@ -41,6 +42,7 @@ dependencies:
|
||||
url_launcher: ^6.3.1
|
||||
package_info_plus: ^8.1.0
|
||||
socket_io_client: ^3.1.6
|
||||
web_socket: ^1.0.1
|
||||
flutter_markdown_plus: ^1.0.12
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easynode_native/core/security/server_certificate_trust.dart';
|
||||
import 'package:easynode_native/core/storage/secure_storage.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:web_socket/web_socket.dart' as ws;
|
||||
|
||||
void main() {
|
||||
test('canonicalizes HTTP and WebSocket origins consistently', () {
|
||||
expect(
|
||||
canonicalServerOrigin('https://100.74.175.1:8092/path'),
|
||||
'https://100.74.175.1:8092',
|
||||
);
|
||||
expect(
|
||||
canonicalServerOrigin('wss://100.74.175.1:8092/docker/'),
|
||||
'https://100.74.175.1:8092',
|
||||
);
|
||||
expect(
|
||||
canonicalServerOrigin('https://example.com:443'),
|
||||
'https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('formats a SHA-256 fingerprint for display', () {
|
||||
expect(formatCertificateFingerprint('aabbccdd'), 'AA:BB:CC:DD');
|
||||
expect(formatCertificateFingerprint('AA:BB:CC:DD'), 'AA:BB:CC:DD');
|
||||
});
|
||||
|
||||
test('keeps plain HTTP requests working', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final subscription = server.listen((request) async {
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode({'status': 200}));
|
||||
await request.response.close();
|
||||
});
|
||||
final trust = ServerCertificateTrustStore(
|
||||
SecureAppStorage(const FlutterSecureStorage()),
|
||||
);
|
||||
final dio = Dio()..httpClientAdapter = trust.createDioAdapter();
|
||||
|
||||
try {
|
||||
final response = await dio.get(
|
||||
'http://${server.address.address}:${server.port}/health',
|
||||
);
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(response.data, {'status': 200});
|
||||
} finally {
|
||||
dio.close(force: true);
|
||||
await server.close(force: true);
|
||||
await subscription.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps plain WebSocket connections working', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final subscription = server.transform(WebSocketTransformer()).listen((
|
||||
socket,
|
||||
) {
|
||||
socket.listen(socket.add);
|
||||
});
|
||||
final trust = ServerCertificateTrustStore(
|
||||
SecureAppStorage(const FlutterSecureStorage()),
|
||||
);
|
||||
final client = await trust.connectWebSocket(
|
||||
Uri.parse('ws://${server.address.address}:${server.port}/socket'),
|
||||
);
|
||||
|
||||
try {
|
||||
final event = client.events.first;
|
||||
client.sendText('ping');
|
||||
final received = await event;
|
||||
expect(received, isA<ws.TextDataReceived>());
|
||||
expect((received as ws.TextDataReceived).text, 'ping');
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close(force: true);
|
||||
await subscription.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:easynode_native/core/api/api_result.dart';
|
||||
import 'package:easynode_native/core/api/cookie_store.dart';
|
||||
import 'package:easynode_native/core/crypto/rsa_crypto.dart';
|
||||
import 'package:easynode_native/core/storage/secure_storage.dart';
|
||||
import 'package:easynode_native/core/security/server_certificate_trust.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:easynode_native/features/auth/login_controller.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -37,6 +38,47 @@ class _FakeRsaCrypto extends RsaCrypto {
|
||||
'encrypted-password';
|
||||
}
|
||||
|
||||
class _MemorySecureStorage extends SecureAppStorage {
|
||||
_MemorySecureStorage() : super(const FlutterSecureStorage());
|
||||
|
||||
final Map<String, String> fingerprints = {};
|
||||
|
||||
@override
|
||||
Future<String?> readServerCertificateFingerprint(String serverOrigin) async {
|
||||
return fingerprints[serverOrigin];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeServerCertificateFingerprint(
|
||||
String serverOrigin,
|
||||
String fingerprint,
|
||||
) async {
|
||||
fingerprints[serverOrigin] = fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
class _CertificateFailureApiClient extends ApiClient {
|
||||
_CertificateFailureApiClient(this.certificate)
|
||||
: super(
|
||||
serverAddress: certificate.origin,
|
||||
cookieStore: SessionCookieStore(
|
||||
SecureAppStorage(const FlutterSecureStorage()),
|
||||
),
|
||||
);
|
||||
|
||||
final PresentedServerCertificate certificate;
|
||||
|
||||
@override
|
||||
Future<String> getPublicKey() async {
|
||||
throw UntrustedServerCertificateException(certificate);
|
||||
}
|
||||
}
|
||||
|
||||
PresentedServerCertificate _certificate() => PresentedServerCertificate(
|
||||
origin: 'https://100.74.175.1:8092',
|
||||
fingerprint: List.filled(32, 'ab').join(),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('blocks http login until user confirms risk', () async {
|
||||
final controller = LoginController.fake();
|
||||
@@ -123,4 +165,36 @@ void main() {
|
||||
expect(result.success, isFalse);
|
||||
expect(result.message, 'session initialization failed');
|
||||
});
|
||||
|
||||
test(
|
||||
'returns an untrusted certificate and persists explicit trust',
|
||||
() async {
|
||||
final secureStorage = _MemorySecureStorage();
|
||||
final trust = ServerCertificateTrustStore(secureStorage);
|
||||
final certificate = _certificate();
|
||||
final controller = LoginController(
|
||||
apiClientFactory: (_, {String? token}) =>
|
||||
_CertificateFailureApiClient(certificate),
|
||||
certificateTrust: trust,
|
||||
);
|
||||
|
||||
final result = await controller.login(
|
||||
serverAddress: certificate.origin,
|
||||
username: 'root',
|
||||
password: 'secret',
|
||||
mfa2Token: '',
|
||||
httpRiskAccepted: false,
|
||||
savePassword: false,
|
||||
);
|
||||
|
||||
expect(result.requiresCertificateConfirmation, isTrue);
|
||||
expect(result.certificate, same(certificate));
|
||||
|
||||
await controller.trustCertificate(certificate);
|
||||
expect(
|
||||
secureStorage.fingerprints[certificate.origin],
|
||||
certificate.fingerprint,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,36 @@ import 'package:easynode_native/features/auth/auth_session.dart';
|
||||
import 'package:easynode_native/features/auth/login_controller.dart';
|
||||
import 'package:easynode_native/features/auth/login_page.dart';
|
||||
import 'package:easynode_native/core/ui/app_color_theme.dart';
|
||||
import 'package:easynode_native/core/security/server_certificate_trust.dart';
|
||||
import 'package:easynode_native/core/utils/jwt_expiry.dart';
|
||||
import 'package:easynode_native/l10n/app_localizations.dart';
|
||||
|
||||
class _CertificateLoginController extends LoginController {
|
||||
_CertificateLoginController(this.certificate)
|
||||
: super(
|
||||
apiClientFactory: (_, {String? token}) =>
|
||||
throw StateError('API client is not used by this UI test'),
|
||||
);
|
||||
|
||||
final PresentedServerCertificate certificate;
|
||||
|
||||
@override
|
||||
Future<LoginResult> login({
|
||||
required String serverAddress,
|
||||
required String username,
|
||||
required String password,
|
||||
required String mfa2Token,
|
||||
required bool httpRiskAccepted,
|
||||
required bool savePassword,
|
||||
LoginExpiry expiry = LoginExpiry.threeDays,
|
||||
}) async => LoginResult(certificate: certificate);
|
||||
}
|
||||
|
||||
PresentedServerCertificate _certificate() => PresentedServerCertificate(
|
||||
origin: 'https://100.74.175.1:8092',
|
||||
fingerprint: List.filled(32, 'ab').join(),
|
||||
);
|
||||
|
||||
void main() {
|
||||
Widget wrap(Widget child) => ProviderScope(
|
||||
child: MaterialApp(
|
||||
@@ -119,4 +147,32 @@ void main() {
|
||||
expect(loginPageShouldWarnHttp('http://10.0.0.1'), isTrue);
|
||||
expect(loginPageShouldWarnHttp('https://10.0.0.1'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('offers cancel and permanent trust for an invalid certificate', (
|
||||
tester,
|
||||
) async {
|
||||
final certificate = _certificate();
|
||||
final controller = _CertificateLoginController(certificate);
|
||||
await pumpLoginPage(
|
||||
tester,
|
||||
LoginPage(
|
||||
controller: controller,
|
||||
initialServerAddress: certificate.origin,
|
||||
initialUsername: 'root',
|
||||
initialSavePassword: false,
|
||||
onLoginSuccess: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.ensureVisible(byKey(const Key('field-password')));
|
||||
await tester.enterText(byKey(const Key('field-password')), 'secret');
|
||||
await tester.tap(byKey(const Key('btn-login')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('无法验证服务器证书'), findsOneWidget);
|
||||
expect(find.text('取消'), findsOneWidget);
|
||||
expect(find.text('信任此证书'), findsOneWidget);
|
||||
expect(find.text('仅本次继续'), findsNothing);
|
||||
expect(find.text(certificate.displayFingerprint), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user