[auth][photos] Support for passkey (#435)

<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Description

Passkey implementation (similar will be done in ente Photos)

<!--- Describe your changes in detail -->

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ] 🖼️ New icon
- [x]  New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore
This commit is contained in:
Neeraj Gupta 2024-03-06 15:55:53 +05:30 committed by GitHub
commit 4744434a62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 585 additions and 72 deletions

View file

@ -56,11 +56,11 @@ android {
signingConfigs {
release {
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : file(System.getenv("SIGNING_KEY_PATH"))
keyAlias keystoreProperties['keyAlias'] ? keystoreProperties['keyAlias'] : System.getenv("SIGNING_KEY_ALIAS")
keyPassword keystoreProperties['keyPassword'] ? keystoreProperties['keyPassword'] : System.getenv("SIGNING_KEY_PASSWORD")
storePassword keystoreProperties['storePassword'] ? keystoreProperties['storePassword'] : System.getenv("SIGNING_STORE_PASSWORD")
}
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : System.getenv("SIGNING_KEY_PATH") ? file(System.getenv("SIGNING_KEY_PATH")) : null
keyAlias keystoreProperties['keyAlias'] ? keystoreProperties['keyAlias'] : System.getenv("SIGNING_KEY_ALIAS")
keyPassword keystoreProperties['keyPassword'] ? keystoreProperties['keyPassword'] : System.getenv("SIGNING_KEY_PASSWORD")
storePassword keystoreProperties['storePassword'] ? keystoreProperties['storePassword'] : System.getenv("SIGNING_STORE_PASSWORD")
}
}
flavorDimensions "default"

View file

@ -35,6 +35,13 @@
<data android:scheme="otpauth" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="enteauth" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.

View file

@ -37,6 +37,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>otpauth</string>
<string>enteauth</string>
</array>
</dict>
</array>

View file

@ -76,8 +76,8 @@ class EnteRequestInterceptor extends Interceptor {
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (kDebugMode) {
assert(
options.baseUrl == enteEndpoint,
"interceptor should only be used for API endpoint",
options.baseUrl == enteEndpoint,
"interceptor should only be used for API endpoint",
);
}
// ignore: prefer_const_constructors

View file

@ -59,7 +59,7 @@
}
},
"contactSupport": "Contact support",
"rateUsOnStore" : "Rate us on {storeName}",
"rateUsOnStore": "Rate us on {storeName}",
"blog": "Blog",
"merchandise": "Merchandise",
"verifyPassword": "Verify password",
@ -133,7 +133,6 @@
"faq_q_5": "How can I enable FaceID lock in ente Auth",
"faq_a_5": "You can enable FaceID lock under Settings → Security → Lockscreen.",
"somethingWentWrongMessage": "Something went wrong, please try again",
"leaveFamily": "Leave family",
"leaveFamilyMessage": "Are you sure that you want to leave the family plan?",
"inFamilyPlanMessage": "You are on a family plan!",
@ -145,6 +144,7 @@
"enterCodeHint": "Enter the 6-digit code from\nyour authenticator app",
"lostDeviceTitle": "Lost device?",
"twoFactorAuthTitle": "Two-factor authentication",
"passkeyAuthTitle": "Passkey authentication",
"recoverAccount": "Recover account",
"enterRecoveryKeyHint": "Enter your recovery key",
"recover": "Recover",
@ -337,10 +337,10 @@
"offlineModeWarning": "You have chosen to proceed without backups. Please take manual backups to make sure your codes are safe.",
"showLargeIcons": "Show large icons",
"shouldHideCode": "Hide codes",
"doubleTapToViewHiddenCode" : "You can double tap on an entry to view code",
"doubleTapToViewHiddenCode": "You can double tap on an entry to view code",
"focusOnSearchBar": "Focus search on app start",
"confirmUpdatingkey": "Are you sure you want to update the secret key?",
"minimizeAppOnCopy": "Minimize app on copy",
"minimizeAppOnCopy": "Minimize app on copy",
"editCodeAuthMessage": "Authenticate to edit code",
"deleteCodeAuthMessage": "Authenticate to delete code",
"showQRAuthMessage": "Authenticate to show QR code",
@ -405,5 +405,8 @@
"signOutOtherDevices": "Sign out other devices",
"doNotSignOut": "Do not sign out",
"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!"
}
"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",
"passkey": "Passkey"
}

View file

