diff --git a/auth/flutter b/auth/flutter index ba39319843..761747bfc5 160000 --- a/auth/flutter +++ b/auth/flutter @@ -1 +1 @@ -Subproject commit ba393198430278b6595976de84fe170f553cc728 +Subproject commit 761747bfc538b5af34aa0d3fac380f1bc331ec49 diff --git a/auth/lib/core/errors.dart b/auth/lib/core/errors.dart index ba1310b6ca..9e36301907 100644 --- a/auth/lib/core/errors.dart +++ b/auth/lib/core/errors.dart @@ -42,3 +42,7 @@ class InvalidStateError extends AssertionError { class SrpSetupNotCompleteError extends Error {} class AuthenticatorKeyNotFound extends Error {} + +class PassKeySessionNotVerifiedError extends Error {} + +class PassKeySessionExpiredError extends Error {} diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index e4d1a07a50..58ad079a0b 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -269,6 +269,7 @@ "privacy": "Privacy", "terms": "Terms", "checkForUpdates": "Check for updates", + "checkStatus": "Check status", "downloadUpdate": "Download", "criticalUpdateAvailable": "Critical update available", "updateAvailable": "Update available", @@ -417,6 +418,9 @@ "waitingForBrowserRequest": "Waiting for browser request...", "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", + "passKeyPendingVerification": "Verification is still pending", + "loginSessionExpired" : "Session expired", + "loginSessionExpiredDetails": "Your session has expired. Please login again.", "developerSettingsWarning":"Are you sure that you want to modify Developer settings?", "developerSettings": "Developer settings", "serverEndpoint": "Server endpoint", diff --git a/auth/lib/services/user_service.dart b/auth/lib/services/user_service.dart index bd411da47b..4f58c670b1 100644 --- a/auth/lib/services/user_service.dart +++ b/auth/lib/services/user_service.dart @@ -266,6 +266,31 @@ class UserService { } } + Future getTokenForPasskeySession(String sessionID) async { + try { + final response = await _dio.get( + "${_config.getHttpEndpoint()}/users/two-factor/passkeys/get-token", + queryParameters: { + "sessionID": sessionID, + }, + ); + return response.data; + } on DioException catch (e) { + if (e.response != null) { + if (e.response!.statusCode == 404 || e.response!.statusCode == 410) { + throw PassKeySessionExpiredError(); + } + if (e.response!.statusCode == 400) { + throw PassKeySessionNotVerifiedError(); + } + } + rethrow; + } catch (e, s) { + _logger.severe("unexpected error", e, s); + rethrow; + } + } + Future onPassKeyVerified(BuildContext context, Map response) async { final ProgressDialog dialog = createProgressDialog(context, context.l10n.pleaseWait); diff --git a/auth/lib/ui/components/models/button_type.dart b/auth/lib/ui/components/models/button_type.dart index 8b9647c07f..2122769d63 100644 --- a/auth/lib/ui/components/models/button_type.dart +++ b/auth/lib/ui/components/models/button_type.dart @@ -33,7 +33,7 @@ enum ButtonType { Color defaultButtonColor(EnteColorScheme colorScheme) { if (isPrimary) { - return colorScheme.primary500; + return colorScheme.primary400; } if (isSecondary) { return colorScheme.fillFaint; diff --git a/auth/lib/ui/passkey_page.dart b/auth/lib/ui/passkey_page.dart index 38c6eb396a..eab0b72f99 100644 --- a/auth/lib/ui/passkey_page.dart +++ b/auth/lib/ui/passkey_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:app_links/app_links.dart'; import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/errors.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'; @@ -50,6 +51,30 @@ class _PasskeyPageState extends State { ); } + Future checkStatus() async { + late dynamic response; + try { + response = await UserService.instance + .getTokenForPasskeySession(widget.sessionID); + } on PassKeySessionNotVerifiedError { + showToast(context, context.l10n.passKeyPendingVerification); + return; + } on PassKeySessionExpiredError { + await showErrorDialog( + context, + context.l10n.loginSessionExpired, + context.l10n.loginSessionExpiredDetails, + ); + Navigator.of(context).pop(); + return; + } catch (e, s) { + _logger.severe("failed to check status", e, s); + showGenericErrorDialog(context: context).ignore(); + return; + } + await UserService.instance.onPassKeyVerified(context, response); + } + Future _handleDeeplink(String? link) async { if (!context.mounted || Configuration.instance.hasConfiguredAccount() || @@ -66,8 +91,15 @@ class _PasskeyPageState extends State { showToast(context, 'Account is already configured.'); return; } - final String? uri = Uri.parse(link).queryParameters['response']; - String base64String = uri!.toString(); + final parsedUri = Uri.parse(link); + final sessionID = parsedUri.queryParameters['passkeySessionID']; + if (sessionID != widget.sessionID) { + showToast(context, "Session ID mismatch"); + _logger.warning('ignored deeplink: sessionID mismatch'); + return; + } + final String? authResponse = parsedUri.queryParameters['response']; + String base64String = authResponse!.toString(); while (base64String.length % 4 != 0) { base64String += '='; } @@ -125,9 +157,23 @@ class _PasskeyPageState extends State { const SizedBox(height: 16), ButtonWidget( buttonType: ButtonType.primary, - labelText: context.l10n.verifyPasskey, + labelText: context.l10n.tryAgain, onTap: () => launchPasskey(), ), + const SizedBox(height: 16), + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: context.l10n.checkStatus, + onTap: () async { + try { + await checkStatus(); + } catch (e) { + debugPrint('failed to check status %e'); + showGenericErrorDialog(context: context).ignore(); + } + }, + shouldSurfaceExecutionStates: true, + ), const Padding(padding: EdgeInsets.all(30)), GestureDetector( behavior: HitTestBehavior.opaque, diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 4e50d38fbd..49672b0368 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/user_details.dart'; -import 'package:ente_auth/services/auth_feature_flag.dart'; import 'package:ente_auth/services/local_authentication_service.dart'; import 'package:ente_auth/services/passkey_service.dart'; import 'package:ente_auth/services/user_service.dart'; @@ -66,20 +65,17 @@ class _SecuritySectionWidgetState extends State { // We don't know if the user can disable MFA yet, so we fetch the info UserService.instance.getUserDetailsV2().ignore(); } - final bool isInternalUser = - FeatureFlagService.instance.isInternalUserOrDebugBuild(); children.addAll([ - if (isInternalUser) sectionOptionSpacing, - if (isInternalUser) - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.passkey, ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index c69c79a06c..9a8bb20d50 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 3.0.9+309 +version: 3.0.10+310 publish_to: none environment: diff --git a/mobile/lib/core/errors.dart b/mobile/lib/core/errors.dart index d39f6f027f..f1c7c24b3c 100644 --- a/mobile/lib/core/errors.dart +++ b/mobile/lib/core/errors.dart @@ -81,3 +81,7 @@ class SrpSetupNotCompleteError extends Error {} class SharingNotPermittedForFreeAccountsError extends Error {} class NoMediaLocationAccessError extends Error {} + +class PassKeySessionNotVerifiedError extends Error {} + +class PassKeySessionExpiredError extends Error {} diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index a85890ee51..a8f43dd4b9 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -418,6 +418,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Check for updates"), "checkInboxAndSpamFolder": MessageLookupByLibrary.simpleMessage( "Please check your inbox (and spam) to complete verification"), + "checkStatus": MessageLookupByLibrary.simpleMessage("Check status"), "checking": MessageLookupByLibrary.simpleMessage("Checking..."), "claimFreeStorage": MessageLookupByLibrary.simpleMessage("Claim free storage"), @@ -908,6 +909,10 @@ class MessageLookup extends MessageLookupByLibrary { "lockscreen": MessageLookupByLibrary.simpleMessage("Lockscreen"), "logInLabel": MessageLookupByLibrary.simpleMessage("Log in"), "loggingOut": MessageLookupByLibrary.simpleMessage("Logging out..."), + "loginSessionExpired": + MessageLookupByLibrary.simpleMessage("Session expired"), + "loginSessionExpiredDetails": MessageLookupByLibrary.simpleMessage( + "Your session has expired. Please login again."), "loginTerms": MessageLookupByLibrary.simpleMessage( "By clicking log in, I agree to the terms of service and privacy policy"), "logout": MessageLookupByLibrary.simpleMessage("Logout"), @@ -1020,6 +1025,8 @@ class MessageLookup extends MessageLookupByLibrary { "pairWithPin": MessageLookupByLibrary.simpleMessage("Pair with PIN"), "pairingComplete": MessageLookupByLibrary.simpleMessage("Pairing complete"), + "passKeyPendingVerification": MessageLookupByLibrary.simpleMessage( + "Verification is still pending"), "passkey": MessageLookupByLibrary.simpleMessage("Passkey"), "passkeyAuthTitle": MessageLookupByLibrary.simpleMessage("Passkey verification"), diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 8a2e67478b..5bc50bc222 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -3162,6 +3162,16 @@ class S { ); } + /// `Check status` + String get checkStatus { + return Intl.message( + 'Check status', + name: 'checkStatus', + desc: '', + args: [], + ); + } + /// `Checking...` String get checking { return Intl.message( @@ -8408,6 +8418,36 @@ class S { ); } + /// `Verification is still pending` + String get passKeyPendingVerification { + return Intl.message( + 'Verification is still pending', + name: 'passKeyPendingVerification', + desc: '', + args: [], + ); + } + + /// `Session expired` + String get loginSessionExpired { + return Intl.message( + 'Session expired', + name: 'loginSessionExpired', + desc: '', + args: [], + ); + } + + /// `Your session has expired. Please login again.` + String get loginSessionExpiredDetails { + return Intl.message( + 'Your session has expired. Please login again.', + name: 'loginSessionExpiredDetails', + desc: '', + args: [], + ); + } + /// `Verify passkey` String get verifyPasskey { return Intl.message( diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index 745d00ece0..6fa3be30e5 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -451,6 +451,7 @@ "privacy": "Privacy", "terms": "Terms", "checkForUpdates": "Check for updates", + "checkStatus": "Check status", "checking": "Checking...", "youAreOnTheLatestVersion": "You are on the latest version", "account": "Account", @@ -1198,6 +1199,9 @@ "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", "passkeyAuthTitle": "Passkey verification", + "passKeyPendingVerification": "Verification is still pending", + "loginSessionExpired" : "Session expired", + "loginSessionExpiredDetails": "Your session has expired. Please login again.", "verifyPasskey": "Verify passkey", "playOnTv": "Play album on TV", "pair": "Pair", diff --git a/mobile/lib/services/user_service.dart b/mobile/lib/services/user_service.dart index 44e098567c..511627ba9a 100644 --- a/mobile/lib/services/user_service.dart +++ b/mobile/lib/services/user_service.dart @@ -16,6 +16,7 @@ import "package:photos/events/account_configured_event.dart"; import 'package:photos/events/two_factor_status_change_event.dart'; import 'package:photos/events/user_details_changed_event.dart'; import "package:photos/generated/l10n.dart"; +import "package:photos/l10n/l10n.dart"; import "package:photos/models/account/two_factor.dart"; import "package:photos/models/api/user/srp.dart"; import 'package:photos/models/delete_account.dart'; @@ -308,23 +309,57 @@ class UserService { } } - Future onPassKeyVerified(BuildContext context, Map response) async { - final userPassword = Configuration.instance.getVolatilePassword(); - if (userPassword == null) throw Exception("volatile password is null"); - - await _saveConfiguration(response); - - if (Configuration.instance.getEncryptedToken() != null) { - await Configuration.instance.decryptSecretsAndGetKeyEncKey( - userPassword, - Configuration.instance.getKeyAttributes()!, + Future getTokenForPasskeySession(String sessionID) async { + try { + final response = await _dio.get( + "${_config.getHttpEndpoint()}/users/two-factor/passkeys/get-token", + queryParameters: { + "sessionID": sessionID, + }, ); - } else { - throw Exception("unexpected response during passkey verification"); + return response.data; + } on DioError catch (e) { + if (e.response != null) { + if (e.response!.statusCode == 404 || e.response!.statusCode == 410) { + throw PassKeySessionExpiredError(); + } + if (e.response!.statusCode == 400) { + throw PassKeySessionNotVerifiedError(); + } + } + rethrow; + } catch (e, s) { + _logger.severe("unexpected error", e, s); + rethrow; } + } - Navigator.of(context).popUntil((route) => route.isFirst); - Bus.instance.fire(AccountConfiguredEvent()); + Future onPassKeyVerified(BuildContext context, Map response) async { + final ProgressDialog dialog = + createProgressDialog(context, context.l10n.pleaseWait); + await dialog.show(); + try { + final userPassword = Configuration.instance.getVolatilePassword(); + if (userPassword == null) throw Exception("volatile password is null"); + + await _saveConfiguration(response); + + if (Configuration.instance.getEncryptedToken() != null) { + await Configuration.instance.decryptSecretsAndGetKeyEncKey( + userPassword, + Configuration.instance.getKeyAttributes()!, + ); + } else { + throw Exception("unexpected response during passkey verification"); + } + await dialog.hide(); + Navigator.of(context).popUntil((route) => route.isFirst); + Bus.instance.fire(AccountConfiguredEvent()); + } catch (e) { + _logger.severe(e); + await dialog.hide(); + await showGenericErrorDialog(context: context, error: e); + } } Future verifyEmail( diff --git a/mobile/lib/ui/account/passkey_page.dart b/mobile/lib/ui/account/passkey_page.dart index c1e0b3edf6..c6e3e00f0f 100644 --- a/mobile/lib/ui/account/passkey_page.dart +++ b/mobile/lib/ui/account/passkey_page.dart @@ -3,13 +3,15 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:photos/core/configuration.dart'; -import "package:photos/generated/l10n.dart"; +import "package:photos/core/errors.dart"; +import "package:photos/l10n/l10n.dart"; import "package:photos/models/account/two_factor.dart"; import 'package:photos/services/user_service.dart'; import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; import "package:photos/utils/dialog_util.dart"; -import 'package:uni_links/uni_links.dart'; +import "package:photos/utils/toast_util.dart"; +import "package:uni_links/uni_links.dart"; import 'package:url_launcher/url_launcher_string.dart'; class PasskeyPage extends StatefulWidget { @@ -17,8 +19,8 @@ class PasskeyPage extends StatefulWidget { const PasskeyPage( this.sessionID, { - Key? key, - }) : super(key: key); + super.key, + }); @override State createState() => _PasskeyPageState(); @@ -49,6 +51,30 @@ class _PasskeyPageState extends State { ); } + Future checkStatus() async { + late dynamic response; + try { + response = await UserService.instance + .getTokenForPasskeySession(widget.sessionID); + } on PassKeySessionNotVerifiedError { + showToast(context, context.l10n.passKeyPendingVerification); + return; + } on PassKeySessionExpiredError { + await showErrorDialog( + context, + context.l10n.loginSessionExpired, + context.l10n.loginSessionExpiredDetails, + ); + Navigator.of(context).pop(); + return; + } catch (e, s) { + _logger.severe("failed to check status", e, s); + showGenericErrorDialog(context: context, error: e).ignore(); + return; + } + await UserService.instance.onPassKeyVerified(context, response); + } + Future _handleDeeplink(String? link) async { if (!context.mounted || Configuration.instance.hasConfiguredAccount() || @@ -60,8 +86,20 @@ class _PasskeyPageState extends State { } try { if (mounted && link.toLowerCase().startsWith("ente://passkey")) { - final String? uri = Uri.parse(link).queryParameters['response']; - String base64String = uri!.toString(); + if (Configuration.instance.isLoggedIn()) { + _logger.info('ignored deeplink: already configured'); + showToast(context, 'Account is already configured.'); + return; + } + final parsedUri = Uri.parse(link); + final sessionID = parsedUri.queryParameters['passkeySessionID']; + if (sessionID != widget.sessionID) { + showToast(context, "Session ID mismatch"); + _logger.warning('ignored deeplink: sessionID mismatch'); + return; + } + final String? authResponse = parsedUri.queryParameters['response']; + String base64String = authResponse!.toString(); while (base64String.length % 4 != 0) { base64String += '='; } @@ -90,10 +128,11 @@ class _PasskeyPageState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Scaffold( appBar: AppBar( title: Text( - S.of(context).passkeyAuthTitle, + l10n.passkeyAuthTitle, ), ), body: _getBody(), @@ -108,7 +147,7 @@ class _PasskeyPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - S.of(context).waitingForVerification, + context.l10n.waitingForVerification, style: const TextStyle( height: 1.4, fontSize: 16, @@ -117,9 +156,23 @@ class _PasskeyPageState extends State { const SizedBox(height: 16), ButtonWidget( buttonType: ButtonType.primary, - labelText: S.of(context).verifyPasskey, + labelText: context.l10n.tryAgain, onTap: () => launchPasskey(), ), + const SizedBox(height: 16), + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: context.l10n.checkStatus, + onTap: () async { + try { + await checkStatus(); + } catch (e) { + debugPrint('failed to check status %e'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + shouldSurfaceExecutionStates: true, + ), const Padding(padding: EdgeInsets.all(30)), GestureDetector( behavior: HitTestBehavior.opaque, @@ -134,7 +187,7 @@ class _PasskeyPageState extends State { padding: const EdgeInsets.all(10), child: Center( child: Text( - S.of(context).recoverAccount, + context.l10n.recoverAccount, style: const TextStyle( decoration: TextDecoration.underline, fontSize: 12, diff --git a/mobile/lib/ui/settings/security_section_widget.dart b/mobile/lib/ui/settings/security_section_widget.dart index eb93d85f62..3e2798ce04 100644 --- a/mobile/lib/ui/settings/security_section_widget.dart +++ b/mobile/lib/ui/settings/security_section_widget.dart @@ -10,7 +10,6 @@ import 'package:photos/events/two_factor_status_change_event.dart'; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; import "package:photos/models/user_details.dart"; -import 'package:photos/service_locator.dart'; import 'package:photos/services/local_authentication_service.dart'; import "package:photos/services/passkey_service.dart"; import 'package:photos/services/user_service.dart'; @@ -101,17 +100,16 @@ class _SecuritySectionWidgetState extends State { }, ), ), - if (flagService.passKeyEnabled) sectionOptionSpacing, - if (flagService.passKeyEnabled) - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.passkey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async => await onPasskeyClick(context), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.passkey, ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async => await onPasskeyClick(context), + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 07045a7d38..62c2a39352 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.8.137+657 +version: 0.8.138+658 publish_to: none environment: