Compare commits

..

22 Commits

Author SHA1 Message Date
vishnukvmd
2526b61620 v1.0.7 2022-11-11 20:24:02 +05:30
vishnukvmd
ec4df467a0 Up flutter version to 3.3.8 2022-11-11 20:23:48 +05:30
vishnukvmd
41fd5337a6 Fix link to checksum 2022-11-11 20:23:29 +05:30
vishnukvmd
8fbec8307e v1.0.6 2022-11-11 20:14:01 +05:30
vishnukvmd
d902fdbf75 Add separate section for import and export 2022-11-11 20:13:52 +05:30
vishnukvmd
932dc53f6c Made with love @ ente 2022-11-11 20:09:59 +05:30
vishnukvmd
745cb54ffd Update copy 2022-11-11 20:01:02 +05:30
vishnukvmd
8ced5bf32e Fix error dialog 2022-11-11 19:58:36 +05:30
vishnukvmd
9b1a8fb4ca v1.0.5 2022-11-11 19:52:23 +05:30
vishnukvmd
824c6d769b Sanitize the secret 2022-11-11 19:51:54 +05:30
vishnukvmd
92c2247aa1 Add option to bulk import codes 2022-11-11 18:51:09 +05:30
vishnukvmd
e84e9db70e Add parameter to disable sync post code addition 2022-11-11 18:50:26 +05:30
vishnukvmd
be72db844d De-duplicate codes before inserting them into the DB 2022-11-11 17:12:05 +05:30
vishnukvmd
2386a5a10b Remove redundant code 2022-11-11 17:05:28 +05:30
vishnukvmd
09ae14a1d6 Add dependency on file picker 2022-11-11 17:05:22 +05:30
vishnukvmd
924ce5ff86 Update launch config 2022-11-11 15:45:58 +05:30
vishnukvmd
c47370163d Auto focus on OTT entry screen 2022-11-11 15:45:52 +05:30
vishnukvmd
3dd408af01 Clarify that account deletion applies to all products 2022-11-03 16:13:48 +05:30
vishnukvmd
408e3bd2b6 Fix the option to view recovery key 2022-11-03 16:00:02 +05:30
vishnukvmd
ebf634ef1e Enable 2fa within 2fa :okaypepe: 2022-11-03 15:59:00 +05:30
vishnukvmd
62c21749b5 v1.0.3 2022-11-03 15:04:52 +05:30
vishnukvmd
a9e8245b4d Fix typo 2022-11-03 15:04:43 +05:30
19 changed files with 918 additions and 152 deletions

View File

@@ -26,7 +26,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.3.2'
flutter-version: '3.3.8'
# Fetch sub modules
- run: git submodule update --init --recursive
@@ -61,7 +61,7 @@ jobs:
- uses: actions/upload-artifact@v2
with:
name: release-checksum
path: build/app/outputs/flutter-apk/checksum
path: build/app/outputs/flutter-apk/sha256sum
# Create a Github release
- uses: ncipollo/release-action@v1

2
.vscode/launch.json vendored
View File

