diff --git a/behat.yml b/behat.yml index cebeb9f81..523d379ef 100644 --- a/behat.yml +++ b/behat.yml @@ -20,6 +20,7 @@ default: - '%paths.base%/modules/tide_webform/tests/behat/features' - '%paths.base%/modules/tide_publication/tests/behat/features' - '%paths.base%/modules/tide_alert/tests/behat/features' + - '%paths.base%/modules/tide_tfa/tests/behat/features' contexts: - Tide\Tests\Context\FeatureContext - Drupal\DrupalExtension\Context\MinkContext diff --git a/composer.json b/composer.json index a975da3d4..c2369dbaa 100644 --- a/composer.json +++ b/composer.json @@ -59,8 +59,8 @@ "drupal/smtp": "^1.2", "drupal/stage_file_proxy": "^2.0", "drupal/tablefield": "2.4", - "drupal/tfa": "1.9", - "drupal/tfa_email_otp": "^1.0@beta", + "drupal/tfa": "^1.12", + "drupal/tfa_email_otp": "^1.0", "drupal/token_conditions": "dev-compatible-with-d10", "drupal/token_filter": "^2.0", "drupal/twig_field_value": "^2.0", @@ -124,7 +124,8 @@ "drupal/shield": "^1.8", "drupal/media_alias_display": "^2.1", "drupal/search_api_exclude_entity": "^3.0", - "drupal/default_paragraphs": "^2.0" + "drupal/default_paragraphs": "^2.0", + "drupal/view_password": "^6.0" }, "repositories": { "drupal": { diff --git a/modules/tide_dashboard/tide_dashboard.module b/modules/tide_dashboard/tide_dashboard.module index 56d5d3866..fce8255a2 100644 --- a/modules/tide_dashboard/tide_dashboard.module +++ b/modules/tide_dashboard/tide_dashboard.module @@ -5,6 +5,11 @@ * Tide Dashboard. */ +use Drupal\Core\Breadcrumb\Breadcrumb; +use Drupal\Core\Link; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Url; + /** * Implements hook_toolbar_alter(). * @@ -39,3 +44,27 @@ function tide_dashboard_user_login() { $request->query->set('destination', '/admin/workbench'); } } + +/** + * Implements hook_system_breadcrumb_alter(). + */ +function tide_dashboard_system_breadcrumb_alter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context) { + $links = $breadcrumb->getLinks(); + + if (!empty($links)) { + // Check if the first link is the Home link. + if ($links[0]->getUrl()->isRouted() && $links[0]->getUrl()->getRouteName() === '') { + + $new_url = Url::fromUserInput('/admin/workbench'); + $new_link = Link::fromTextAndUrl($links[0]->getText(), $new_url); + + // Swap the first link to workbench. + $links[0] = $new_link; + + $reflection = new \ReflectionClass($breadcrumb); + $property = $reflection->getProperty('links'); + $property->setAccessible(TRUE); + $property->setValue($breadcrumb, $links); + } + } +} diff --git a/modules/tide_tfa/src/Form/TideTfaOverviewForm.php b/modules/tide_tfa/src/Form/TideTfaOverviewForm.php new file mode 100644 index 000000000..0a1cfe099 --- /dev/null +++ b/modules/tide_tfa/src/Form/TideTfaOverviewForm.php @@ -0,0 +1,149 @@ + 'markup', + '#markup' => '

' . $this->t('Multi-factor authentication provides + additional security for your account.
With multi-factor authentication enabled, + you log in to the CMS with a verification code in addition to your username and + password.') . '

', + ]; + // $form_state['storage']['account'] = $user;. + $config = $this->config('tfa.settings'); + $user_tfa = $this->tfaGetTfaData($user->id(), $this->userData); + $enabled = isset($user_tfa['status']) && $user_tfa['status']; + + if ($config->get('enabled')) { + $enabled = isset($user_tfa['status'], $user_tfa['data']) && !empty($user_tfa['data']['plugins']) && $user_tfa['status']; + $enabled_plugins = $user_tfa['data']['plugins'] ?? []; + + $validation_plugins = $this->tfaValidation->getDefinitions(); + if ($validation_plugins) { + $output['validation'] = [ + '#type' => 'details', + '#title' => $this->t('Validation plugins'), + '#open' => TRUE, + ]; + + foreach ($validation_plugins as $plugin_id => $plugin) { + if (!empty($config->get('allowed_validation_plugins')[$plugin_id])) { + $output['validation'][$plugin_id] = $this->tfaPluginSetupFormOverview($plugin, $user, !empty($enabled_plugins[$plugin_id])); + } + } + } + + if ($enabled) { + $login_plugins = $this->tfaLogin->getDefinitions(); + if ($login_plugins) { + $output['login'] = [ + '#type' => 'details', + '#title' => $this->t('Login plugins'), + '#open' => TRUE, + '#access' => FALSE, + ]; + + foreach ($login_plugins as $plugin_id => $plugin) { + if (!empty($config->get('login_plugins')[$plugin_id])) { + $output['login'][$plugin_id] = $this->tfaPluginSetupFormOverview($plugin, $user, TRUE); + $output['login']['#access'] = TRUE; + } + } + } + + $send_plugins = $this->tfaSend->getDefinitions(); + if ($send_plugins) { + $output['send'] = [ + '#type' => 'details', + '#title' => $this->t('Send plugins'), + '#open' => TRUE, + ]; + + foreach ($send_plugins as $plugin_id => $plugin) { + if (!empty($config->get('send_plugins')[$plugin_id])) { + $output['send'][$plugin_id] = $this->tfaPluginSetupFormOverview($plugin, $user, TRUE); + } + } + } + } + + // Moved it inside to show the status if only TFA is enabled. + if (!empty($user_tfa)) { + if ($enabled && !empty($user_tfa['data']['plugins'])) { + $disable_url = Url::fromRoute('tfa.disable', ['user' => $user->id()]); + if ($disable_url->access()) { + $status_text = $this->t('Status: Multi-factor authentication enabled, set + @time. Disable Multi-factor authentication', [ + '@time' => $this->dateFormatter->format($user_tfa['saved']), + ':url' => $disable_url->toString(), + ]); + } + else { + $status_text = $this->t('Status: Multi-factor authentication enabled'); + } + } + else { + $status_text = $this->t('Status: Multi-factor authentication disabled'); + } + $output['status'] = [ + '#type' => 'markup', + '#markup' => '

' . $status_text . '

', + ]; + } + + if (!$config->get('forced')) { + $validation_skipped = $user_tfa['validation_skipped'] ?? 0; + + $output['validation_skip_status'] = [ + '#type' => 'markup', + '#markup' => '

' . $this->t( + 'Authentication setup: @remaining logins remain before multi-factor authentication is required', + [ + '@remaining' => $config->get('validation_skip') - $validation_skipped, + ] + ) . '

', + ]; + } + } + else { + $output['disabled'] = [ + '#type' => 'markup', + '#markup' => 'Currently there are no enabled plugins.', + ]; + } + + if ($this->canPerformReset($user)) { + $output['actions'] = ['#type' => 'actions']; + $output['actions']['reset_skip_attempts'] = [ + '#type' => 'submit', + '#value' => $this->t('Reset skip validation attempts'), + '#submit' => ['::resetSkipValidationAttempts'], + ]; + $output['account'] = [ + '#type' => 'value', + '#value' => $user, + ]; + } + + return $output; + } + +} diff --git a/modules/tide_tfa/src/Plugin/TfaSetup/TideTfaEmailOtpSetup.php b/modules/tide_tfa/src/Plugin/TfaSetup/TideTfaEmailOtpSetup.php index 6b9a8f3be..bb5c98720 100644 --- a/modules/tide_tfa/src/Plugin/TfaSetup/TideTfaEmailOtpSetup.php +++ b/modules/tide_tfa/src/Plugin/TfaSetup/TideTfaEmailOtpSetup.php @@ -28,10 +28,16 @@ public function getSetupForm(array $form, FormStateInterface $form_state) { $params = $form_state->getValues(); $userData = $this->userData->get('tfa', $params['account']->id(), 'tfa_email_otp'); + $form['email_otp_heading'] = [ + '#type' => 'html_tag', + '#tag' => 'h2', + '#value' => $this->t('Email authentication for login'), + ]; + // [SD-294] Changing the title and description. $form['enabled'] = [ '#type' => 'checkbox', - '#title' => $this->t('Yes, email me a verification code every time I log in'), + '#title' => $this->t('I agree to be sent a verification code via email each time I log in.'), '#description' => $this->t('Each single-use verification code expires after use, or after 10 minutes if not used.'), '#required' => TRUE, '#default_value' => $userData['enable'] ?? 0, @@ -58,7 +64,7 @@ public function getOverview(array $params) { // [SD-294] Modify the description. $description = ''; if ($params['enabled']) { - $description .= $this->t('

Enabled

'); + $description .= $this->t('

Multi-factor authentication enabled

'); } $output = [ 'heading' => [ @@ -77,7 +83,7 @@ public function getOverview(array $params) { '#access' => !$params['enabled'], '#links' => [ 'admin' => [ - 'title' => $this->t('Enable two-factor authentication via email'), + 'title' => $this->t('Enable multi-factor authentication via email'), 'url' => Url::fromRoute('tfa.validation.setup', [ 'user' => $params['account']->id(), 'method' => $params['plugin_id'], diff --git a/modules/tide_tfa/src/Plugin/TfaValidation/TideTfaEmailOtpValidation.php b/modules/tide_tfa/src/Plugin/TfaValidation/TideTfaEmailOtpValidation.php index 8d682c448..2a7016680 100644 --- a/modules/tide_tfa/src/Plugin/TfaValidation/TideTfaEmailOtpValidation.php +++ b/modules/tide_tfa/src/Plugin/TfaValidation/TideTfaEmailOtpValidation.php @@ -2,6 +2,9 @@ namespace Drupal\tide_tfa\Plugin\TfaValidation; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\HtmlCommand; +use Drupal\Core\Ajax\InvokeCommand; use Drupal\Core\Form\FormStateInterface; use Drupal\tfa_email_otp\Plugin\TfaValidation\TfaEmailOtpValidation; @@ -17,6 +20,73 @@ */ class TideTfaEmailOtpValidation extends TfaEmailOtpValidation { + /** + * {@inheritdoc} + */ + public function getForm(array $form, FormStateInterface $form_state) { + $code_sent = $this->hasActiveOtp(); + + // Send automatically on first land. + if (!$code_sent && empty($userInput)) { + $this->send(); + } + + // Hide the heading if the resend link was clicked. + $form['email_otp_entry_heading'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('Enter the 8-digit verification code that was sent to your registered email.'), + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Used in Ajax call back. + // To hide the emepty error box. + // This makes sure to not load the whole form. + $form['messages'] = [ + '#markup' => '', + '#prefix' => '
', + '#suffix' => '
', + ]; + + $form['code'] = [ + '#type' => 'textfield', + '#title' => $this->t('Verification code'), + '#required' => TRUE, + '#description' => $this->t('The verification code field is mandatory.'), + '#attributes' => ['autocomplete' => 'off'], + ]; + + $form['actions']['#type'] = 'actions'; + $form['actions']['#attributes']['style'] = 'display: block;'; + $form['actions']['login'] = [ + '#type' => 'submit', + '#button_type' => 'primary', + '#value' => $this->t('Verify'), + '#prefix' => '
', + '#suffix' => '
', + ]; + + // Convert the Resend button into a link-style. + $form['actions']['rsend_link'] = [ + '#type' => 'submit', + '#value' => $this->t('Send me a new verification code.'), + '#prefix' => '
' . $this->t('Didn’t receive an email or need a new code?'), + '#suffix' => '
', + '#attributes' => [ + 'style' => 'background:none; border:none; padding:0; color:#003CC5; text-decoration:underline; cursor:pointer; font-size: inherit; font-weight:400;', + ], + '#limit_validation_errors' => [['']], + '#ajax' => [ + 'callback' => [$this, 'updateButtonValue'], + 'event' => 'click', + 'wrapper' => 'send-button-wrapper', + ], + ]; + + return $form; + } + /** * {@inheritdoc} */ @@ -24,9 +94,17 @@ public function validateForm(array $form, FormStateInterface $form_state) { $values = $form_state->getValues(); // If user is asking for sending the code, // no need to validate the input. - // [SD-294]. - // Updating the op string to match with form alter change. - if (isset($values['op']) && $values['op']->getUntranslatedString() === 'Email me a verification code') { + if (isset($values['op']) && in_array($values['op']->getUntranslatedString(), ['Send me a new verification code.'], TRUE)) { + // Check flood control for email sending to prevent email bombing. + $flood_identifier = 'tfa_email_otp_send_' . $this->uid; + if (!$this->flood->isAllowed('tfa_email_otp.send', static::EMAIL_SEND_FLOOD_THRESHOLD, static::EMAIL_SEND_FLOOD_WINDOW, $flood_identifier)) { + $this->errorMessages['send'] = $this->t('Too many code requests. Please wait before requesting another code.'); + return FALSE; + } + + // Register the send attempt before sending. + $this->flood->register('tfa_email_otp.send', static::EMAIL_SEND_FLOOD_WINDOW, $flood_identifier); + // Send user the access code. $this->send(); @@ -40,19 +118,49 @@ public function validateForm(array $form, FormStateInterface $form_state) { if (!$this->validate($values['code'])) { if (!isset($this->errorMessages['code'])) { - $this->errorMessages['code'] = $this->t('Invalid authentication code. Please try again.'); - } - if ($this->alreadyAccepted) { - $form_state->clearErrors(); - $this->errorMessages['code'] = $this->t('Invalid code, it was recently used for a login. Please try a new code.'); + $this->errorMessages['code'] = $this->t('Enter a valid verification code.'); } return FALSE; } else { - // Store accepted code to prevent replay attacks. - $this->storeAcceptedCode($values['code']); return TRUE; } } + /** + * AJAX callback to update the form buttons and display status messages. + * + * This function is triggered when the "resend-link" submit is clicked. + * This updates the message area with any status messages. + * + * @param array &$form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response to send back to the browser, containing the updated + * form elements and any status messages. + */ + public function updateButtonValue(array &$form, FormStateInterface $form_state) { + $response = new AjaxResponse(); + + // Clear all pending messages from the Drupal system. + // This ensures they don't pop up later if the page is refreshed. + \Drupal::messenger()->deleteAll(); + + // Wipe the message area in the browser. + // By sending an empty string to the #tfa-email-message-area, + // Remove any existing HTML (the green or red boxes). + $response->addCommand(new HtmlCommand('#tfa-email-message-area', '')); + + $response->addCommand(new InvokeCommand('#tfa-email-heading-wrapper', 'addClass', ['hidden'])); + + // Update the buttons as before. + $response->addCommand(new HtmlCommand('#tfa-email-verify-button', $form['actions']['login'])); + $response->addCommand(new HtmlCommand('#tfa-email-send-button', $form['actions']['rsend_link'])); + + return $response; + } + } diff --git a/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php b/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php index fbf1d9760..2621da2f9 100644 --- a/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php +++ b/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php @@ -3,6 +3,7 @@ namespace Drupal\tide_tfa\Routing; use Drupal\Core\Routing\RouteSubscriberBase; +use Drupal\Core\Routing\RoutingEvents; use Symfony\Component\Routing\RouteCollection; /** @@ -25,6 +26,33 @@ protected function alterRoutes(RouteCollection $collection) { if ($route = $collection->get('user.reset.login')) { $route->setDefault('_controller', '\Drupal\tide_tfa\Controller\TideTfaUserController::doResetPassLogin'); } + // TFA overview page (User → Security → TFA). + if ($route = $collection->get('tfa.overview')) { + $route->setDefault('_title', 'Multi-factor authentication'); + $route->setDefault('_form', '\Drupal\tide_tfa\Form\TideTfaOverviewForm'); + } + // TFA setup page. + if ($route = $collection->get('tfa.validation.setup')) { + $route->setDefault('_title', 'Multi-factor authentication setup'); + } + // TFA Entry page. + if ($route = $collection->get('tfa.entry')) { + $route->setDefault('_title', 'Multi-factor authentication'); + } + // TFA disable page. + if ($route = $collection->get('tfa.disable')) { + $route->setDefault('_title', 'Disable multi-factor authentication'); + } + } + + /** + * Attempt to be the last subscriber to allow our routes to take priority. + */ + public static function getSubscribedEvents(): array { + $events = parent::getSubscribedEvents(); + // Use lower priority than tfa module. + $events[RoutingEvents::ALTER] = ['onAlterRoutes', (PHP_INT_MIN - 1)]; + return $events; } } diff --git a/modules/tide_tfa/src/TideTfaOperation.php b/modules/tide_tfa/src/TideTfaOperation.php index d9b329b6a..58c8e68c6 100644 --- a/modules/tide_tfa/src/TideTfaOperation.php +++ b/modules/tide_tfa/src/TideTfaOperation.php @@ -102,19 +102,19 @@ public static function setupTfaSettings() { 'tfa_email_otp' => [ 'code_validity_period' => '600', 'email_setting' => [ - 'subject' => 'Single Digtial Presence CMS two-factor authentication code', - 'body' => 'Hi [user:display-name],\r\n\r\nYour two-factor authentication code is: [code]\r\n\r\nThis code is valid for [length] minutes. \r\n\r\nThis code will expire when you have logged in.\r\n\r\nFrom the SDP team\r\n\r\nRead more about 2FA: https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507', + 'subject' => 'Single Digital Presence CMS multi-factor authentication code', + 'body' => "Hi [user:display-name],\r\n\r\nYour verification code is\r\n\r\n[code]\r\n\r\nThis code will expire in [length] minutes, and can't be reused after you have logged in.\r\n\r\nIf you did not initiate this request, please contact the Single Digital Presence support team immediately.\r\n\r\nKind regards,\r\nSingle Digital Presence team", ], ], ]; $mail_settings = [ 'tfa_enabled_configuration' => [ - 'subject' => 'Your Single Digtial Presence CMS account now has two-factor authentication', - 'body' => "[user:display-name],\r\n\r\nThanks for configuring two-factor authentication on your Single Digital Presence account!\r\n\r\nThis additional level of security will help to ensure that only you are able to log in to your account.\r\n\r\nIf you ever lose the device you configured, you should act quickly to delete its association with this account.\r\n\r\nFrom the SDP team\r\n\r\nRead more about 2FA: https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507", + 'subject' => 'Your Single Digtial Presence CMS account now has multi-factor authentication', + 'body' => "Hi [user:display-name],\r\n\r\nThanks for enabling multi-factor authentication on your Single Digital Presence CMS account.\r\n\r\nThis additional level of security will help to ensure that only you are able to log in to your account.\r\n\r\nRead more about multi-factor authentication https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507\r\n\r\nKind regards,\r\nSingle Digital Presence team", ], 'tfa_disabled_configuration' => [ - 'subject' => 'Your Single Digtial Presence CMS account now has two-factor authentication', - 'body' => "[user:display-name],\r\n\r\nThanks for configuring two-factor authentication on your Single Digital Presence account!\r\n\r\nThis additional level of security will help to ensure that only you are able to log in to your account.\r\n\r\nIf you ever lose the device you configured, you should act quickly to delete its association with this account.\r\n\r\nFrom the SDP team\r\n\r\nRead more about 2FA: https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507", + 'subject' => 'Your Single Digital Presence CMS account no longer has multi-factor authentication', + 'body' => "Hi [user:display-name],\r\n\r\nMulti-factor authentication has been disabled on your Single Digital Presence CMS user account.\r\n\r\nIf you did not take this action, please contact your site administrator immediately.\r\n\r\nRead more about multi-factor authentication https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507\r\n\r\nKind regards,\r\nSingle Digital Presence team", ], ]; @@ -146,4 +146,19 @@ public static function setupTfaRolePermissions() { } } + /** + * Setup view password. + */ + public static function setupViewPassword() { + $moduleHandler = \Drupal::service('module_handler'); + $moduleInstaller = \Drupal::service('module_installer'); + + if (!$moduleHandler->moduleExists('view_password')) { + $moduleInstaller->install(['view_password']); + } + + $config = \Drupal::configFactory()->getEditable('view_password.settings'); + $config->set('form_ids', (string) 'tfa_setup')->save(); + } + } diff --git a/modules/tide_tfa/tests/behat/features/tide_2fa.feature b/modules/tide_tfa/tests/behat/features/tide_2fa.feature index e6c59a924..6ae51e16c 100644 --- a/modules/tide_tfa/tests/behat/features/tide_2fa.feature +++ b/modules/tide_tfa/tests/behat/features/tide_2fa.feature @@ -1,4 +1,4 @@ -@tide +@tide @wip Feature: Force 2FA setup Ensure that all users setup 2FA. @@ -30,3 +30,39 @@ Feature: Force 2FA setup | role | | administrator | | site_admin | + + @api @javascript + Scenario Outline: Enable multi-factor authentication + Given I am logged in as an administrator + Then I go to "/admin/config/people/tfa" + And I click on the element "#edit-tfa-enabled" + Then I save screenshot + And I should see the text "Roles required to set up TFA" + And I should see the text "Allowed Validation plugins" + And I should see the text "TFA Email one-time password (EOTP)" + And the "edit-tfa-allowed-validation-plugins-tfa-email-otp" checkbox should be checked + And I should see the text "Validation Settings" + And I should see the text "TFA Email one-time password (EOTP)" + And the "#edit-validation-plugin-settings-tfa-email-otp-code-validity-period option[selected='selected']" element should contain "10" + Then the "validation_plugin_settings[tfa_email_otp][email_setting][subject]" field should contain "Single Digital Presence CMS multi-factor authentication code" + Then I press the "Save configuration" button + + @api @javascript + Scenario Outline: Non admin user multi-factor authentication flow + Given I am logged in as an administrator + When I go to "/" + And I click "Multi-factor authentication" + Then I should see the heading "Multi-factor authentication" in the "header" region + And I should see the text "Email verification code" + And I click "Enable multi-factor authentication via email" + Then I should see the heading "Multi-factor authentication setup" in the "header" region + Then I save screenshot + + @api @javascript + Scenario Outline: Disable multi-factor authentication + Given I am logged in as an administrator + Then I go to "/admin/config/people/tfa" + And I click on the element "#edit-tfa-enabled" + And I should not see the text "Roles required to set up TFA" + Then I save screenshot + Then I press the "Save configuration" button \ No newline at end of file diff --git a/modules/tide_tfa/tide_tfa.install b/modules/tide_tfa/tide_tfa.install index 982a8ace0..8d6c9a991 100644 --- a/modules/tide_tfa/tide_tfa.install +++ b/modules/tide_tfa/tide_tfa.install @@ -21,4 +21,46 @@ function tide_tfa_install() { // Setup TFA role permissions. $tideTfaOperation->setupTfaRolePermissions(); + + // Setup view password. + $tideTfaOperation->setupViewPassword(); +} + +/** + * Setup view password. + */ +function tide_tfa_update_10000() { + $tideTfaOperation = new TideTfaOperation(); + $tideTfaOperation->setupViewPassword(); +} + +/** + * Update TFA email settings for SDP. + */ +function tide_tfa_update_10001() { + $config = \Drupal::configFactory()->getEditable('tfa.settings'); + + $validation_settings = $config->get('validation_plugin_settings'); + + if (isset($validation_settings['tfa_email_otp'])) { + $validation_settings['tfa_email_otp']['email_setting'] = [ + 'subject' => 'Single Digital Presence CMS multi-factor authentication code', + 'body' => "Hi [user:display-name],\r\n\r\nYour verification code is\r\n\r\n[code]\r\n\r\nThis code will expire in [length] minutes, and can't be reused after you have logged in.\r\n\r\nIf you did not initiate this request, please contact the Single Digital Presence support team immediately.\r\n\r\nKind regards,\r\nSingle Digital Presence team", + ]; + $config->set('validation_plugin_settings', $validation_settings); + } + + $mail_settings = [ + 'tfa_enabled_configuration' => [ + 'subject' => 'Your Single Digtial Presence CMS account now has multi-factor authentication', + 'body' => "Hi [user:display-name],\r\n\r\nThanks for enabling multi-factor authentication on your Single Digital Presence CMS account.\r\n\r\nThis additional level of security will help to ensure that only you are able to log in to your account.\r\n\r\nRead more about multi-factor authentication https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507\r\n\r\nKind regards,\r\nSingle Digital Presence team", + ], + 'tfa_disabled_configuration' => [ + 'subject' => 'Your Single Digital Presence CMS account no longer has multi-factor authentication', + 'body' => "Hi [user:display-name],\r\n\r\nMulti-factor authentication has been disabled on your Single Digital Presence CMS user account.\r\n\r\nIf you did not take this action, please contact your site administrator immediately.\r\n\r\nRead more about multi-factor authentication https://digital-vic.atlassian.net/servicedesk/customer/article/2439479507\r\n\r\nKind regards,\r\nSingle Digital Presence team", + ], + ]; + + $config->set('mail', $mail_settings); + $config->save(); } diff --git a/modules/tide_tfa/tide_tfa.module b/modules/tide_tfa/tide_tfa.module index f9771b950..e1ef1551c 100644 --- a/modules/tide_tfa/tide_tfa.module +++ b/modules/tide_tfa/tide_tfa.module @@ -6,21 +6,134 @@ */ use Drupal\Core\Form\FormStateInterface; +use Drupal\user\UserInterface; /** * Implements hook_form_alter(). */ function tide_tfa_form_alter(&$form, FormStateInterface $form_state, $form_id) { + $current_user = \Drupal::currentUser(); // [SD-375] Bypass tfa during reset pass for all users. if ($form_id == 'tfa_settings_form') { if (isset($form['reset_pass_skip_enabled'])) { $form['reset_pass_skip_enabled']['#description'] = t('Allow TFA to be bypassed during password reset by the authenticated user.'); } } - if ($form_id == 'tfa_entry_form') { - // [SD-294] Change the label of the 'Send' button. - if (isset($form['actions']['send'])) { - $form['actions']['send']['#value'] = t('Email me a verification code'); + if ($form_id == 'tfa_setup') { + $route_user = \Drupal::routeMatch()->getParameter('user'); + // If this is NOT "admin altering another user's TFA", + // change only the default description. + if ( + $current_user->id() === $route_user->id() + && !$current_user->hasPermission('administer tfa for other users') + ) { + $form['current_pass']['#description'] = t( + 'The current password is mandatory.' + ); + } + } +} + +/** + * Implements hook_menu_local_tasks_alter(). + */ +function tide_tfa_menu_local_tasks_alter(&$data, $route_name, &$ref_root) { + // Check if the tfa.overview tab exists. + if (isset($data['tabs'][0]['tfa.overview'])) { + $data['tabs'][0]['tfa.overview']['#link']['title'] = t('Multi-factor authentication'); + } + + // Check if the tfa.settings tab exists. + if (isset($data['tabs'][0]['tfa.settings'])) { + $data['tabs'][0]['tfa.settings']['#link']['title'] = t('Multi-factor authentication settings'); + } +} + +/** + * Implements hook_preprocess_HOOK(). + */ +function tide_tfa_preprocess_status_messages(&$variables) { + if (!$variables && !isset($variables['message_list'])) { + return; + } + + // Custom messages to replace TFA messages. + if (isset($variables['message_list']['error'])) { + foreach ($variables['message_list']['error'] as $key => $message) { + $message_string = (string) $message; + + // Convert the error message into warning message. + if (strpos($message_string, 'You are required to') !== FALSE && + strpos($message_string, 'setup two-factor authentication') !== FALSE && + strpos($message_string, 'unable to login') !== FALSE) { + + if (!isset($variables['message_list']['warning'])) { + $variables['message_list']['warning'] = []; + } + + $variables['message_list']['warning'][] = t('You are required to set up multi-factor authentication. Select the link below to enable multi-factor authentication via email.'); + + // Remove the original message from the error list. + unset($variables['message_list']['error'][$key]); + } + // When wrong code entered, this error shows up. + if (strpos($message_string, 'Verification code') !== FALSE) { + $variables['message_list']['error'][$key] = t('We couldn’t verify your code. Enter a valid verification code or request a new one below.'); + } + } + + // Clean up: If the error list is now empty, remove the error key entirely. + if (empty($variables['message_list']['error'])) { + unset($variables['message_list']['error']); + } + } + if (isset($variables['message_list']['status'])) { + foreach ($variables['message_list']['status'] as $key => $message) { + $message_string = (string) $message; + if (strpos($message_string, 'The authentication code has been sent') !== FALSE) { + unset($variables['message_list']['status'][$key]); + continue; + } + if (strpos($message_string, 'TFA setup complete.') !== FALSE) { + $variables['message_list']['status'][$key] = t("Multi-factor authentication setup is complete. Go to the dashboard."); + } + if (strpos($message_string, 'TFA has been disabled.') !== FALSE) { + $variables['message_list']['status'][$key] = t("Multi-factor authentication has been disabled. Go to the dashboard."); + } + } + if (empty($variables['message_list']['status'])) { + unset($variables['message_list']['status']); + } + } +} + +/** + * Implements hook_user_login(). + */ +function tide_tfa_user_login(UserInterface $account) { + $request = \Drupal::service('request_stack')->getCurrentRequest(); + $uid = $account->id(); + + // Check which roles are forced to use tfa. + $tfa_config = \Drupal::config('tfa.settings'); + $required_roles = $tfa_config->get('required_roles') ?: []; + + // Check if the current user has any of the required roles. + $user_roles = $account->getRoles(); + $is_required = (bool) array_intersect($required_roles, $user_roles); + + // If they ARE required to have it, check if they've actually set it up. + if ($tfa_config->get('enabled') && $is_required) { + $user_data = \Drupal::service('user.data'); + $tfa_settings = $user_data->get('tfa', $uid, 'tfa_user_settings'); + // Check 'status' AND ensure the 'plugins' array is not empty. + $has_active_plugins = !empty($tfa_settings['data']['plugins']); + $is_enabled = !empty($tfa_settings['status']) && $tfa_settings['status'] == 1; + + // If user don't have active plugins, they haven't finished setup. + if (!$is_enabled || !$has_active_plugins) { + $request->query->set('destination', "/user/$uid/security/tfa"); + return; } } } diff --git a/tide_core.module b/tide_core.module index 5b3c92b6d..834311f77 100644 --- a/tide_core.module +++ b/tide_core.module @@ -899,3 +899,13 @@ function tide_core_field_widget_complete_form_alter(&$field_widget_complete_form $field_widget_complete_form['widget'][0]['state']['#default_value'] = 'draft'; } } + +/** + * Implements hook_menu_local_tasks_alter(). + */ +function tide_core_menu_local_tasks_alter(&$data, $route_name, &$ref_root) { + // Update "View" to "View profile". + if (isset($data['tabs'][0]['entity.user.canonical'])) { + $data['tabs'][0]['entity.user.canonical']['#link']['title'] = t('View profile'); + } +}