@ -0,0 +1,23 @@
import 'package:ente_auth/core/configuration.dart';
import 'package:flutter/foundation.dart';
class FeatureFlagService {
FeatureFlagService._privateConstructor();
static final FeatureFlagService instance =
FeatureFlagService._privateConstructor();
static final _internalUserIDs = const String.fromEnvironment(
"internal_user_ids",
defaultValue: "1,2,3,4,191,125,1580559962388044,1580559962392434,10000025",
).split(",").map((element) {
return int.parse(element);
}).toSet();
bool isInternalUserOrDebugBuild() {
final String? email = Configuration.instance.getEmail();
final userID = Configuration.instance.getUserID();
return (email != null && email.endsWith("@ente.io")) ||
_internalUserIDs.contains(userID) ||
kDebugMode;
}
}

View file

@ -0,0 +1,33 @@
import 'package:ente_auth/core/network.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PasskeyService {
PasskeyService._privateConstructor();
static final PasskeyService instance = PasskeyService._privateConstructor();
final _enteDio = Network.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).ignore();
}
}
}

View file

@ -26,6 +26,7 @@ import 'package:ente_auth/ui/account/password_reentry_page.dart';
import 'package:ente_auth/ui/account/recovery_page.dart';
import 'package:ente_auth/ui/common/progress_dialog.dart';
import 'package:ente_auth/ui/home_page.dart';
import 'package:ente_auth/ui/passkey_page.dart';
import 'package:ente_auth/ui/two_factor_authentication_page.dart';
import 'package:ente_auth/ui/two_factor_recovery_page.dart';
import 'package:ente_auth/utils/crypto_util.dart';
@ -264,6 +265,33 @@ 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);
Widget page;
if (Configuration.instance.getEncryptedToken() != null) {
await Configuration.instance.decryptSecretsAndGetKeyEncKey(
userPassword,
Configuration.instance.getKeyAttributes()!,
);
page = const HomePage();
} else {
throw Exception("unexpected response during passkey verification");
}
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return page;
},
),
(route) => route.isFirst,
);
}
Future<void> verifyEmail(
BuildContext context,
String ott, {
@ -487,9 +515,9 @@ class UserService {
final clientS = client.calculateSecret(serverB);
final clientM = client.calculateClientEvidenceMessage();
// ignore: unused_local_variable
late Response srpCompleteResponse;
late Response _;
if (setKeysRequest == null) {
srpCompleteResponse = await _enteDio.post(
_ = await _enteDio.post(
"/users/srp/complete",
data: {
'setupID': setupSRPResponse.setupID,
@ -497,7 +525,7 @@ class UserService {
},
);
} else {
srpCompleteResponse = await _enteDio.post(
_ = await _enteDio.post(
"/users/srp/update",
data: {
'setupID': setupSRPResponse.setupID,
@ -581,11 +609,15 @@ class UserService {
},
);
if (response.statusCode == 200) {
Widget page;
Widget? page;
final String passkeySessionID = response.data["passkeySessionID"];
final String twoFASessionID = response.data["twoFactorSessionID"];
Configuration.instance.setVolatilePassword(userPassword);
if (twoFASessionID.isNotEmpty) {
page = TwoFactorAuthenticationPage(twoFASessionID);
} else if (passkeySessionID.isNotEmpty) {
page = PasskeyPage(passkeySessionID);
} else {
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {
@ -603,7 +635,7 @@ class UserService {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return page;
return page!;
},
),
(route) => route.isFirst,
@ -861,16 +893,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

@ -15,7 +15,8 @@ class OfflineAuthenticatorDB {
static const entityTable = 'entities';
OfflineAuthenticatorDB._privateConstructor();
static final OfflineAuthenticatorDB instance = OfflineAuthenticatorDB._privateConstructor();
static final OfflineAuthenticatorDB instance =
OfflineAuthenticatorDB._privateConstructor();
static Future<Database>? _dbFuture;
@ -26,7 +27,7 @@ class OfflineAuthenticatorDB {
Future<Database> _initDatabase() async {
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
debugPrint(path);
return await openDatabase(
@ -70,10 +71,10 @@ class OfflineAuthenticatorDB {
}
Future<int> updateEntry(
int generatedID,
String encData,
String header,
) async {
int generatedID,
String encData,
String header,
) async {
final db = await instance.database;
final int timeInMicroSeconds = DateTime.now().microsecondsSinceEpoch;
int affectedRows = await db.update(

View file

@ -0,0 +1,115 @@
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/services/user_service.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:uni_links/uni_links.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PasskeyPage extends StatefulWidget {
final String sessionID;
const PasskeyPage(
this.sessionID, {
Key? key,
}) : 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=enteauth://passkey",
mode: LaunchMode.externalApplication,
);
}
Future<void> _handleDeeplink(String? link) async {
if (!context.mounted ||
Configuration.instance.hasConfiguredAccount() ||
link == null) {
return;
}
if (mounted && link.toLowerCase().startsWith("enteauth://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>;
await UserService.instance.onPassKeyVerified(context, json);
}
}
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) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(
l10n.passkeyAuthTitle,
),
),
body: _getBody(),
);
}
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,
),
),
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),
),
),
],
),
);
}
}

View file

@ -1,4 +1,3 @@
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/components/captioned_text_widget.dart';

View file

@ -4,7 +4,9 @@ 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';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/account/request_pwd_verification_page.dart';
@ -61,7 +63,21 @@ 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: () => PasskeyService.instance.openPasskeyPage(context),
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: l10n.emailVerificationToggle,

View file

@ -197,10 +197,10 @@ packages:
dependency: "direct main"
description:
name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.17.2"
version: "1.18.0"
computer:
dependency: "direct main"
description:
@ -262,10 +262,10 @@ packages:
dependency: transitive
description:
name: coverage
sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097"
sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76"
url: "https://pub.dev"
source: hosted
version: "1.6.3"
version: "1.7.2"
cross_file:
dependency: transitive
description:
@ -751,6 +751,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
url: "https://pub.dev"
source: hosted
version: "2.0.1"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
url: "https://pub.dev"
source: hosted
version: "2.0.1"
lints:
dependency: "direct dev"
description:
@ -811,26 +835,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
version: "0.8.0"
meta:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.11.0"
mime:
dependency: transitive
description:
@ -931,10 +955,10 @@ packages:
dependency: transitive
description:
name: path
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.8.3"
version: "1.9.0"
path_drawing:
dependency: transitive
description:
@ -1304,10 +1328,10 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.11.1"
step_progress_indicator:
dependency: "direct main"
description:
@ -1320,10 +1344,10 @@ packages:
dependency: transitive
description:
name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
stream_transform:
dependency: transitive
description:
@ -1368,26 +1392,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46"
sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f
url: "https://pub.dev"
source: hosted
version: "1.24.3"
version: "1.24.9"
test_api:
dependency: transitive
description:
name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.6.1"
test_core:
dependency: transitive
description:
name: test_core
sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e"
sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a
url: "https://pub.dev"
source: hosted
version: "0.5.3"
version: "0.5.9"
timezone:
dependency: transitive
description:
@ -1560,10 +1584,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: e7fb6c2282f7631712b69c19d1bff82f3767eea33a2321c14fa59ad67ea391c7
sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
url: "https://pub.dev"
source: hosted
version: "9.4.0"
version: "13.0.0"
watcher:
dependency: transitive
description:
@ -1572,14 +1596,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
web:
dependency: transitive
description:
name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
url: "https://pub.dev"
source: hosted
version: "0.1.4-beta"
web_socket_channel:
dependency: transitive
description:
@ -1637,5 +1653,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.1.0-185.0.dev <4.0.0"
dart: ">=3.2.0-0 <4.0.0"
flutter: ">=3.10.0"

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,25 @@ 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()!,
);
} 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 +668,14 @@ 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);
} else {
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {
@ -1108,16 +1132,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,118 @@
import 'dart:convert';
import 'package:flutter/material.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;
const PasskeyPage(
this.sessionID, {
Key? key,
}) : 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.onPassKeyVerified(context, json);
} 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

@ -7,8 +7,11 @@ import 'package:photos/core/event_bus.dart';
import 'package:photos/ente_theme_data.dart';
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/services/feature_flag_service.dart";
import 'package:photos/services/local_authentication_service.dart';
import "package:photos/services/passkey_service.dart";
import 'package:photos/services/user_service.dart';
import 'package:photos/theme/ente_theme.dart';
import "package:photos/ui/account/request_pwd_verification_page.dart";
@ -65,6 +68,8 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
final Completer completer = Completer();
final List<Widget> children = [];
if (_config.hasConfiguredAccount()) {
final bool isInternalUser =
FeatureFlagService.instance.isInternalUserOrDebugBuild();
children.addAll(
[
sectionOptionSpacing,
@ -96,6 +101,17 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
},
),
),
if (isInternalUser) sectionOptionSpacing,
if (isInternalUser)
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.passkey,
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () => PasskeyService.instance.openPasskeyPage(context),
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(

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