diff --git a/composer.json b/composer.json index 6f35dfada..0c3830885 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..23e48b6de 100644 --- a/modules/tide_dashboard/tide_dashboard.module +++ b/modules/tide_dashboard/tide_dashboard.module @@ -5,6 +5,12 @@ * Tide Dashboard. */ +use Drupal\Core\Breadcrumb\Breadcrumb; +use Drupal\Core\Link; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Url; +use Drupal\user\UserInterface; + /** * Implements hook_toolbar_alter(). * @@ -33,9 +39,62 @@ function _tide_dashboard_workbench_content_title_callback() { /** * Implements hook_user_login(). */ -function tide_dashboard_user_login() { +function tide_dashboard_user_login(UserInterface $account) { $request = \Drupal::service('request_stack')->getCurrentRequest(); - if (($request->query->has('destination')) === FALSE) { + $uid = $account->id(); + + $module_handler = \Drupal::service('module_handler'); + if ($module_handler->moduleExists('tfa')) { + // 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 ($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; + } + } + } + + // Fallback: If not required or already setup, go to workbench. + if (!$request->query->has('destination')) { $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/Routing/TideTfaRouteSubscriber.php b/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php index fbf1d9760..e6a1d14ed 100644 --- a/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php +++ b/modules/tide_tfa/src/Routing/TideTfaRouteSubscriber.php @@ -4,6 +4,7 @@ use Drupal\Core\Routing\RouteSubscriberBase; use Symfony\Component\Routing\RouteCollection; +use Drupal\Core\Routing\RoutingEvents; /** * Listens to the dynamic route events. @@ -25,6 +26,29 @@ 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 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..db380fd5e 100644 --- a/modules/tide_tfa/src/TideTfaOperation.php +++ b/modules/tide_tfa/src/TideTfaOperation.php @@ -146,4 +146,29 @@ public static function setupTfaRolePermissions() { } } + /** + * Setup view password. + */ + public static function setupViewPassword() { + // Enable view_password module if not already enabled. + $moduleHandler = \Drupal::service('module_handler'); + $moduleInstaller = \Drupal::service('module_installer'); + if (!$moduleHandler->moduleExists('view_password')) { + $moduleInstaller->install(['view_password']); + } + + // Set view_password configuration. + $config = \Drupal::configFactory()->getEditable('view_password.settings'); + $form_ids_string = $config->get('form_ids') ?? ''; + $form_ids = array_filter(array_map('trim', explode(',', $form_ids_string))); + + if (!in_array('tfa_setup', $form_ids, TRUE)) { + $form_ids[] = 'tfa_setup'; + // Convert back to a comma-separated string. + // view_password only accept string for the schema. + $new_value = implode(',', $form_ids); + $config->set('form_ids', $new_value)->save(); + } + } + } diff --git a/modules/tide_tfa/tide_tfa.install b/modules/tide_tfa/tide_tfa.install index 982a8ace0..9f3411a84 100644 --- a/modules/tide_tfa/tide_tfa.install +++ b/modules/tide_tfa/tide_tfa.install @@ -21,4 +21,15 @@ 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(); } diff --git a/modules/tide_tfa/tide_tfa.module b/modules/tide_tfa/tide_tfa.module index f9771b950..4c9bb0d12 100644 --- a/modules/tide_tfa/tide_tfa.module +++ b/modules/tide_tfa/tide_tfa.module @@ -11,6 +11,7 @@ use Drupal\Core\Form\FormStateInterface; * 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'])) { @@ -23,4 +24,84 @@ function tide_tfa_form_alter(&$form, FormStateInterface $form_state, $form_id) { $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.' + ); + } + } + if ($form_id == 'tfa_entry_form') { + if (isset($form['code'])) { + $form['code']['#title'] = t('Verification code'); + $form['code']['#description'] = t('The verification code field 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]); + } + } + + // 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) { + if (strpos((string) $message, 'TFA setup complete.') !== FALSE) { + $variables['message_list']['status'][$key] = t("Multi-factor authentication setup is complete. Go to the dashboard."); + } + if (strpos((string) $message, 'TFA has been disabled.') !== FALSE) { + $variables['message_list']['status'][$key] = t("Multi-factor authentication has been disabled. Go to the dashboard."); + } + } + } } 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'); + } +}