From ec710a612b86088614676c20cb4fa3b24e02a515 Mon Sep 17 00:00:00 2001
From: Vincent Gao <vincent.gao@dpc.vic.gov.au>
Date: Wed, 11 Oct 2023 19:58:57 +1100
Subject: [PATCH] exclude css and js router

---
 config/install/tfa.settings.yml            |   1 +
 config/schema/tfa.schema.yml               |   3 +
 src/EventSubscriber/ForceTfaSetup.php      | 141 +++++++++++++++++++++
 src/Form/SettingsForm.php                  |  18 ++-
 src/Form/TfaOverviewForm.php               |  16 ++-
 src/TfaLoginContextTrait.php               |  19 ++-
 tests/src/Functional/ForceTfaSetupTest.php |  99 +++++++++++++++
 tfa.services.yml                           |   7 +-
 8 files changed, 292 insertions(+), 12 deletions(-)
 create mode 100644 src/EventSubscriber/ForceTfaSetup.php
 create mode 100644 tests/src/Functional/ForceTfaSetupTest.php

diff --git a/config/install/tfa.settings.yml b/config/install/tfa.settings.yml
index 38ad658..d26f711 100644
--- a/config/install/tfa.settings.yml
+++ b/config/install/tfa.settings.yml
@@ -1,6 +1,7 @@
 langcode: en
 enabled: false
 required_roles: { }
+forced: 0
 send_plugins: { }
 login_plugins: { }
 default_validation_plugin: ''
diff --git a/config/schema/tfa.schema.yml b/config/schema/tfa.schema.yml
index 4740703..05a3559 100644
--- a/config/schema/tfa.schema.yml
+++ b/config/schema/tfa.schema.yml
@@ -11,6 +11,9 @@ tfa.settings:
       sequence:
         type: string
         label: 'Role'
+    forced:
+      type: integer
+      label: 'Force required roles to setup when on last validation skip'
     send_plugins:
      type: sequence
      label: 'Enabled send plugins'