@@ -26,7 +26,7 @@
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": ["--dart-define", "endpoint=http://192.168.1.3:8080"]
"args": ["--dart-define", "endpoint=http://192.168.1.30:8080"]
},
{
"name": "Prod",

View File

@@ -52,9 +52,6 @@
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
tools:ignore="ScopedStorage"/>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<manifest>
</manifest>

View File

@@ -4,6 +4,40 @@ PODS:
- Reachability
- device_info (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.4):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.4)
- DKImagePickerController/PhotoGallery (4.3.4):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.4)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- fk_user_agent (2.0.0):
- Flutter
- Flutter (1.0.0)
@@ -44,6 +78,9 @@ PODS:
- Flutter
- MTBBarcodeScanner
- Reachability (3.2)
- SDWebImage (5.13.4):
- SDWebImage/Core (= 5.13.4)
- SDWebImage/Core (5.13.4)
- share_plus (0.0.1):
- Flutter
- shared_preferences_ios (0.0.1):
@@ -51,6 +88,7 @@ PODS:
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
- SwiftyGif (5.4.3)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
@@ -58,6 +96,7 @@ PODS:
DEPENDENCIES:
- connectivity (from `.symlinks/plugins/connectivity/ios`)
- device_info (from `.symlinks/plugins/device_info/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- fk_user_agent (from `.symlinks/plugins/fk_user_agent/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
@@ -79,10 +118,14 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- FMDB
- MTBBarcodeScanner
- OrderedSet
- Reachability
- SDWebImage
- SwiftyGif
- Toast
EXTERNAL SOURCES:
@@ -90,6 +133,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity/ios"
device_info:
:path: ".symlinks/plugins/device_info/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
fk_user_agent:
:path: ".symlinks/plugins/fk_user_agent/ios"
Flutter:
@@ -130,6 +175,9 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity: c4130b2985d4ef6fd26f9702e886bd5260681467
device_info: d7d233b645a32c40dfdc212de5cf646ca482f175
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95
fk_user_agent: 1f47ec39291e8372b1d692b50084b0d54103c545
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
@@ -148,9 +196,11 @@ SPEC CHECKSUMS:
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
SDWebImage: e5cc87bf736e60f49592f307bdf9e157189298a3
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de

View File

@@ -1,3 +1,5 @@
import 'package:ente_auth/utils/totp_util.dart';
class Code {
static const defaultDigits = 6;
static const defaultPeriod = 30;
@@ -51,7 +53,7 @@ class Code {
_getIssuer(uri),
_getDigits(uri),
_getPeriod(uri),
uri.queryParameters['secret']!,
getSanitizedSecret(uri.queryParameters['secret']!),
_getAlgorithm(uri),
_getType(uri),
rawData,

View File

@@ -62,7 +62,7 @@ class AuthenticatorService {
return entries;
}
Future<int> addEntry(String plainText) async {
Future<int> addEntry(String plainText, bool shouldSync) async {
var key = await getOrCreateAuthDataKey();
final encryptedKeyData = await CryptoUtil.encryptChaCha(
utf8.encode(plainText) as Uint8List,
@@ -71,7 +71,9 @@ class AuthenticatorService {
String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!);
String header = Sodium.bin2base64(encryptedKeyData.header!);
final insertedID = await _db.insert(encryptedData, header);
unawaited(sync());
if (shouldSync) {
unawaited(sync());
}
return insertedID;
}

View File

@@ -12,12 +12,17 @@ import 'package:ente_auth/models/sessions.dart';
import 'package:ente_auth/models/set_keys_request.dart';
import 'package:ente_auth/models/set_recovery_key_request.dart';
import 'package:ente_auth/models/user_details.dart';
import 'package:ente_auth/ui/account/login_page.dart';
import 'package:ente_auth/ui/account/ott_verification_page.dart';
import 'package:ente_auth/ui/account/password_entry_page.dart';
import 'package:ente_auth/ui/account/password_reentry_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';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/toast_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sodium/flutter_sodium.dart';
import 'package:logging/logging.dart';
class UserService {
@@ -265,11 +270,16 @@ class UserService {
await dialog.hide();
if (response != null && response.statusCode == 200) {
Widget page;
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {
page = const PasswordReentryPage();
final String twoFASessionID = response.data["twoFactorSessionID"];
if (twoFASessionID != null && twoFASessionID.isNotEmpty) {
page = TwoFactorAuthenticationPage(twoFASessionID);
} else {
page = const PasswordEntryPage();
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {
page = const PasswordReentryPage();
} else {
page = const PasswordEntryPage();
}
}
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
@@ -484,4 +494,196 @@ class UserService {
await Configuration.instance.setToken(response.data["token"]);
}
}
Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
try {
final response = await _dio.get(
_config.getHttpEndpoint() + "/users/two-factor/recover",
queryParameters: {
"sessionID": sessionID,
},
);
if (response != null && response.statusCode == 200) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return TwoFactorRecoveryPage(
sessionID,
response.data["encryptedSecret"],
response.data["secretDecryptionNonce"],
);
},
),
(route) => route.isFirst,
);
}
} on DioError catch (e) {
_logger.severe(e);
if (e.response != null && e.response.statusCode == 404) {
showToast(context, "Session expired");
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const LoginPage();
},
),
(route) => route.isFirst,
);
} else {
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
}
} catch (e) {
_logger.severe(e);
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
} finally {
await dialog.hide();
}
}
Future<void> verifyTwoFactor(
BuildContext context,
String sessionID,
String code,
) async {
final dialog = createProgressDialog(context, "Authenticating...");
await dialog.show();
try {
final response = await _dio.post(
_config.getHttpEndpoint() + "/users/two-factor/verify",
data: {
"sessionID": sessionID,
"code": code,
},
);
await dialog.hide();
if (response != null && response.statusCode == 200) {
showToast(context, "Authentication successful!");
await _saveConfiguration(response);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const PasswordReentryPage();
},
),
(route) => route.isFirst,
);
}
} on DioError catch (e) {
await dialog.hide();
_logger.severe(e);
if (e.response != null && e.response.statusCode == 404) {
showToast(context, "Session expired");
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const LoginPage();
},
),
(route) => route.isFirst,
);
} else {
showErrorDialog(
context,
"Incorrect code",
"Authentication failed, please try again",
);
}
} catch (e) {
await dialog.hide();
_logger.severe(e);
showErrorDialog(
context,
"Oops",
"Authentication failed, please try again",
);
}
}
Future<void> removeTwoFactor(
BuildContext context,
String sessionID,
String recoveryKey,
String encryptedSecret,
String secretDecryptionNonce,
) async {
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
String secret;
try {
secret = Sodium.bin2base64(
await CryptoUtil.decrypt(
Sodium.base642bin(encryptedSecret),
Sodium.hex2bin(recoveryKey.trim()),
Sodium.base642bin(secretDecryptionNonce),
),
);
} catch (e) {
await dialog.hide();
showErrorDialog(
context,
"Incorrect recovery key",
"The recovery key you entered is incorrect",
);
return;
}
try {
final response = await _dio.post(
_config.getHttpEndpoint() + "/users/two-factor/remove",
data: {
"sessionID": sessionID,
"secret": secret,
},
);
if (response != null && response.statusCode == 200) {
showShortToast(context, "Two-factor authentication successfully reset");
await _saveConfiguration(response);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const PasswordReentryPage();
},
),
(route) => route.isFirst,
);
}
} on DioError catch (e) {
_logger.severe(e);
if (e.response != null && e.response.statusCode == 404) {
showToast(context, "Session expired");
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const LoginPage();
},
),
(route) => route.isFirst,
);
} else {
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
}
} catch (e) {
_logger.severe(e);
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
} finally {
await dialog.hide();
}
}
}

View File

@@ -4,6 +4,7 @@ import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/events/codes_updated_event.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:logging/logging.dart';
class CodeStore {
static final CodeStore instance = CodeStore._privateConstructor();
@@ -11,6 +12,7 @@ class CodeStore {
CodeStore._privateConstructor();
late AuthenticatorService _authenticatorService;
final _logger = Logger("CodeStore");
Future<void> init() async {
_authenticatorService = AuthenticatorService.instance;
@@ -29,10 +31,21 @@ class CodeStore {
return codes;
}
Future<void> addCode(Code code) async {
Future<void> addCode(
Code code, {
bool shouldSync = true,
}) async {
final codes = await getAllCodes();
code.id = await _authenticatorService.addEntry(jsonEncode(code.rawData));
codes.add(code);
for (final existingCode in codes) {
if (existingCode == code) {
_logger.info("Found duplicate code, skipping add");
return;
}
}
code.id = await _authenticatorService.addEntry(
jsonEncode(code.rawData),
shouldSync,
);
Bus.instance.fire(CodesUpdatedEvent());
}

View File

@@ -152,9 +152,10 @@ class DeleteAccountPage extends StatelessWidget {
if (hasAuthenticated) {
final choice = await showChoiceDialog(
context,
'Are you sure you want to delete your account?',
'Your uploaded data will be scheduled for deletion, and your account '
'will be permanently deleted. \n\nThis action is not reversible.',
'Are you sure you want to delete your ente account?',
'Your uploaded data, across all apps '
'(Photos and Authenticator both), will be scheduled for deletion,'
'and your account will be permanently deleted.',
firstAction: 'Cancel',
secondAction: 'Delete',
firstActionColor: Theme.of(context).colorScheme.onSurface,

View File

@@ -161,7 +161,7 @@ class _OTTVerificationPageState extends State<OTTVerificationPage> {
),
),
controller: _verificationCodeController,
autofocus: false,
autofocus: true,
autocorrect: false,
keyboardType: TextInputType.number,
onChanged: (_) {

View File

@@ -1,10 +1,7 @@
// @dart=2.9
import 'dart:io';
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/services/local_authentication_service.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/account/change_email_dialog.dart';
import 'package:ente_auth/ui/account/password_entry_page.dart';
@@ -17,13 +14,8 @@ import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/navigation_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sodium/flutter_sodium.dart';
import 'package:share_plus/share_plus.dart';
class AccountSectionWidget extends StatelessWidget {
final _codeFile = File(
Configuration.instance.getTempDirectory() + "ente-authenticator-codes.txt",
);
AccountSectionWidget({Key key}) : super(key: key);
@override
@@ -37,46 +29,42 @@ class AccountSectionWidget extends StatelessWidget {
Column _getSectionOptions(BuildContext context) {
List<Widget> children = [];
if (Configuration.instance.getRecoveryKey() != null) {
children.addAll([
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Recovery key",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
final hasAuthenticated = await LocalAuthenticationService.instance
.requestLocalAuthentication(
context,
"Please authenticate to view your recovery key",
);
if (hasAuthenticated) {
String recoveryKey;
try {
recoveryKey =
Sodium.bin2base64(Configuration.instance.getRecoveryKey());
} catch (e) {
showGenericErrorDialog(context);
return;
}
routeToPage(
context,
RecoveryKeyPage(
recoveryKey,
"OK",
showAppBar: true,
onDone: () {},
),
);
}
},
),
]);
}
children.addAll([
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Recovery key",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
final hasAuthenticated = await LocalAuthenticationService.instance
.requestLocalAuthentication(
context,
"Please authenticate to view your recovery key",
);
if (hasAuthenticated) {
String recoveryKey;
try {
recoveryKey =
Sodium.bin2base64(Configuration.instance.getRecoveryKey());
} catch (e) {
showGenericErrorDialog(context);
return;
}
routeToPage(
context,
RecoveryKeyPage(
recoveryKey,
"OK",
showAppBar: true,
onDone: () {},
),
);
}
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
@@ -131,90 +119,9 @@ class AccountSectionWidget extends StatelessWidget {
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Export secrets",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
_showWarningDialog(context);
},
),
sectionOptionSpacing,
]);
return Column(
children: children,
);
}
Future<void> _showWarningDialog(BuildContext context) async {
final AlertDialog alert = AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Text(
"Warning",
style: Theme.of(context).textTheme.headline6,
),
content: const Text(
"The exported file contains sensitive information. Please store this safely.",
),
actions: [
TextButton(
child: const Text(
"I understand",
style: TextStyle(
color: Colors.red,
),
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
_exportCodes(context);
},
),
TextButton(
child: const Text(
"Cancel",
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
},
),
],
);
return showDialog(
context: context,
builder: (BuildContext context) {
return alert;
},
barrierColor: Colors.black12,
);
}
Future<void> _exportCodes(BuildContext context) async {
final hasAuthenticated =
await LocalAuthenticationService.instance.requestLocalAuthentication(
context,
"Please authenticate to export your codes",
);
if (!hasAuthenticated) {
return;
}
if (_codeFile.existsSync()) {
await _codeFile.delete();
}
final codes = await CodeStore.instance.getAllCodes();
String data = "";
for (final code in codes) {
data += code.rawData + "\n";
}
_codeFile.writeAsStringSync(data);
await Share.shareFiles([_codeFile.path]);
Future.delayed(const Duration(seconds: 15), () async {
if (_codeFile.existsSync()) {
_codeFile.deleteSync();
}
});
}
}

View File

@@ -0,0 +1,282 @@
// @dart=2.9
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/ente_theme_data.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/services/local_authentication_service.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/components/captioned_text_widget.dart';
import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart';
import 'package:ente_auth/ui/components/menu_item_widget.dart';
import 'package:ente_auth/ui/settings/common_settings.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:share_plus/share_plus.dart';
class DataSectionWidget extends StatelessWidget {
final _logger = Logger("AccountSectionWidget");
final _codeFile = File(
Configuration.instance.getTempDirectory() + "ente-authenticator-codes.txt",
);
DataSectionWidget({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ExpandableMenuItemWidget(
title: "Data",
selectionOptionsWidget: _getSectionOptions(context),
leadingIcon: Icons.key_outlined,
);
}
Column _getSectionOptions(BuildContext context) {
List<Widget> children = [];
children.addAll([
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Import codes",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
_showImportInstructionDialog(context);
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Export codes",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
_showExportWarningDialog(context);
},
),
sectionOptionSpacing,
]);
return Column(
children: children,
);
}
Future<void> _showImportInstructionDialog(BuildContext context) async {
final AlertDialog alert = AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Text(
"Import codes",
style: Theme.of(context).textTheme.headline6,
),
content: SingleChildScrollView(
child: Column(
children: [
const Text(
"Please select a file that contains a list of your codes in the following format",
),
const SizedBox(
height: 20,
),
Container(
color: Theme.of(context).colorScheme.gNavBackgroundColor,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
"otpauth://totp/provider.com:you@email.com?secret=YOUR_SECRET",
style: TextStyle(
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: Platform.isIOS ? "Courier" : "monospace",
fontSize: 13,
),
),
),
),
const SizedBox(
height: 20,
),
const Text(
"The codes can be separated by a comma or a new line",
),
],
),
),
actions: [
TextButton(
child: const Text(
"Cancel",
style: TextStyle(
color: Colors.red,
),
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
},
),
TextButton(
child: const Text(
"Select file",
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
_pickImportFile(context);
},
),
],
);
return showDialog(
context: context,
builder: (BuildContext context) {
return alert;
},
barrierColor: Colors.black12,
);
}
Future<void> _showExportWarningDialog(BuildContext context) async {
final AlertDialog alert = AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Text(
"Warning",
style: Theme.of(context).textTheme.headline6,
),
content: const Text(
"The exported file contains sensitive information. Please store this safely.",
),
actions: [
TextButton(
child: const Text(
"I understand",
style: TextStyle(
color: Colors.red,
),
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
_exportCodes(context);
},
),
TextButton(
child: const Text(
"Cancel",
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
},
),
],
);
return showDialog(
context: context,
builder: (BuildContext context) {
return alert;
},
barrierColor: Colors.black12,
);
}
Future<void> _exportCodes(BuildContext context) async {
final hasAuthenticated =
await LocalAuthenticationService.instance.requestLocalAuthentication(
context,
"Please authenticate to export your codes",
);
if (!hasAuthenticated) {
return;
}
if (_codeFile.existsSync()) {
await _codeFile.delete();
}
final codes = await CodeStore.instance.getAllCodes();
String data = "";
for (final code in codes) {
data += code.rawData + "\n";
}
_codeFile.writeAsStringSync(data);
await Share.shareFiles([_codeFile.path]);
Future.delayed(const Duration(seconds: 15), () async {
if (_codeFile.existsSync()) {
_codeFile.deleteSync();
}
});
}
Future<void> _pickImportFile(BuildContext context) async {
FilePickerResult result = await FilePicker.platform.pickFiles();
if (result == null) {
return;
}
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
try {
File file = File(result.files.single.path);
final codes = await file.readAsString();
List<String> splitCodes = codes.split(",");
if (splitCodes.length == 1) {
splitCodes = codes.split("\n");
}
final parsedCodes = [];
for (final code in splitCodes) {
try {
parsedCodes.add(Code.fromRawData(code));
} catch (e) {
_logger.severe("Could not parse code", e);
}
}
for (final code in parsedCodes) {
await CodeStore.instance.addCode(code, shouldSync: false);
}
unawaited(AuthenticatorService.instance.sync());
await dialog.hide();
await showConfettiDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Text(
"Yay!",
style: Theme.of(context).textTheme.headline6,
),
content: Text(
"You have imported " + parsedCodes.length.toString() + " codes!",
),
actions: [
TextButton(
child: Text(
"Okay",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
},
),
],
);
},
);
} catch (e) {
await dialog.hide();
await showErrorDialog(
context,
"Sorry",
"Could not parse the selected file.\nPlease write to support@ente.io if you need help!",
);
}
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class MadeWithLoveWidget extends StatelessWidget {
const MadeWithLoveWidget({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
launchUrl(Uri.parse("https://ente.io"));
},
child: RichText(
text: TextSpan(
text: "made with ❤️ at ",
style: DefaultTextStyle.of(context).style,
children: const <TextSpan>[
TextSpan(
text: 'ente.io',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
),
);
}
}

View File

@@ -8,6 +8,8 @@ import 'package:ente_auth/ui/settings/about_section_widget.dart';
import 'package:ente_auth/ui/settings/account_section_widget.dart';
import 'package:ente_auth/ui/settings/app_version_widget.dart';
import 'package:ente_auth/ui/settings/danger_section_widget.dart';
import 'package:ente_auth/ui/settings/data_section_widget.dart';
import 'package:ente_auth/ui/settings/made_with_love_widget.dart';
import 'package:ente_auth/ui/settings/security_section_widget.dart';
import 'package:ente_auth/ui/settings/social_section_widget.dart';
import 'package:ente_auth/ui/settings/support_section_widget.dart';
@@ -60,6 +62,8 @@ class SettingsPage extends StatelessWidget {
contents.addAll([
AccountSectionWidget(),
sectionSpacing,
DataSectionWidget(),
sectionSpacing,
const SecuritySectionWidget(),
sectionSpacing,
]);
@@ -80,6 +84,7 @@ class SettingsPage extends StatelessWidget {
sectionSpacing,
const DangerSectionWidget(),
const AppVersionWidget(),
const MadeWithLoveWidget(),
const Padding(
padding: EdgeInsets.only(bottom: 60),
),

View File

@@ -0,0 +1,150 @@
// @dart=2.9
import 'package:ente_auth/services/user_service.dart';
import 'package:ente_auth/ui/lifecycle_event_handler.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinput/pin_put/pin_put.dart';
class TwoFactorAuthenticationPage extends StatefulWidget {
final String sessionID;
const TwoFactorAuthenticationPage(this.sessionID, {Key key})
: super(key: key);
@override
State<TwoFactorAuthenticationPage> createState() =>
_TwoFactorAuthenticationPageState();
}
class _TwoFactorAuthenticationPageState
extends State<TwoFactorAuthenticationPage> {
final _pinController = TextEditingController();
final _pinPutDecoration = BoxDecoration(
border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)),
borderRadius: BorderRadius.circular(15.0),
);
String _code = "";
LifecycleEventHandler _lifecycleEventHandler;
@override
void initState() {
_lifecycleEventHandler = LifecycleEventHandler(
resumeCallBack: () async {
if (mounted) {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null && data.text.length == 6) {
_pinController.text = data.text;
}
}
},
);
WidgetsBinding.instance.addObserver(_lifecycleEventHandler);
super.initState();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(_lifecycleEventHandler);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
"Two-factor authentication",
),
),
body: _getBody(),
);
}
Widget _getBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const Text(
"Enter the 6-digit code from\nyour authenticator app",
style: TextStyle(
height: 1.4,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const Padding(padding: EdgeInsets.all(32)),
Padding(
padding: const EdgeInsets.fromLTRB(40, 0, 40, 0),
child: PinPut(
fieldsCount: 6,
onSubmit: (String code) {
_verifyTwoFactorCode(code);
},
onChanged: (String pin) {
setState(() {
_code = pin;
});
},
controller: _pinController,
submittedFieldDecoration: _pinPutDecoration.copyWith(
borderRadius: BorderRadius.circular(20.0),
),
selectedFieldDecoration: _pinPutDecoration,
followingFieldDecoration: _pinPutDecoration.copyWith(
borderRadius: BorderRadius.circular(5.0),
border: Border.all(
color: const Color.fromRGBO(45, 194, 98, 0.5),
),
),
inputDecoration: const InputDecoration(
focusedBorder: InputBorder.none,
border: InputBorder.none,
counterText: '',
),
autofocus: true,
),
),
const Padding(padding: EdgeInsets.all(24)),
Container(
padding: const EdgeInsets.fromLTRB(80, 0, 80, 0),
width: double.infinity,
height: 64,
child: OutlinedButton(
onPressed: _code.length == 6
? () async {
_verifyTwoFactorCode(_code);
}
: null,
child: const Text("Verify"),
),
),
const Padding(padding: EdgeInsets.all(30)),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
UserService.instance.recoverTwoFactor(context, widget.sessionID);
},
child: Container(
padding: const EdgeInsets.all(10),
child: const Center(
child: Text(
"Lost device?",
style: TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
),
),
),
),
),
],
);
}
Future<void> _verifyTwoFactorCode(String code) async {
await UserService.instance.verifyTwoFactor(context, widget.sessionID, code);
}
}

View File

@@ -0,0 +1,112 @@
// @dart=2.9
import 'dart:ui';
import 'package:ente_auth/services/user_service.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:flutter/material.dart';
class TwoFactorRecoveryPage extends StatefulWidget {
final String sessionID;
final String encryptedSecret;
final String secretDecryptionNonce;
const TwoFactorRecoveryPage(
this.sessionID,
this.encryptedSecret,
this.secretDecryptionNonce, {
Key key,
}) : super(key: key);
@override
State<TwoFactorRecoveryPage> createState() => _TwoFactorRecoveryPageState();
}
class _TwoFactorRecoveryPageState extends State<TwoFactorRecoveryPage> {
final _recoveryKey = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
"Recover account",
style: TextStyle(
fontSize: 18,
),
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(60, 0, 60, 0),
child: TextFormField(
decoration: const InputDecoration(
hintText: "Enter your recovery key",
contentPadding: EdgeInsets.all(20),
),
style: const TextStyle(
fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()],
),
controller: _recoveryKey,
autofocus: false,
autocorrect: false,
keyboardType: TextInputType.multiline,
maxLines: null,
onChanged: (_) {
setState(() {});
},
),
),
const Padding(padding: EdgeInsets.all(24)),
Container(
padding: const EdgeInsets.fromLTRB(80, 0, 80, 0),
width: double.infinity,
height: 64,
child: OutlinedButton(
onPressed: _recoveryKey.text.isNotEmpty
? () async {
await UserService.instance.removeTwoFactor(
context,
widget.sessionID,
_recoveryKey.text,
widget.encryptedSecret,
widget.secretDecryptionNonce,
);
}
: null,
child: const Text("Recover"),
),
),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
showErrorDialog(
context,
"Contact support",
"Please drop an email to support@ente.io from your registered email address",
);
},
child: Container(
padding: const EdgeInsets.all(40),
child: Center(
child: Text(
"No recovery key?",
style: TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
color: Colors.white.withOpacity(0.9),
),
),
),
),
),
],
),
);
}
}

