From fb20381f9815d5773dc358a41048643ee3b86a35 Mon Sep 17 00:00:00 2001 From: Dhrumil Shah Date: Tue, 12 Sep 2023 10:51:43 -0400 Subject: [PATCH] feat(mobile): allow self-signed certificate on the mobile app (#4051) * WIP: self-signed certs accept * WIP: format * WIP: pushing up adding settings menu * Add serverEndpointURL check * Add translation update * Handle errors properly * format * typo * cleanup * styling and permission * remove deadcode * put pack condition * styling * remove hiding settings options * format + match drop shadow * match color * remove deadcode --------- Co-authored-by: Alex --- mobile/assets/i18n/en-US.json | 3 + mobile/lib/main.dart | 2 + mobile/lib/modules/login/ui/login_form.dart | 106 +++++++++++++----- .../services/app_settings.service.dart | 1 + .../advanced_settings/advanced_settings.dart | 21 +++- .../ui/settings_switch_list_tile.dart | 22 ++-- mobile/lib/routing/router.dart | 1 - mobile/lib/routing/router.gr.dart | 5 +- mobile/lib/shared/models/store.dart | 1 + mobile/lib/utils/http_ssl_cert_override.dart | 37 ++++++ 10 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 mobile/lib/utils/http_ssl_cert_override.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index c57a404d89..94bd4b7dd0 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -7,6 +7,8 @@ "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", + "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", "album_thumbnail_card_item": "1 item", @@ -174,6 +176,7 @@ "library_page_sort_title": "Album title", "login_disabled": "Login has been disabled", "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", "login_form_endpoint_hint": "http://your-server-ip:port/api", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 07dc981393..2506e9b47a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -29,6 +29,7 @@ import 'package:immich_mobile/shared/services/immich_logger.service.dart'; import 'package:immich_mobile/shared/services/local_notification.service.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/version_announcement_overlay.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/immich_app_theme.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:isar/isar.dart'; @@ -41,6 +42,7 @@ void main() async { final db = await loadDb(); await initApp(); await migrateDatabaseIfNeeded(db); + HttpOverrides.global = HttpSSLCertOverride(); runApp(getMainWidget(db)); } diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index f50e6661b7..7622b0b53f 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -88,6 +89,16 @@ class LoginForm extends HookConsumerWidget { isPasswordLoginEnable.value = true; isLoadingServer.value = false; return false; + } on HandshakeException { + ImmichToast.show( + context: context, + msg: 'login_form_handshake_exception'.tr(), + toastType: ToastType.error, + ); + isOauthEnable.value = false; + isPasswordLoginEnable.value = true; + isLoadingServer.value = false; + return false; } catch (e) { ImmichToast.show( context: context, @@ -226,6 +237,7 @@ class LoginForm extends HookConsumerWidget { } buildSelectServer() { + const buttonRadius = 25.0; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -235,24 +247,51 @@ class LoginForm extends HookConsumerWidget { onSubmit: getServerLoginCredential, ), const SizedBox(height: 18), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: isLoadingServer.value ? null : getServerLoginCredential, - icon: const Icon(Icons.arrow_forward_rounded), - label: const Text( - 'login_form_next_button', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ).tr(), - ), - if (isLoadingServer.value) - const Padding( - padding: EdgeInsets.only(top: 18.0), - child: Center( - child: CircularProgressIndicator(), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(buttonRadius), + bottomLeft: Radius.circular(buttonRadius), + ), + ), + ), + onPressed: () => + AutoRouter.of(context).push(const SettingsRoute()), + icon: const Icon(Icons.settings_rounded), + label: const SizedBox.shrink(), + ), ), - ), + const SizedBox(width: 1), + Expanded( + flex: 3, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(buttonRadius), + bottomRight: Radius.circular(buttonRadius), + ), + ), + ), + onPressed: + isLoadingServer.value ? null : getServerLoginCredential, + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text( + 'login_form_next_button', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ), + ), + ], + ), + const SizedBox(height: 18), + if (isLoadingServer.value) const LoadingIcon(), ], ); } @@ -285,18 +324,7 @@ class LoginForm extends HookConsumerWidget { // Note: This used to have an AnimatedSwitcher, but was removed // because of https://github.com/flutter/flutter/issues/120874 isLoading.value - ? const Padding( - padding: EdgeInsets.only(top: 18.0), - child: SizedBox( - width: 24, - height: 24, - child: FittedBox( - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ), - ) + ? const LoadingIcon() : Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center, @@ -572,3 +600,23 @@ class OAuthLoginButton extends ConsumerWidget { ); } } + +class LoadingIcon extends StatelessWidget { + const LoadingIcon({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(top: 18.0), + child: SizedBox( + width: 24, + height: 24, + child: FittedBox( + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart index 7ad93ea08a..774ee1b2f7 100644 --- a/mobile/lib/modules/settings/services/app_settings.service.dart +++ b/mobile/lib/modules/settings/services/app_settings.service.dart @@ -49,6 +49,7 @@ enum AppSettingsEnum { mapThemeMode(StoreKey.mapThemeMode, null, false), mapShowFavoriteOnly(StoreKey.mapShowFavoriteOnly, null, false), mapRelativeDate(StoreKey.mapRelativeDate, null, 0), + allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart b/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart index 838bb46602..3c8d47d1e0 100644 --- a/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart +++ b/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart @@ -1,23 +1,29 @@ +import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart'; import 'package:immich_mobile/shared/services/immich_logger.service.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:logging/logging.dart'; class AdvancedSettings extends HookConsumerWidget { const AdvancedSettings({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null; final appSettingService = ref.watch(appSettingsServiceProvider); final isEnabled = useState(AppSettingsEnum.advancedTroubleshooting.defaultValue); final levelId = useState(AppSettingsEnum.logLevel.defaultValue); final preferRemote = useState(AppSettingsEnum.preferRemoteImage.defaultValue); + final allowSelfSignedSSLCert = + useState(AppSettingsEnum.allowSelfSignedSSLCert.defaultValue); useEffect( () { @@ -27,6 +33,8 @@ class AdvancedSettings extends HookConsumerWidget { levelId.value = appSettingService.getSetting(AppSettingsEnum.logLevel); preferRemote.value = appSettingService.getSetting(AppSettingsEnum.preferRemoteImage); + allowSelfSignedSSLCert.value = appSettingService + .getSetting(AppSettingsEnum.allowSelfSignedSSLCert); return null; }, [], @@ -88,6 +96,17 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_prefer_remote_title".tr(), subtitle: "advanced_settings_prefer_remote_subtitle".tr(), ), + SettingsSwitchListTile( + enabled: !isLoggedIn, + appSettingService: appSettingService, + valueNotifier: allowSelfSignedSSLCert, + settingsEnum: AppSettingsEnum.allowSelfSignedSSLCert, + title: "advanced_settings_self_signed_ssl_title".tr(), + subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(), + onChanged: (value) { + HttpOverrides.global = HttpSSLCertOverride(); + }, + ), ], ); } diff --git a/mobile/lib/modules/settings/ui/settings_switch_list_tile.dart b/mobile/lib/modules/settings/ui/settings_switch_list_tile.dart index 003d94c3f4..c6fff19f69 100644 --- a/mobile/lib/modules/settings/ui/settings_switch_list_tile.dart +++ b/mobile/lib/modules/settings/ui/settings_switch_list_tile.dart @@ -8,6 +8,7 @@ class SettingsSwitchListTile extends StatelessWidget { final String title; final bool enabled; final String? subtitle; + final Function(bool)? onChanged; SettingsSwitchListTile({ required this.appSettingService, @@ -16,19 +17,26 @@ class SettingsSwitchListTile extends StatelessWidget { required this.title, this.subtitle, this.enabled = true, + this.onChanged, }) : super(key: Key(settingsEnum.name)); @override Widget build(BuildContext context) { return SwitchListTile.adaptive( + selectedTileColor: enabled ? null : Theme.of(context).disabledColor, value: valueNotifier.value, - onChanged: !enabled - ? null - : (value) { - valueNotifier.value = value; - appSettingService.setSetting(settingsEnum, value); - }, - activeColor: Theme.of(context).primaryColor, + onChanged: (bool value) { + if (enabled) { + valueNotifier.value = value; + appSettingService.setSetting(settingsEnum, value); + } + if (onChanged != null) { + onChanged!(value); + } + }, + activeColor: enabled + ? Theme.of(context).primaryColor + : Theme.of(context).disabledColor, dense: true, title: Text( title, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 56885aeaf3..9b2592e46a 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -125,7 +125,6 @@ part 'router.gr.dart'; AutoRoute( page: SettingsPage, guards: [ - AuthGuard, DuplicateGuard, ], ), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index bd385fc8e5..87c362ad83 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -550,10 +550,7 @@ class _$AppRouter extends RootStackRouter { RouteConfig( SettingsRoute.name, path: '/settings-page', - guards: [ - authGuard, - duplicateGuard, - ], + guards: [duplicateGuard], ), RouteConfig( AppLogRoute.name, diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart index f67b2b4115..54b0c595cb 100644 --- a/mobile/lib/shared/models/store.dart +++ b/mobile/lib/shared/models/store.dart @@ -178,6 +178,7 @@ enum StoreKey { mapThemeMode(117, type: bool), mapShowFavoriteOnly(118, type: bool), mapRelativeDate(119, type: int), + selfSignedCert(120, type: bool), ; const StoreKey( diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart new file mode 100644 index 0000000000..7a36a2e83b --- /dev/null +++ b/mobile/lib/utils/http_ssl_cert_override.dart @@ -0,0 +1,37 @@ +import 'dart:io'; +import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:logging/logging.dart'; + +class HttpSSLCertOverride extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = (X509Certificate cert, String host, int port) { + var log = Logger("HttpSSLCertOverride"); + + AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; + + // Check if user has allowed self signed SSL certificates. + bool selfSignedCertsAllowed = + Store.get(setting.storeKey as StoreKey, setting.defaultValue); + + bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null; + + // Conduct server host checks if user is logged in to avoid making + // insecure SSL connections to services that are not the immich server. + if (isLoggedIn && selfSignedCertsAllowed) { + String serverHost = + Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; + + selfSignedCertsAllowed &= serverHost.contains(host); + } + + if (!selfSignedCertsAllowed) { + log.severe("Invalid SSL certificate for $host:$port"); + } + + return selfSignedCertsAllowed; + }; + } +}