feat: passkeys for mobile branch
This commit is contained in:
parent
b35d942eac
commit
ad542429a4
10 changed files with 317 additions and 8 deletions
|
@ -8,7 +8,8 @@ const String sentryDSN =
|
|||
const String sentryDebugDSN =
|
||||
"https://ca5e686dd7f149d9bf94e620564cceba@sentry.ente.io/3";
|
||||
const String sentryTunnel = "https://sentry-reporter.ente.io";
|
||||
const String githubDiscussionsUrl = "https://github.com/ente-io/ente/discussions";
|
||||
const String githubDiscussionsUrl =
|
||||
"https://github.com/ente-io/ente/discussions";
|
||||
const int microSecondsInDay = 86400000000;
|
||||
const int android11SDKINT = 30;
|
||||
const int jan011981Time = 347155200000000;
|
||||
|
@ -41,6 +42,7 @@ const supportEmail = 'support@ente.io';
|
|||
class FFDefault {
|
||||
static const bool enableStripe = true;
|
||||
static const bool disableCFWorker = false;
|
||||
static const bool enablePasskey = false;
|
||||
}
|
||||
|
||||
const kDefaultProductionEndpoint = 'https://api.ente.io';
|
||||
|
|
7
mobile/lib/generated/intl/messages_en.dart
generated
7
mobile/lib/generated/intl/messages_en.dart
generated
|
@ -797,6 +797,8 @@ 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"),
|
||||
|
@ -954,6 +956,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"orPickAnExistingOne":
|
||||
MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
|
||||
"pair": MessageLookupByLibrary.simpleMessage("Pair"),
|
||||
"passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
|
||||
"passkeyAuthTitle":
|
||||
MessageLookupByLibrary.simpleMessage("Passkey authentication"),
|
||||
"password": MessageLookupByLibrary.simpleMessage("Password"),
|
||||
"passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage(
|
||||
"Password changed successfully"),
|
||||
|
@ -1460,6 +1465,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"viewer": MessageLookupByLibrary.simpleMessage("Viewer"),
|
||||
"visitWebToManage": MessageLookupByLibrary.simpleMessage(
|
||||
"Please visit web.ente.io to manage your subscription"),
|
||||
"waitingForBrowserRequest": MessageLookupByLibrary.simpleMessage(
|
||||
"Waiting for browser request..."),
|
||||
"waitingForWifi":
|
||||
MessageLookupByLibrary.simpleMessage("Waiting for WiFi..."),
|
||||
"weAreOpenSource":
|
||||
|
|
40
mobile/lib/generated/l10n.dart
generated
40
mobile/lib/generated/l10n.dart
generated
|
@ -8308,6 +8308,46 @@ class S {
|
|||
);
|
||||
}
|
||||
|
||||
/// `Waiting for browser request...`
|
||||
String get waitingForBrowserRequest {
|
||||
return Intl.message(
|
||||
'Waiting for browser request...',
|
||||
name: 'waitingForBrowserRequest',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `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(
|
||||
'Passkey',
|
||||
name: 'passkey',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Passkey authentication`
|
||||
String get passkeyAuthTitle {
|
||||
return Intl.message(
|
||||
'Passkey authentication',
|
||||
name: 'passkeyAuthTitle',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Play album on TV`
|
||||
String get playOnTv {
|
||||
return Intl.message(
|
||||
|
|
|
@ -1188,6 +1188,10 @@
|
|||
"changeLocationOfSelectedItems": "Change location of selected items?",
|
||||
"editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
|
||||
"cleanUncategorized": "Clean Uncategorized",
|
||||
"waitingForBrowserRequest": "Waiting for browser request...",
|
||||
"launchPasskeyUrlAgain": "Launch passkey URL again",
|
||||
"passkey": "Passkey",
|
||||
"passkeyAuthTitle": "Passkey authentication",
|
||||
"playOnTv": "Play album on TV",
|
||||
"pair": "Pair",
|
||||
"deviceNotFound": "Device not found",
|
||||
|
|
|
@ -68,6 +68,18 @@ class FeatureFlagService {
|
|||
}
|
||||
}
|
||||
|
||||
bool enablePasskey() {
|
||||
try {
|
||||
if (isInternalUserOrDebugBuild()) {
|
||||
return true;
|
||||
}
|
||||
return _getFeatureFlags().enablePasskey;
|
||||
} catch (e) {
|
||||
_logger.info('error in enablePasskey check', e);
|
||||
return FFDefault.enablePasskey;
|
||||
}
|
||||
}
|
||||
|
||||
bool isInternalUserOrDebugBuild() {
|
||||
final String? email = Configuration.instance.getEmail();
|
||||
final userID = Configuration.instance.getUserID();
|
||||
|
@ -94,20 +106,24 @@ class FeatureFlags {
|
|||
static FeatureFlags defaultFlags = FeatureFlags(
|
||||
disableCFWorker: FFDefault.disableCFWorker,
|
||||
enableStripe: FFDefault.enableStripe,
|
||||
enablePasskey: FFDefault.enablePasskey,
|
||||
);
|
||||
|
||||
final bool disableCFWorker;
|
||||
final bool enableStripe;
|
||||
final bool enablePasskey;
|
||||
|
||||
FeatureFlags({
|
||||
required this.disableCFWorker,
|
||||
required this.enableStripe,
|
||||
required this.enablePasskey,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
"disableCFWorker": disableCFWorker,
|
||||
"enableStripe": enableStripe,
|
||||
"enablePasskey": enablePasskey,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -120,6 +136,7 @@ class FeatureFlags {
|
|||
return FeatureFlags(
|
||||
disableCFWorker: json["disableCFWorker"] ?? FFDefault.disableCFWorker,
|
||||
enableStripe: json["enableStripe"] ?? FFDefault.enableStripe,
|
||||
enablePasskey: json["enablePasskey"] ?? FFDefault.enablePasskey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
33
mobile/lib/services/passkey_service.dart
Normal file
33
mobile/lib/services/passkey_service.dart
Normal file
|
@ -0,0 +1,33 @@
|
|||
import "package:flutter/cupertino.dart";
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/core/network/network.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class PasskeyService {
|
||||
PasskeyService._privateConstructor();
|
||||
static final PasskeyService instance = PasskeyService._privateConstructor();
|
||||
|
||||
final _enteDio = NetworkClient.instance.enteDio;
|
||||
|
||||
Future<String> getJwtToken() async {
|
||||
final response = await _enteDio.get(
|
||||
"/users/accounts-token",
|
||||
);
|
||||
return response.data!["accountsToken"] as String;
|
||||
}
|
||||
|
||||
Future<void> openPasskeyPage(BuildContext context) async {
|
||||
try {
|
||||
final jwtToken = await getJwtToken();
|
||||
final url = "https://accounts.ente.io/account-handoff?token=$jwtToken";
|
||||
await launchUrlString(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger('PasskeyService').severe("failed to open passkey page", e);
|
||||
showGenericErrorDialog(context: context, error: e).ignore();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ import 'package:photos/models/set_recovery_key_request.dart';
|
|||
import 'package:photos/models/user_details.dart';
|
||||
import 'package:photos/ui/account/login_page.dart';
|
||||
import 'package:photos/ui/account/ott_verification_page.dart';
|
||||
import "package:photos/ui/account/passkey_page.dart";
|
||||
import 'package:photos/ui/account/password_entry_page.dart';
|
||||
import 'package:photos/ui/account/password_reentry_page.dart';
|
||||
import "package:photos/ui/account/recovery_page.dart";
|
||||
|
@ -314,6 +315,30 @@ class UserService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> acceptPasskey(
|
||||
BuildContext context,
|
||||
Map response,
|
||||
Uint8List keyEncryptionKey,
|
||||
) 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()!,
|
||||
keyEncryptionKey: keyEncryptionKey,
|
||||
);
|
||||
} else {
|
||||
throw Exception("unexpected response during passkey verification");
|
||||
}
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
Bus.instance.fire(AccountConfiguredEvent());
|
||||
}
|
||||
|
||||
Future<void> verifyEmail(
|
||||
BuildContext context,
|
||||
String ott, {
|
||||
|
@ -648,10 +673,17 @@ class UserService {
|
|||
if (response.statusCode == 200) {
|
||||
Widget page;
|
||||
final String twoFASessionID = response.data["twoFactorSessionID"];
|
||||
final String passkeySessionID = response.data["passkeySessionID"];
|
||||
|
||||
Configuration.instance.setVolatilePassword(userPassword);
|
||||
if (twoFASessionID.isNotEmpty) {
|
||||
await setTwoFactor(value: true);
|
||||
page = TwoFactorAuthenticationPage(twoFASessionID);
|
||||
} else if (passkeySessionID.isNotEmpty) {
|
||||
page = PasskeyPage(
|
||||
passkeySessionID,
|
||||
keyEncryptionKey: keyEncryptionKey,
|
||||
);
|
||||
} else {
|
||||
await _saveConfiguration(response);
|
||||
if (Configuration.instance.getEncryptedToken() != null) {
|
||||
|
@ -1108,16 +1140,19 @@ class UserService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _saveConfiguration(Response response) async {
|
||||
await Configuration.instance.setUserID(response.data["id"]);
|
||||
if (response.data["encryptedToken"] != null) {
|
||||
Future<void> _saveConfiguration(dynamic response) async {
|
||||
final responseData = response is Map ? response : response.data as Map?;
|
||||
if (responseData == null) return;
|
||||
|
||||
await Configuration.instance.setUserID(responseData["id"]);
|
||||
if (responseData["encryptedToken"] != null) {
|
||||
await Configuration.instance
|
||||
.setEncryptedToken(response.data["encryptedToken"]);
|
||||
.setEncryptedToken(responseData["encryptedToken"]);
|
||||
await Configuration.instance.setKeyAttributes(
|
||||
KeyAttributes.fromMap(response.data["keyAttributes"]),
|
||||
KeyAttributes.fromMap(responseData["keyAttributes"]),
|
||||
);
|
||||
} else {
|
||||
await Configuration.instance.setToken(response.data["token"]);
|
||||
await Configuration.instance.setToken(responseData["token"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
125
mobile/lib/ui/account/passkey_page.dart
Normal file
125
mobile/lib/ui/account/passkey_page.dart
Normal file
|
@ -0,0 +1,125 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:uni_links/uni_links.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class PasskeyPage extends StatefulWidget {
|
||||
final String sessionID;
|
||||
final Uint8List keyEncryptionKey;
|
||||
|
||||
const PasskeyPage(
|
||||
this.sessionID, {
|
||||
Key? key,
|
||||
required this.keyEncryptionKey,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PasskeyPage> createState() => _PasskeyPageState();
|
||||
}
|
||||
|
||||
class _PasskeyPageState extends State<PasskeyPage> {
|
||||
final Logger _logger = Logger("PasskeyPage");
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
launchPasskey();
|
||||
_initDeepLinks();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> launchPasskey() async {
|
||||
await launchUrlString(
|
||||
"https://accounts.ente.io/passkeys/flow?"
|
||||
"passkeySessionID=${widget.sessionID}"
|
||||
"&redirect=ente://passkey",
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDeeplink(String? link) async {
|
||||
if (!context.mounted ||
|
||||
Configuration.instance.hasConfiguredAccount() ||
|
||||
link == null) {
|
||||
return;
|
||||
}
|
||||
if (mounted && link.toLowerCase().startsWith("ente://passkey")) {
|
||||
final uri = Uri.parse(link).queryParameters['response'];
|
||||
|
||||
// response to json
|
||||
final res = utf8.decode(base64.decode(uri!));
|
||||
final json = jsonDecode(res) as Map<String, dynamic>;
|
||||
|
||||
try {
|
||||
await UserService.instance.acceptPasskey(
|
||||
context,
|
||||
json,
|
||||
widget.keyEncryptionKey,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.severe(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _initDeepLinks() async {
|
||||
// Attach a listener to the stream
|
||||
linkStream.listen(
|
||||
_handleDeeplink,
|
||||
onError: (err) {
|
||||
_logger.severe(err);
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
S.of(context).passkeyAuthTitle,
|
||||
),
|
||||
),
|
||||
body: _getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getBody() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).waitingForBrowserRequest,
|
||||
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(S.of(context).launchPasskeyUrlAgain),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -8,12 +8,15 @@ import 'package:photos/theme/ente_theme.dart';
|
|||
import 'package:photos/ui/account/change_email_dialog.dart';
|
||||
import 'package:photos/ui/account/delete_account_page.dart';
|
||||
import 'package:photos/ui/account/password_entry_page.dart';
|
||||
import "package:photos/ui/account/recovery_key_page.dart";
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
|
||||
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
|
||||
import "package:photos/ui/payment/subscription.dart";
|
||||
import 'package:photos/ui/settings/common_settings.dart';
|
||||
import "package:photos/utils/crypto_util.dart";
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:url_launcher/url_launcher_string.dart";
|
||||
|
||||
class AccountSectionWidget extends StatelessWidget {
|
||||
|
@ -101,6 +104,43 @@ class AccountSectionWidget extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: S.of(context).recoveryKey,
|
||||
),
|
||||
pressedColor: getEnteColorScheme(context).fillFaint,
|
||||
trailingIcon: Icons.chevron_right_outlined,
|
||||
trailingIconIsMuted: true,
|
||||
showOnlyLoadingState: true,
|
||||
onTap: () async {
|
||||
final hasAuthenticated = await LocalAuthenticationService.instance
|
||||
.requestLocalAuthentication(
|
||||
context,
|
||||
S.of(context).authToViewYourRecoveryKey,
|
||||
);
|
||||
if (hasAuthenticated) {
|
||||
String recoveryKey;
|
||||
try {
|
||||
recoveryKey = await _getOrCreateRecoveryKey(context);
|
||||
} catch (e) {
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
RecoveryKeyPage(
|
||||
recoveryKey,
|
||||
S.of(context).ok,
|
||||
showAppBar: true,
|
||||
onDone: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
sectionOptionSpacing,
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: S.of(context).exportYourData,
|
||||
|
@ -157,6 +197,12 @@ class AccountSectionWidget extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Future<String> _getOrCreateRecoveryKey(BuildContext context) async {
|
||||
return CryptoUtil.bin2hex(
|
||||
await UserService.instance.getOrCreateRecoveryKey(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _onLogoutTapped(BuildContext context) {
|
||||
showChoiceActionSheet(
|
||||
context,
|
||||
|
|
|
@ -181,7 +181,7 @@ dependency_overrides:
|
|||
# current fork of tfite_flutter_helper depends on ffi: ^1.x.x
|
||||
# but we need ffi: ^2.0.1 for newer packages. The original tfite_flutter_helper
|
||||
#
|
||||
ffi: ^2.0.0
|
||||
ffi: ^2.1.0
|
||||
video_player:
|
||||
git:
|
||||
url: https://github.com/ente-io/packages.git
|
||||
|
|
Loading…
Reference in a new issue