feat: passkeys for mobile branch

This commit is contained in:
Prateek Sunal 2024-03-05 00:25:52 +05:30
parent b35d942eac
commit ad542429a4
10 changed files with 317 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
}
}
}

View file

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

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

View file

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

View file

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