diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index ea605259e..74239b3f5 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -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 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 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 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(); + } + } } diff --git a/lib/ui/two_factor_authentication_page.dart b/lib/ui/two_factor_authentication_page.dart new file mode 100644 index 000000000..97203f54d --- /dev/null +++ b/lib/ui/two_factor_authentication_page.dart @@ -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 createState() => + _TwoFactorAuthenticationPageState(); +} + +class _TwoFactorAuthenticationPageState + extends State { + 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 _verifyTwoFactorCode(String code) async { + await UserService.instance.verifyTwoFactor(context, widget.sessionID, code); + } +} diff --git a/lib/ui/two_factor_recovery_page.dart b/lib/ui/two_factor_recovery_page.dart new file mode 100644 index 000000000..1f378e5f0 --- /dev/null +++ b/lib/ui/two_factor_recovery_page.dart @@ -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 createState() => _TwoFactorRecoveryPageState(); +} + +class _TwoFactorRecoveryPageState extends State { + 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), + ), + ), + ), + ), + ), + ], + ), + ); + } +}