View File

@@ -3,7 +3,7 @@ import 'package:otp/otp.dart' as otp;
String getTotp(Code code) {
return otp.OTP.generateTOTPCodeString(
code.secret,
getSanitizedSecret(code.secret),
DateTime.now().millisecondsSinceEpoch,
length: code.digits,
interval: code.period,
@@ -14,7 +14,7 @@ String getTotp(Code code) {
String getNextTotp(Code code) {
return otp.OTP.generateTOTPCodeString(
code.secret,
getSanitizedSecret(code.secret),
DateTime.now().millisecondsSinceEpoch + code.period * 1000,
length: code.digits,
interval: code.period,
@@ -33,3 +33,7 @@ otp.Algorithm _getAlgorithm(Code code) {
return otp.Algorithm.SHA1;
}
}
String getSanitizedSecret(String secret) {
return secret.toUpperCase().trim();
}

View File

@@ -344,6 +344,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "4.6.1"
fixnum:
dependency: transitive
description:

View File

@@ -1,6 +1,6 @@
name: ente_auth
description: ente two-factor authenticator
version: 1.0.2+3
version: 1.0.7+7
publish_to: none
environment:
@@ -29,7 +29,7 @@ dependencies:
flutter_secure_storage: ^6.0.0
flutter_animation_progress_bar: ^2.2.1
flutter_slidable: ^2.0.0
file_picker: ^4.6.1
flutter:
sdk: flutter
flutter_bloc: ^8.0.1