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

## Description

## Tests
  Verified that reset flow is working fine on both auth and photos app.
This commit is contained in:
Neeraj Gupta 2024-03-13 11:12:30 +05:30 committed by GitHub
commit 297148dc67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 139 additions and 52 deletions

View file

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

View file

@ -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;
}
}

View file

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

View file

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

View file

@ -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,
),
),
),
),
),
],
),
),
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("离开家庭计划"),

View file

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

View file

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

View file

@ -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),
},
);

View file

@ -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!),
);