[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:
commit
297148dc67
15 changed files with 139 additions and 52 deletions
|
@ -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
auth/lib/models/account/two_factor.dart
Normal file
13
auth/lib/models/account/two_factor.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
2
mobile/lib/generated/intl/messages_en.dart
generated
2
mobile/lib/generated/intl/messages_en.dart
generated
|
@ -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"),
|
||||
|
|
2
mobile/lib/generated/intl/messages_pt.dart
generated
2
mobile/lib/generated/intl/messages_pt.dart
generated
|
@ -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"),
|
||||
|
|
2
mobile/lib/generated/intl/messages_zh.dart
generated
2
mobile/lib/generated/intl/messages_zh.dart
generated
|
@ -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("离开家庭计划"),
|
||||
|
|
10
mobile/lib/generated/l10n.dart
generated
10
mobile/lib/generated/l10n.dart
generated
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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!),
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue