[Passkey] Add check status option + other fixes (#2123)

## Description

## Tests
This commit is contained in:
Neeraj Gupta
2024-06-14 06:03:51 +05:30
committed by GitHub
16 changed files with 271 additions and 55 deletions

View File

@@ -42,3 +42,7 @@ class InvalidStateError extends AssertionError {
class SrpSetupNotCompleteError extends Error {}
class AuthenticatorKeyNotFound extends Error {}
class PassKeySessionNotVerifiedError extends Error {}
class PassKeySessionExpiredError extends Error {}

View File

@@ -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",

View File

@@ -266,6 +266,31 @@ class UserService {
}
}
Future<dynamic> 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<void> onPassKeyVerified(BuildContext context, Map response) async {
final ProgressDialog dialog =
createProgressDialog(context, context.l10n.pleaseWait);

View File

@@ -33,7 +33,7 @@ enum ButtonType {
Color defaultButtonColor(EnteColorScheme colorScheme) {
if (isPrimary) {
return colorScheme.primary500;
return colorScheme.primary400;
}
if (isSecondary) {
return colorScheme.fillFaint;

View File

@@ -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<PasskeyPage> {
);
}
Future<void> 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<void> _handleDeeplink(String? link) async {
if (!context.mounted ||
Configuration.instance.hasConfiguredAccount() ||
@@ -66,8 +91,15 @@ class _PasskeyPageState extends State<PasskeyPage> {
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<PasskeyPage> {
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,

View File

@@ -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<SecuritySectionWidget> {
// 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(

View File

@@ -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:

View File

@@ -81,3 +81,7 @@ class SrpSetupNotCompleteError extends Error {}
class SharingNotPermittedForFreeAccountsError extends Error {}
class NoMediaLocationAccessError extends Error {}
class PassKeySessionNotVerifiedError extends Error {}
class PassKeySessionExpiredError extends Error {}

View File

@@ -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 <u-terms>terms of service</u-terms> and <u-policy>privacy policy</u-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"),

View File

@@ -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(

View File

@@ -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",

View File

@@ -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<void> 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<dynamic> 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<void> 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<void> verifyEmail(

View File

@@ -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<PasskeyPage> createState() => _PasskeyPageState();
@@ -49,6 +51,30 @@ class _PasskeyPageState extends State<PasskeyPage> {
);
}
Future<void> 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<void> _handleDeeplink(String? link) async {
if (!context.mounted ||
Configuration.instance.hasConfiguredAccount() ||
@@ -60,8 +86,20 @@ class _PasskeyPageState extends State<PasskeyPage> {
}
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<PasskeyPage> {
@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<PasskeyPage> {
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<PasskeyPage> {
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<PasskeyPage> {
padding: const EdgeInsets.all(10),
child: Center(
child: Text(
S.of(context).recoverAccount,
context.l10n.recoverAccount,
style: const TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,

View File

@@ -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<SecuritySectionWidget> {
},
),
),
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(

View File

@@ -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: