diff --git a/assets/js/app.js b/assets/js/app.js index ccad4ba3..9d23993e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -90,6 +90,8 @@ require('./jquery.tristate.js'); require('darkmode-js'); +window.ClipboardJS = require('clipboard'); + window.QRCode = require('qrcode'); require('../ts_src/ajax_ui'); diff --git a/assets/ts_src/event_listeners.ts b/assets/ts_src/event_listeners.ts index 8a3ed399..c415839d 100644 --- a/assets/ts_src/event_listeners.ts +++ b/assets/ts_src/event_listeners.ts @@ -469,6 +469,32 @@ $(document).on("ajaxUI:start ajaxUI:reload", function() { }); }); +$(document).on("ajaxUI:start ajaxUI:reload", function() { + function setTooltip(btn, message) { + $(btn).tooltip('hide') + .attr('data-original-title', message) + .tooltip('show'); + } + + function hideTooltip(btn) { + setTimeout(function() { + $(btn).tooltip('hide'); + }, 1000); + } + + //@ts-ignore + var clipboard = new ClipboardJS('.btn'); + clipboard.on('success', function(e) { + setTooltip(e.trigger, 'Copied!'); + hideTooltip(e.trigger); + }); + + clipboard.on('error', function(e) { + setTooltip(e.trigger, 'Failed!'); + hideTooltip(e.trigger); + }); +}); + //Need for proper body padding, with every navbar height $(window).resize(function () { let height : number = $('#navbar').height() + 10; diff --git a/config/services.yaml b/config/services.yaml index 7fa8ebf3..90c5a93f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -123,7 +123,7 @@ services: App\Services\TFA\BackupCodeGenerator: arguments: $code_length: 8 - $code_count: 10 + $code_count: 15 App\Services\TranslationExtractor\PermissionExtractor: tags: diff --git a/package.json b/package.json index 99980aad..ad964654 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "bootstrap-fileinput": "^5.0.1", "bootstrap-select": "^1.13.8", "bootswatch": "^4.3.1", + "clipboard": "^2.0.4", "copy-webpack-plugin": "^5.0.4", "corejs-typeahead": "^1.2.1", "darkmode-js": "^1.5.3", diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index c31f990d..697fe56e 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -31,7 +31,9 @@ use App\Form\UserSettingsType; use App\Services\EntityExporter; use App\Services\EntityImporter; use App\Services\StructuralElementRecursionHelper; +use App\Services\TFA\BackupCodeManager; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Util\Exception; use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticator; use Symfony\Component\Asset\Packages; use Symfony\Component\Form\Extension\Core\Type\HiddenType; @@ -151,10 +153,33 @@ class UserController extends AdminPages\BaseAdminController ]); } + /** + * @Route("/2fa_backup_codes", name="show_backup_codes") + */ + public function showBackupCodes() + { + $user = $this->getUser(); + if (!$user instanceof User) { + return new \RuntimeException('This controller only works only for Part-DB User objects!'); + } + + //When user change its settings, he should be logged in fully. + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + if (empty($user->getBackupCodes())) { + $this->addFlash('error', 'You do not have any backup codes enabled, therefore you can not view them!'); + throw new Exception('You do not have any backup codes enabled, therefore you can not view them!'); + } + + return $this->render('Users/backup_codes.html.twig', [ + 'user' => $user + ]); + } + /** * @Route("/settings", name="user_settings") */ - public function userSettings(Request $request, EntityManagerInterface $em, UserPasswordEncoderInterface $passwordEncoder, GoogleAuthenticator $googleAuthenticator) + public function userSettings(Request $request, EntityManagerInterface $em, UserPasswordEncoderInterface $passwordEncoder, GoogleAuthenticator $googleAuthenticator, BackupCodeManager $backupCodeManager) { /** * @var User @@ -252,12 +277,14 @@ class UserController extends AdminPages\BaseAdminController if (!$google_enabled) { //Save 2FA settings (save secrets) $user->setGoogleAuthenticatorSecret($google_form->get('googleAuthenticatorSecret')->getData()); + $backupCodeManager->enableBackupCodes($user); $em->flush(); $this->addFlash('success', 'user.settings.2fa.google.activated'); return $this->redirectToRoute('user_settings'); } elseif ($google_enabled) { //Remove secret to disable google authenticator $user->setGoogleAuthenticatorSecret(null); + $backupCodeManager->disableBackupCodesIfUnused($user); $em->flush(); $this->addFlash('success', 'user.settings.2fa.google.disabled'); return $this->redirectToRoute('user_settings'); @@ -270,6 +297,7 @@ class UserController extends AdminPages\BaseAdminController *****************************/ return $this->render('Users/user_settings.html.twig', [ + 'user' => $user, 'settings_form' => $form->createView(), 'pw_form' => $pw_form->createView(), 'page_need_reload' => $page_need_reload, diff --git a/src/Entity/UserSystem/User.php b/src/Entity/UserSystem/User.php index a03aa85a..21c20e45 100644 --- a/src/Entity/UserSystem/User.php +++ b/src/Entity/UserSystem/User.php @@ -764,7 +764,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe */ public function getBackupCodes() : array { - return $this->backupCodes; + return $this->backupCodes ?? []; } /** diff --git a/src/Services/TFA/BackupCodeGenerator.php b/src/Services/TFA/BackupCodeGenerator.php index a180dae0..053b741e 100644 --- a/src/Services/TFA/BackupCodeGenerator.php +++ b/src/Services/TFA/BackupCodeGenerator.php @@ -46,7 +46,6 @@ class BackupCodeGenerator /** * Returns a full backup code set. The code count can be configured in the constructor * @return string[] An array containing different backup codes. - * @throws \Exception If no entropy source is available */ public function generateCodeSet() : array { diff --git a/src/Services/TFA/BackupCodeManager.php b/src/Services/TFA/BackupCodeManager.php new file mode 100644 index 00000000..ec73e367 --- /dev/null +++ b/src/Services/TFA/BackupCodeManager.php @@ -0,0 +1,57 @@ +backupCodeGenerator = $backupCodeGenerator; + } + + /** + * Enable backup codes for the given user, by generating a set of backup codes. + * If the backup codes were already enabled before, they a + * @param User $user + */ + public function enableBackupCodes(User $user) + { + if(empty($user->getBackupCodes())) { + $this->regenerateBackupCodes($user); + } + } + + /** + * Disable (remove) the backup codes when no other 2 factor authentication methods are enabled. + * @param User $user + */ + public function disableBackupCodesIfUnused(User $user) + { + if($user->isU2FAuthEnabled() || $user->isGoogleAuthenticatorEnabled()) { + return; + } + + $user->setBackupCodes([]); + } + + /** + * Generates a new set of backup codes for the user. If no backup codes were available before, new ones are + * generated. + * @param User $user The user for which the backup codes should be regenerated + */ + public function regenerateBackupCodes(User $user) + { + $codes = $this->backupCodeGenerator->generateCodeSet(); + $user->setBackupCodes($codes); + } +} \ No newline at end of file diff --git a/templates/Users/_2fa_settings.html.twig b/templates/Users/_2fa_settings.html.twig index 0ac1c6cd..fe21b45f 100644 --- a/templates/Users/_2fa_settings.html.twig +++ b/templates/Users/_2fa_settings.html.twig @@ -1,3 +1,5 @@ +{# @var user \App\Entity\UserSystem\User #} +
@@ -19,6 +21,10 @@
{{ form_start(google_form) }} {% if not tfa_google.enabled %} +
+
{% trans %}tfa_google.disabled_message{% endtrans %}
+
+
@@ -50,13 +56,33 @@ {{ form_row(google_form.google_confirmation) }} {% else %} - Google Authenticator is enabled! TODO +
+
{% trans %}tfa_google.enabled_message{% endtrans %}
+
{% endif %} {{ form_row(google_form.submit) }} {{ form_end(google_form) }}
+ {% if user.backupCodes is empty %} +
+
{% trans %}tfa_backup.disabled{% endtrans %}
+ {% trans %}tfa_backup.explanation{% endtrans %} +
+ {% else %} +
+
{% trans %}tfa_backup.enabled{% endtrans %}
+ {% trans %}tfa_backup.explanation{% endtrans %} +
+
+

{% trans %}tfa_backup.remaining_tokens{% endtrans %}: {{ user.backupCodes | length }}

+

{% trans %}tfa_backup.generation_date{% endtrans %}: {{ user.backupCodesGenerationDate | format_datetime }}

+
+ + {% endif %}
diff --git a/templates/Users/backup_codes.html.twig b/templates/Users/backup_codes.html.twig new file mode 100644 index 00000000..554beb78 --- /dev/null +++ b/templates/Users/backup_codes.html.twig @@ -0,0 +1,40 @@ +{% extends "base.html.twig" %} + +{% block title %}{{ partdb_title }} {% trans %}tfa_backup.codes.title{% endtrans %}{% endblock %} + +{% block body %} +
+
+
+ {{ partdb_title }} {% trans %}tfa_backup.codes.title{% endtrans %} +
+
+
{% trans %}tfa_backup.codes.explanation{% endtrans %}
+

{% trans %}tfa_backup.codes.help{% endtrans %}

+ +

{% trans %}tfa_backup.remaining_tokens{% endtrans %}: {{ user.backupCodes | length }}

+

{% trans %}tfa_backup.username{% endtrans %}: {{ user.name }}

+

{% trans %}tfa_backup.generation_date{% endtrans %}: {{ user.backupCodesGenerationDate | format_datetime }}

+ +
+
+
    + {% for code in user.backupCodes %} +

  • {{ code }}
  • + {% endfor %} +
+
+
+ +

{% trans with {'%date%': "now" | format_datetime} %}tfa_backup.codes.page_generated_on{% endtrans %}

+ + + {% trans %}tfa_backup.codes.print{% endtrans %} + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/tests/Entity/UserSystem/UserTest.php b/tests/Entity/UserSystem/UserTest.php index f201348f..6290aad2 100644 --- a/tests/Entity/UserSystem/UserTest.php +++ b/tests/Entity/UserSystem/UserTest.php @@ -57,13 +57,16 @@ class UserTest extends TestCase $this->assertSame($expected ,$user->isGoogleAuthenticatorEnabled()); } + /** + * @requires PHPUnit 8 + */ public function testSetBackupCodes() { $user = new User(); $codes = ["test", "invalid", "test"]; $user->setBackupCodes($codes); // Backup Codes generation date must be changed! - $this->assertEquals(new \DateTime(), $user->getBackupCodesGenerationDate(), '', 0.1); + $this->assertEqualsWithDelta(new \DateTime(), $user->getBackupCodesGenerationDate(), 0.1); $this->assertEquals($codes, $user->getBackupCodes()); //Test what happens if we delete the backup keys diff --git a/tests/Services/TFA/BackupCodeManagerTest.php b/tests/Services/TFA/BackupCodeManagerTest.php new file mode 100644 index 00000000..45d9198f --- /dev/null +++ b/tests/Services/TFA/BackupCodeManagerTest.php @@ -0,0 +1,69 @@ +service = self::$container->get(BackupCodeManager::class); + } + + public function testRegenerateBackupCodes() + { + $user = new User(); + $old_codes = ['aaaa', 'bbbb']; + $user->setBackupCodes($old_codes); + $this->service->regenerateBackupCodes($user); + $this->assertNotEquals($old_codes, $user->getBackupCodes()); + } + + public function testEnableBackupCodes() + { + $user = new User(); + //Check that nothing is changed, if there are already backup codes + + $old_codes = ['aaaa', 'bbbb']; + $user->setBackupCodes($old_codes); + $this->service->enableBackupCodes($user); + $this->assertEquals($old_codes, $user->getBackupCodes()); + + //When no old codes are existing, it should generate a set + $user->setBackupCodes([]); + $this->service->enableBackupCodes($user); + $this->assertNotEmpty($user->getBackupCodes()); + } + + public function testDisableBackupCodesIfUnused() + { + $user = new User(); + + //By default nothing other 2FA is activated, so the backup codes should be disabled + $codes = ['aaaa', 'bbbb']; + $user->setBackupCodes($codes); + $this->service->disableBackupCodesIfUnused($user); + $this->assertEmpty($user->getBackupCodes()); + + $user->setBackupCodes($codes); + + $user->setGoogleAuthenticatorSecret('jskf'); + $this->service->disableBackupCodesIfUnused($user); + $this->assertEquals($codes, $user->getBackupCodes()); + + $user->setGoogleAuthenticatorSecret(''); + $user->addU2FKey(new U2FKey()); + $this->service->disableBackupCodesIfUnused($user); + $this->assertEquals($codes, $user->getBackupCodes()); + } +} diff --git a/yarn.lock b/yarn.lock index 28e056a8..c7fabea4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1705,6 +1705,15 @@ clean-webpack-plugin@^0.1.19: dependencies: rimraf "^2.6.1" +clipboard@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.4.tgz#836dafd66cf0fea5d71ce5d5b0bf6e958009112d" + integrity sha512-Vw26VSLRpJfBofiVaFb/I8PVfdI1OxKcYShe6fm0sP/DtmiWQNCjhM/okTvdCo0G+lMMm1rMYbk4IK4x1X+kgQ== + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + cliui@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" @@ -2405,6 +2414,11 @@ del@^4.1.1: pify "^4.0.1" rimraf "^2.6.3" +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== + delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -3353,6 +3367,13 @@ globby@^7.1.1: pify "^3.0.0" slash "^1.0.0" +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= + dependencies: + delegate "^3.1.2" + graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" @@ -5956,6 +5977,11 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= + selfsigned@^1.10.7: version "1.10.7" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.7.tgz#da5819fd049d5574f28e88a9bcc6dbc6e6f3906b" @@ -6563,6 +6589,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-emitter@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"