Selaa lähdekoodia

[auth][mob] Add recovery support for passkey (#1013)

## Description

## Tests
✅  Verified that reset flow is working fine on both auth and photos app.
Neeraj Gupta 1 vuosi sitten
vanhempi
commit
297148dc67

+ 3 - 2
auth/lib/l10n/arb/app_en.arb

@@ -144,7 +144,8 @@
   "enterCodeHint": "Enter the 6-digit code from\nyour authenticator app",
   "lostDeviceTitle": "Lost device?",
   "twoFactorAuthTitle": "Two-factor authentication",
-  "passkeyAuthTitle": "Passkey authentication",
+  "passkeyAuthTitle": "Passkey verification",
+  "verifyPasskey": "Verify passkey",
   "recoverAccount": "Recover account",
   "enterRecoveryKeyHint": "Enter your recovery key",
   "recover": "Recover",
@@ -407,7 +408,7 @@
   "hearUsWhereTitle": "How did you hear about Ente? (optional)",
   "hearUsExplanation": "We don't track app installs. It'd help if you told us where you found us!",
   "waitingForBrowserRequest": "Waiting for browser request...",
-  "launchPasskeyUrlAgain": "Launch passkey URL again",
+  "waitingForVerification": "Waiting for verification...",
   "passkey": "Passkey",
   "developerSettingsWarning":"Are you sure that you want to modify Developer settings?",
   "developerSettings": "Developer settings",

+ 13 - 0
auth/lib/models/account/two_factor.dart

@@ -0,0 +1,13 @@
+enum TwoFactorType { totp, passkey }
+
+// ToString for TwoFactorType
+String twoFactorTypeToString(TwoFactorType type) {
+  switch (type) {
+    case TwoFactorType.totp:
+      return "totp";
+    case TwoFactorType.passkey:
+      return "passkey";
+    default:
+      return type.name;
+  }
+}

+ 22 - 0
auth/lib/services/passkey_service.dart

@@ -17,6 +17,28 @@ class PasskeyService {
     return response.data!["accountsToken"] as String;
   }
 
+  Future<bool> isPasskeyRecoveryEnabled() async {
+    final response = await _enteDio.get(
+      "/users/two-factor/recovery-status",
+    );
+    return response.data!["isPasskeyRecoveryEnabled"] as bool;
+  }
+
+  Future<void> configurePasskeyRecovery(
+    String secret,
+    String userEncryptedSecret,
+    String userSecretNonce,
+  ) async {
+    await _enteDio.post(
+      "/users/two-factor/passkeys/configure-recovery",
+      data: {
+        "secret": secret,
+        "userSecretCipher": userEncryptedSecret,
+        "userSecretNonce": userSecretNonce,
+      },
+    );
+  }
+
   Future<void> openPasskeyPage(BuildContext context) async {
     try {
       final jwtToken = await getJwtToken();

+ 14 - 1
auth/lib/services/user_service.dart

@@ -11,6 +11,7 @@ import 'package:ente_auth/core/event_bus.dart';
 import 'package:ente_auth/core/network.dart';
 import 'package:ente_auth/events/user_details_changed_event.dart';
 import 'package:ente_auth/l10n/l10n.dart';
+import 'package:ente_auth/models/account/two_factor.dart';
 import 'package:ente_auth/models/api/user/srp.dart';
 import 'package:ente_auth/models/delete_account.dart';
 import 'package:ente_auth/models/key_attributes.dart';
@@ -762,7 +763,11 @@ class UserService {
     }
   }
 
-  Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
+  Future<void> recoverTwoFactor(
+    BuildContext context,
+    String sessionID,
+    TwoFactorType type,
+  ) async {
     final dialog = createProgressDialog(context, context.l10n.pleaseWait);
     await dialog.show();
     try {
@@ -770,6 +775,7 @@ class UserService {
         _config.getHttpEndpoint() + "/users/two-factor/recover",
         queryParameters: {
           "sessionID": sessionID,
+          "twoFactorType": twoFactorTypeToString(type),
         },
       );
       if (response.statusCode == 200) {
@@ -778,6 +784,7 @@ class UserService {
           MaterialPageRoute(
             builder: (BuildContext context) {
               return TwoFactorRecoveryPage(
+                type,
                 sessionID,
                 response.data["encryptedSecret"],
                 response.data["secretDecryptionNonce"],
@@ -788,6 +795,7 @@ class UserService {
         );
       }
     } on DioError catch (e) {
+      await dialog.hide();
       _logger.severe(e);
       if (e.response != null && e.response!.statusCode == 404) {
         showToast(context, context.l10n.sessionExpired);
@@ -809,6 +817,7 @@ class UserService {
         );
       }
     } catch (e) {
+      await dialog.hide();
       _logger.severe(e);
       // ignore: unawaited_futures
       showErrorDialog(
@@ -823,6 +832,7 @@ class UserService {
 
   Future<void> removeTwoFactor(
     BuildContext context,
+    TwoFactorType type,
     String sessionID,
     String recoveryKey,
     String encryptedSecret,
@@ -862,6 +872,7 @@ class UserService {
         data: {
           "sessionID": sessionID,
           "secret": secret,
+          "twoFactorType": twoFactorTypeToString(type),
         },
       );
       if (response.statusCode == 200) {
@@ -881,6 +892,7 @@ class UserService {
         );
       }
     } on DioError catch (e) {
+      await dialog.hide();
       _logger.severe(e);
       if (e.response != null && e.response!.statusCode == 404) {
         showToast(context, "Session expired");
@@ -902,6 +914,7 @@ class UserService {
         );
       }
     } catch (e) {
+      await dialog.hide();
       _logger.severe(e);
       // ignore: unawaited_futures
       showErrorDialog(

+ 44 - 22
auth/lib/ui/passkey_page.dart

@@ -1,9 +1,11 @@
 import 'dart:convert';
 
 import 'package:ente_auth/core/configuration.dart';
-import 'package:ente_auth/ente_theme_data.dart';
 import 'package:ente_auth/l10n/l10n.dart';
+import 'package:ente_auth/models/account/two_factor.dart';
 import 'package:ente_auth/services/user_service.dart';
+import 'package:ente_auth/ui/components/buttons/button_widget.dart';
+import 'package:ente_auth/ui/components/models/button_type.dart';
 import 'package:ente_auth/utils/dialog_util.dart';
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
@@ -99,30 +101,50 @@ class _PasskeyPageState extends State<PasskeyPage> {
   }
 
   Widget _getBody() {
-    final l10n = context.l10n;
-
     return Center(
-      child: Column(
-        mainAxisAlignment: MainAxisAlignment.center,
-        children: [
-          Text(
-            l10n.waitingForBrowserRequest,
-            style: const TextStyle(
-              height: 1.4,
-              fontSize: 16,
+      child: Padding(
+        padding: const EdgeInsets.symmetric(horizontal: 32),
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            Text(
+              context.l10n.waitingForVerification,
+              style: const TextStyle(
+                height: 1.4,
+                fontSize: 16,
+              ),
             ),
-          ),
-          const SizedBox(height: 16),
-          Container(
-            width: double.infinity,
-            padding: const EdgeInsets.symmetric(horizontal: 32),
-            child: ElevatedButton(
-              style: Theme.of(context).colorScheme.optionalActionButtonStyle,
-              onPressed: launchPasskey,
-              child: Text(l10n.launchPasskeyUrlAgain),
+            const SizedBox(height: 16),
+            ButtonWidget(
+              buttonType: ButtonType.primary,
+              labelText: context.l10n.verifyPasskey,
+              onTap: () => launchPasskey(),
             ),
-          ),
-        ],
+            const Padding(padding: EdgeInsets.all(30)),
+            GestureDetector(
+              behavior: HitTestBehavior.opaque,
+              onTap: () {
+                UserService.instance.recoverTwoFactor(
+                  context,
+                  widget.sessionID,
+                  TwoFactorType.passkey,
+                );
+              },
+              child: Container(
+                padding: const EdgeInsets.all(10),
+                child: Center(
+                  child: Text(
+                    context.l10n.recoverAccount,
+                    style: const TextStyle(
+                      decoration: TextDecoration.underline,
+                      fontSize: 12,
+                    ),
+                  ),
+                ),
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }

+ 28 - 1
auth/lib/ui/settings/security_section_widget.dart

@@ -21,6 +21,7 @@ import 'package:ente_auth/utils/dialog_util.dart';
 import 'package:ente_auth/utils/navigation_util.dart';
 import 'package:ente_auth/utils/toast_util.dart';
 import 'package:flutter/material.dart';
+import 'package:logging/logging.dart';
 
 class SecuritySectionWidget extends StatefulWidget {
   const SecuritySectionWidget({Key? key}) : super(key: key);
@@ -32,6 +33,7 @@ class SecuritySectionWidget extends StatefulWidget {
 class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
   final _config = Configuration.instance;
   late bool _hasLoggedIn;
+  final Logger _logger = Logger('SecuritySectionWidget');
 
   @override
   void initState() {
@@ -75,7 +77,7 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
             pressedColor: getEnteColorScheme(context).fillFaint,
             trailingIcon: Icons.chevron_right_outlined,
             trailingIconIsMuted: true,
-            onTap: () => PasskeyService.instance.openPasskeyPage(context),
+            onTap: () async => await onPasskeyClick(context),
           ),
         sectionOptionSpacing,
         MenuItemWidget(
@@ -159,6 +161,31 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
     );
   }
 
+  Future<void> onPasskeyClick(BuildContext buildContext) async {
+    try {
+      final isPassKeyResetEnabled =
+          await PasskeyService.instance.isPasskeyRecoveryEnabled();
+      if (!isPassKeyResetEnabled) {
+        final Uint8List recoveryKey = Configuration.instance.getRecoveryKey();
+        final resetKey = CryptoUtil.generateKey();
+        final resetKeyBase64 = CryptoUtil.bin2base64(resetKey);
+        final encryptionResult = CryptoUtil.encryptSync(
+          resetKey,
+          recoveryKey,
+        );
+        await PasskeyService.instance.configurePasskeyRecovery(
+          resetKeyBase64,
+          CryptoUtil.bin2base64(encryptionResult.encryptedData!),
+          CryptoUtil.bin2base64(encryptionResult.nonce!),
+        );
+      }
+      PasskeyService.instance.openPasskeyPage(buildContext).ignore();
+    } catch (e, s) {
+      _logger.severe("failed to open passkey page", e, s);
+      await showGenericErrorDialog(context: context);
+    }
+  }
+
   Future<void> updateEmailMFA(bool enableEmailMFA) async {
     try {
       final UserDetails details =

+ 6 - 1
auth/lib/ui/two_factor_authentication_page.dart

@@ -1,4 +1,5 @@
 import 'package:ente_auth/l10n/l10n.dart';
+import 'package:ente_auth/models/account/two_factor.dart';
 import 'package:ente_auth/services/user_service.dart';
 import 'package:ente_auth/ui/lifecycle_event_handler.dart';
 import 'package:flutter/material.dart';
@@ -129,7 +130,11 @@ class _TwoFactorAuthenticationPageState
         GestureDetector(
           behavior: HitTestBehavior.opaque,
           onTap: () {
-            UserService.instance.recoverTwoFactor(context, widget.sessionID);
+            UserService.instance.recoverTwoFactor(
+              context,
+              widget.sessionID,
+              TwoFactorType.totp,
+            );
           },
           child: Container(
             padding: const EdgeInsets.all(10),

+ 4 - 0
auth/lib/ui/two_factor_recovery_page.dart

@@ -1,6 +1,7 @@
 import 'dart:ui';
 
 import 'package:ente_auth/l10n/l10n.dart';
+import 'package:ente_auth/models/account/two_factor.dart';
 import 'package:ente_auth/services/user_service.dart';
 import 'package:ente_auth/utils/dialog_util.dart';
 import 'package:flutter/material.dart';
@@ -9,8 +10,10 @@ class TwoFactorRecoveryPage extends StatefulWidget {
   final String sessionID;
   final String encryptedSecret;
   final String secretDecryptionNonce;
+  final TwoFactorType type;
 
   const TwoFactorRecoveryPage(
+    this.type,
     this.sessionID,
     this.encryptedSecret,
     this.secretDecryptionNonce, {
@@ -72,6 +75,7 @@ class _TwoFactorRecoveryPageState extends State<TwoFactorRecoveryPage> {
                   ? () async {
                       await UserService.instance.removeTwoFactor(
                         context,
+                        widget.type,
                         widget.sessionID,
                         _recoveryKey.text,
                         widget.encryptedSecret,

+ 0 - 2
mobile/lib/generated/intl/messages_en.dart

@@ -800,8 +800,6 @@ class MessageLookup extends MessageLookupByLibrary {
             "Kindly help us with this information"),
         "language": MessageLookupByLibrary.simpleMessage("Language"),
         "lastUpdated": MessageLookupByLibrary.simpleMessage("Last updated"),
-        "launchPasskeyUrlAgain":
-            MessageLookupByLibrary.simpleMessage("Launch passkey URL again"),
         "leave": MessageLookupByLibrary.simpleMessage("Leave"),
         "leaveAlbum": MessageLookupByLibrary.simpleMessage("Leave album"),
         "leaveFamily": MessageLookupByLibrary.simpleMessage("Leave family"),

+ 0 - 2
mobile/lib/generated/intl/messages_pt.dart

@@ -821,8 +821,6 @@ class MessageLookup extends MessageLookupByLibrary {
         "language": MessageLookupByLibrary.simpleMessage("Idioma"),
         "lastUpdated":
             MessageLookupByLibrary.simpleMessage("Última atualização"),
-        "launchPasskeyUrlAgain": MessageLookupByLibrary.simpleMessage(
-            "Iniciar a URL de chave de acesso novamente"),
         "leave": MessageLookupByLibrary.simpleMessage("Sair"),
         "leaveAlbum": MessageLookupByLibrary.simpleMessage("Sair do álbum"),
         "leaveFamily": MessageLookupByLibrary.simpleMessage("Sair da família"),

+ 0 - 2
mobile/lib/generated/intl/messages_zh.dart

@@ -674,8 +674,6 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("请帮助我们了解这个信息"),
         "language": MessageLookupByLibrary.simpleMessage("语言"),
         "lastUpdated": MessageLookupByLibrary.simpleMessage("最后更新"),
-        "launchPasskeyUrlAgain":
-            MessageLookupByLibrary.simpleMessage("再次启动 通行密钥 URL"),
         "leave": MessageLookupByLibrary.simpleMessage("离开"),
         "leaveAlbum": MessageLookupByLibrary.simpleMessage("离开相册"),
         "leaveFamily": MessageLookupByLibrary.simpleMessage("离开家庭计划"),

+ 0 - 10
mobile/lib/generated/l10n.dart

@@ -8318,16 +8318,6 @@ class S {
     );
   }
 
-  /// `Launch passkey URL again`
-  String get launchPasskeyUrlAgain {
-    return Intl.message(
-      'Launch passkey URL again',
-      name: 'launchPasskeyUrlAgain',
-      desc: '',
-      args: [],
-    );
-  }
-
   /// `Passkey`
   String get passkey {
     return Intl.message(

+ 0 - 1
mobile/lib/l10n/intl_en.arb

@@ -1189,7 +1189,6 @@
   "cleanUncategorized": "Clean Uncategorized",
   "cleanUncategorizedDescription": "Remove all files from Uncategorized that are present in other albums",
   "waitingForVerification": "Waiting for verification...",
-  "launchPasskeyUrlAgain": "Launch passkey URL again",
   "passkey": "Passkey",
   "passkeyAuthTitle": "Passkey verification",
   "verifyPasskey": "Verify passkey",

+ 1 - 1
mobile/lib/services/user_service.dart

@@ -916,7 +916,7 @@ class UserService {
         _config.getHttpEndpoint() + "/users/two-factor/remove",
         data: {
           "sessionID": sessionID,
-          "secret": utf8.decode(base64.decode(secret)),
+          "secret": secret,
           "twoFactorType": twoFactorTypeToString(type),
         },
       );

+ 4 - 7
mobile/lib/ui/settings/security_section_widget.dart

@@ -1,5 +1,4 @@
 import 'dart:async';
-import "dart:convert";
 import "dart:typed_data";
 
 import 'package:flutter/material.dart';
@@ -27,7 +26,6 @@ import "package:photos/utils/crypto_util.dart";
 import "package:photos/utils/dialog_util.dart";
 import "package:photos/utils/navigation_util.dart";
 import "package:photos/utils/toast_util.dart";
-import "package:uuid/uuid.dart";
 
 class SecuritySectionWidget extends StatefulWidget {
   const SecuritySectionWidget({Key? key}) : super(key: key);
@@ -243,15 +241,14 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
       if (!isPassKeyResetEnabled) {
         final Uint8List recoveryKey =
             await UserService.instance.getOrCreateRecoveryKey(context);
-        final resetSecret = const Uuid().v4().toString();
-        final bytes = utf8.encode(resetSecret);
-        final base64Str = base64.encode(bytes);
+        final resetKey = CryptoUtil.generateKey();
+        final resetKeyBase64 = CryptoUtil.bin2base64(resetKey);
         final encryptionResult = CryptoUtil.encryptSync(
-          CryptoUtil.base642bin(base64Str),
+          resetKey,
           recoveryKey,
         );
         await PasskeyService.instance.configurePasskeyRecovery(
-          resetSecret,
+          resetKeyBase64,
           CryptoUtil.bin2base64(encryptionResult.encryptedData!),
           CryptoUtil.bin2base64(encryptionResult.nonce!),
         );