diff --git a/src/EventSubscriber/ForceTfaSetup.php b/src/EventSubscriber/ForceTfaSetup.php
new file mode 100644
index 0000000..e8d58dc
--- /dev/null
+++ b/src/EventSubscriber/ForceTfaSetup.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\tfa\EventSubscriber;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\Url;
+use Drupal\tfa\TfaValidationPluginManager;
+use Drupal\tfa\TfaLoginPluginManager;
+use Drupal\tfa\TfaLoginContextTrait;
+use Drupal\user\UserDataInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+/**
+ * Event subscriber for enforcing TFA.
+ */
+class ForceTfaSetup implements EventSubscriberInterface {
+  use TfaLoginContextTrait;
+
+  /**
+   * The route match.
+   *
+   * @var \Drupal\Core\Routing\RouteMatchInterface
+   */
+  protected $routeMatch;
+
+  /**
+   * The messenger.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * The user account service.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The user storage.
+   *
+   * @var \Drupal\user\UserStorageInterface
+   */
+  protected $userStorage;
+
+  /**
+   * Constructs an AutologoutSubscriber object.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The route match.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger.
+   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\user\UserDataInterface $user_data
+   *   The user data service.
+   * @param \Drupal\tfa\TfaValidationPluginManager $tfa_validation_manager
+   *   The plugin manager for TFA validation plugins.
+   * @param \Drupal\tfa\TfaLoginPluginManager $tfa_plugin_manager
+   *   The plugin manager for TFA login plugins.
+   */
+  public function __construct(RouteMatchInterface $route_match, MessengerInterface $messenger, AccountProxyInterface $current_user, ConfigFactoryInterface $config_factory,
+  EntityTypeManagerInterface $entity_type_manager, UserDataInterface $user_data, TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager) {
+    $this->messenger = $messenger;
+    $this->routeMatch = $route_match;
+    $this->currentUser = $current_user;
+    $this->userStorage = $entity_type_manager->getStorage('user');
+    $this->tfaSettings = $config_factory->get('tfa.settings');
+    $this->userData = $user_data;
+    $this->tfaValidationManager = $tfa_validation_manager;
+    $this->tfaLoginManager = $tfa_plugin_manager;
+  }
+
+  /**
+   * Redirect users to TFA overview when no remaining skips.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
+   *   The request event.
+   */
+  public function redirect(RequestEvent $event): void {
+    /** @var \Drupal\user\UserInterface $user */
+    $user = $this->userStorage->load($this->currentUser->id());
+    $this->setUser($user);
+
+    if ($this->isReady() || !$this->forceTFASetup()) {
+      return;
+    }
+
+    $this->messenger->addWarning(t('You need to enable Two Factor Authentication.'));
+
+    // Don't redirect the user if on password/profile edit page,
+    // as it is possible the user used one-time login URL
+    // and need to change the password.
+    $ignored_route_names = [
+      'user.login',
+      'user.logout',
+      'user.pass',
+      'user.edit',
+      'entity.user.edit_form',
+      'user.reset.login',
+      'user.reset',
+      'user.reset.form',
+      'user.well-known.change_password',
+      'tfa.entry',
+      'tfa.login',
+      'tfa.overview',
+      'tfa.validation.setup',
+      'tfa.disable',
+      'tfa.plugin.reset',
+      'system.js_asset',
+      'system.css_asset'
+    ];
+    if (in_array($this->routeMatch->getRouteName(), $ignored_route_names)) {
+      return;
+    }
+
+    $tfa_overview_url = Url::fromRoute('tfa.overview', ['user' => $this->user->id()]);
+    $event->setResponse(new RedirectResponse($tfa_overview_url->toString()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[KernelEvents::REQUEST][] = ['redirect', 32];
+    return $events;
+  }
+
+}
diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php
index 1d63a21..e231fec 100644
--- a/src/Form/SettingsForm.php
+++ b/src/Form/SettingsForm.php
@@ -179,6 +179,14 @@ class SettingsForm extends ConfigFormBase {
       '#required' => FALSE,
     ];
 
+    $form['tfa_forced'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Force TFA setup'),
+      '#default_value' => $config->get('forced'),
+      '#description' => $this->t('Force TFA setup on login, redirect user to FTA overview page.'),
+      '#states' => $enabled_state,
+    ];
+
     $form['tfa_allowed_validation_plugins'] = [
       '#type' => 'checkboxes',
       '#title' => $this->t('Allowed Validation plugins'),
@@ -186,7 +194,14 @@ class SettingsForm extends ConfigFormBase {
       '#default_value' => $config->get('allowed_validation_plugins') ?: ['tfa_totp'],
       '#description' => $this->t('Plugins that can be setup by users for various TFA processes.'),
       // Show only when TFA is enabled.
-      '#states' => $enabled_state,
+      '#states' => [
+        'visible' => [
+          [
+            ':input[name="tfa_enabled"]' => ['checked' => TRUE],
+            ':input[name="tfa_forced"]' => ['checked' => FALSE],
+          ],
+        ],
+      ],
       '#required' => TRUE,
     ];
     $form['tfa_validate'] = [
@@ -465,6 +480,7 @@ class SettingsForm extends ConfigFormBase {
     $this->config('tfa.settings')
       ->set('enabled', $form_state->getValue('tfa_enabled'))
       ->set('required_roles', $form_state->getValue('tfa_required_roles'))
+      ->set('forced', $form_state->getValue('tfa_forced'))
       ->set('send_plugins', array_filter($send_plugins))
       ->set('login_plugins', array_filter($login_plugins))
       ->set('login_plugin_settings', $form_state->getValue('login_plugin_settings'))
diff --git a/src/Form/TfaOverviewForm.php b/src/Form/TfaOverviewForm.php
index 52b2e11..f667b6d 100644
--- a/src/Form/TfaOverviewForm.php
+++ b/src/Form/TfaOverviewForm.php
@@ -197,13 +197,15 @@ class TfaOverviewForm extends FormBase {
         }
       }
 
-      $output['validation_skip_status'] = [
-        '#type'   => 'markup',
-        '#markup' => '<p>' . $this->t('Number of times validation skipped: @skipped of @limit', [
-          '@skipped' => $user_tfa['validation_skipped'] ?? 0,
-          '@limit' => $config->get('validation_skip'),
-        ]) . '</p>',
-      ];
+      if (!$config->get('forced')) {
+        $output['validation_skip_status'] = [
+          '#type' => 'markup',
+          '#markup' => '<p>' . $this->t('Number of times validation skipped: @skipped of @limit', [
+              '@skipped' => $user_tfa['validation_skipped'] ?? 0,
+              '@limit' => $config->get('validation_skip'),
+            ]) . '</p>',
+        ];
+      }
     }
     else {
       $output['disabled'] = [
diff --git a/src/TfaLoginContextTrait.php b/src/TfaLoginContextTrait.php
index f4f2ca6..d693b4e 100644
--- a/src/TfaLoginContextTrait.php
+++ b/src/TfaLoginContextTrait.php
@@ -4,7 +4,6 @@ namespace Drupal\tfa;
 
 use Drupal\Component\Plugin\Exception\PluginException;
 use Drupal\user\UserInterface;
-use Psr\Log\LoggerInterface;
 use Drupal\Core\Url;
 
 /**
@@ -136,6 +135,17 @@ trait TfaLoginContextTrait {
     return FALSE;
   }
 
+  /**
+   * Should we force TFA setup?
+   *
+   * @return bool
+   *   TRUE if TFA is enabled and there are no remaining skips left.
+   */
+  public function forceTFASetup(): bool {
+    return !$this->isTfaDisabled()
+      && $this->tfaSettings->get('forced');
+  }
+
   /**
    * Remaining number of allowed logins without setting up TFA.
    *
@@ -217,7 +227,11 @@ trait TfaLoginContextTrait {
    *   Return true if the user can login without TFA,
    *   otherwise return false.
    */
-  public function canLoginWithoutTfa(LoggerInterface $logger) {
+  public function canLoginWithoutTfa() {
+    if ($this->forceTFASetup()) {
+      $this->doUserLogin();
+      return;
+    }
     // User may be able to skip TFA, depending on module settings and number of
     // prior attempts.
     $remaining = $this->remainingSkips();
@@ -240,7 +254,6 @@ trait TfaLoginContextTrait {
     else {
       $message = $this->config('tfa.settings')->get('help_text');
       $this->messenger()->addError($message);
-      $logger->notice('@name has no more remaining attempts for bypassing the second authentication factor.', ['@name' => $user->getAccountName()]);
     }
 
     // User can't login without TFA.
diff --git a/tests/src/Functional/ForceTfaSetupTest.php b/tests/src/Functional/ForceTfaSetupTest.php
new file mode 100644
index 0000000..ff8798b
--- /dev/null
+++ b/tests/src/Functional/ForceTfaSetupTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\Tests\tfa\Functional;
+
+/**
+ * Tests for the tfa setup enforcement.
+ *
+ * @group Tfa
+ */
+class ForceTfaSetupTest extends TfaTestBase {
+
+  /**
+   * User doing the TFA Validation.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $webUser;
+
+  /**
+   * Administrator to handle configurations.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $adminUser;
+
+  /**
+   * TFA settings.
+   *
+   * @var \Drupal\Core\Config\Config
+   */
+  protected $config;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() : void {
+    parent::setUp();
+    $this->webUser = $this->drupalCreateUser(['setup own tfa']);
+    $this->adminUser = $this->drupalCreateUser(['admin tfa settings']);
+    $this->config = $this->config('tfa.settings');
+    $this->config->set('validation_skip', 2)
+      ->set('enabled', 1)
+      ->set('forced', 1)
+      ->set('default_validation_plugin', 'tfa_recovery_code')
+      ->set('allowed_validation_plugins', ['tfa_recovery_code' => 'tfa_recovery_code'])
+      ->set('encryption', $this->encryptionProfile->id())
+      ->save();
+  }
+
+  /**
+   * Tests the tfa login process.
+   */
+  public function testTfaLogin() {
+    $assert_session = $this->assertSession();
+
+    // Setup enforcement is not active when the user roles are not required.
+    $this->drupalLogin($this->webUser);
+    $assert_session->statusCodeEquals(200);
+    $assert_session->addressEquals('user/' . $this->webUser->id());
+
+    // Make it required.
+    $web_user_roles = $this->webUser->getRoles(TRUE);
+    $this->config->set('required_roles', [$web_user_roles[0] => $web_user_roles[0]])
+      ->save();
+
+    // The User is redirected to the tfa page.
+    $this->drupalLogout();
+    $this->drupalLogin($this->webUser);
+    $assert_session->statusCodeEquals(200);
+    $assert_session->addressEquals('user/' . $this->webUser->id() . '/security/tfa');
+
+    // Disable again.
+    $this->config->set('forced', 0)->save();
+    $this->drupalGet('user/' . $this->webUser->id());
+    $assert_session->statusCodeEquals(200);
+    $assert_session->addressEquals('user/' . $this->webUser->id());
+
+    // Re-enable.
+    $this->config->set('forced', 1)->save();
+    $this->drupalGet('user/' . $this->webUser->id());
+    $assert_session->statusCodeEquals(200);
+    $assert_session->addressEquals('user/' . $this->webUser->id() . '/security/tfa');
+
+    $this->clickLink('Generate codes');
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains('Enter your current password to continue.');
+    $edit = [
+      'current_pass' => $this->webUser->passRaw,
+    ];
+    $this->submitForm($edit, 'Confirm');
+    $this->submitForm([], 'Save codes to account');
+
+    // Other pages can be visited now.
+    $this->drupalGet('user/' . $this->webUser->id());
+    $assert_session->statusCodeEquals(200);
+    $assert_session->addressEquals('user/' . $this->webUser->id());
+  }
+
+}
diff --git a/tfa.services.yml b/tfa.services.yml
index 44ae361..414148f 100644
--- a/tfa.services.yml
+++ b/tfa.services.yml
@@ -17,4 +17,9 @@ services:
   tfa.route_subscriber:
     class: Drupal\tfa\Routing\TfaRouteSubscriber
     tags:
-      - { name: event_subscriber }
\ No newline at end of file
+      - { name: event_subscriber }
+  tfa.force_setup:
+    class: Drupal\tfa\EventSubscriber\ForceTfaSetup
+    arguments: ['@current_route_match', '@messenger', '@current_user', '@config.factory', '@entity_type.manager', '@user.data', '@plugin.manager.tfa.validation', '@plugin.manager.tfa.login']
+    tags:
+      - { name: event_subscriber }
-- 
2.39.3 (Apple Git-145)

