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..97d6545 --- /dev/null +++ b/src/EventSubscriber/ForceTfaSetup.php @@ -0,0 +1,139 @@ +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', + ]; + 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' => '
' . $this->t('Number of times validation skipped: @skipped of @limit', [ - '@skipped' => $user_tfa['validation_skipped'] ?? 0, - '@limit' => $config->get('validation_skip'), - ]) . '
', - ]; + if (!$config->get('forced')) { + $output['validation_skip_status'] = [ + '#type' => 'markup', + '#markup' => '' . $this->t('Number of times validation skipped: @skipped of @limit', [ + '@skipped' => $user_tfa['validation_skipped'] ?? 0, + '@limit' => $config->get('validation_skip'), + ]) . '
', + ]; + } } 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 @@ +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 }