ソースを参照

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 <alex.tran1502@gmail.com>
Dhrumil Shah 1 年間 前
コミット
fb20381f98

+ 3 - 0
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",

+ 2 - 0
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));
 }
 

+ 77 - 29
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,
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 1 - 0
mobile/lib/modules/settings/services/app_settings.service.dart

@@ -49,6 +49,7 @@ enum AppSettingsEnum<T> {
   mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
   mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
   mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
+  allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
   ;
 
   const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

+ 20 - 1
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();
+          },
+        ),
       ],
     );
   }

+ 15 - 7
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,

+ 0 - 1
mobile/lib/routing/router.dart

@@ -125,7 +125,6 @@ part 'router.gr.dart';
     AutoRoute(
       page: SettingsPage,
       guards: [
-        AuthGuard,
         DuplicateGuard,
       ],
     ),

+ 1 - 4
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,

+ 1 - 0
mobile/lib/shared/models/store.dart

@@ -178,6 +178,7 @@ enum StoreKey<T> {
   mapThemeMode<bool>(117, type: bool),
   mapShowFavoriteOnly<bool>(118, type: bool),
   mapRelativeDate<int>(119, type: int),
+  selfSignedCert<bool>(120, type: bool),
   ;
 
   const StoreKey(

+ 37 - 0
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<bool>, 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;
+      };
+  }
+}