diff --git a/src/Form/EntryForm.php b/src/Form/EntryForm.php index 56a618e..de80080 100644 --- a/src/Form/EntryForm.php +++ b/src/Form/EntryForm.php @@ -2,15 +2,18 @@ namespace Drupal\tfa\Form; +use Drupal\Component\Utility\Crypt; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\Flood\FloodInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\tfa\TfaContext; use Drupal\Core\Url; use Drupal\tfa\TfaLoginPluginManager; use Drupal\tfa\TfaValidationPluginManager; use Drupal\user\UserDataInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -81,9 +84,25 @@ class EntryForm extends FormBase { */ protected $userData; + /** + * The TFA context service. + * + * @var \Drupal\tfa\TfaContext + */ + protected $tfaContext; + + /** + * A logger instance. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + /** * EntryForm constructor. * + * @todo - refactor this to use functionality provided by tfaContext. + * * @param \Drupal\tfa\TfaValidationPluginManager $tfa_validation_manager * Plugin manager for validation plugins. * @param \Drupal\tfa\TfaLoginPluginManager $tfa_login_manager @@ -94,14 +113,20 @@ class EntryForm extends FormBase { * The date service. * @param \Drupal\user\UserDataInterface $user_data * User data service. + * @param \Psr\Log\LoggerInterface $logger + * The logger service. + * @param \Drupal\tfa\TfaContext $tfa_context + * The TFA context service. */ - public function __construct(TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_login_manager, FloodInterface $flood, DateFormatterInterface $date_formatter, UserDataInterface $user_data) { + public function __construct(TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_login_manager, FloodInterface $flood, DateFormatterInterface $date_formatter, UserDataInterface $user_data, LoggerInterface $logger, TfaContext $tfa_context) { $this->tfaValidationManager = $tfa_validation_manager; $this->tfaLoginManager = $tfa_login_manager; $this->tfaSettings = $this->config('tfa.settings'); $this->flood = $flood; $this->dateFormatter = $date_formatter; $this->userData = $user_data; + $this->logger = $logger; + $this->tfaContext = $tfa_context; } /** @@ -113,12 +138,15 @@ class EntryForm extends FormBase { * @return static */ public static function create(ContainerInterface $container) { + // @todo refactor - omit tfa services already in tfa.context service. return new static( $container->get('plugin.manager.tfa.validation'), $container->get('plugin.manager.tfa.login'), $container->get('flood'), $container->get('date.formatter'), - $container->get('user.data') + $container->get('user.data'), + $container->get('logger.factory')->get('user'), + $container->get('tfa.context') ); } @@ -272,13 +300,42 @@ class EntryForm extends FormBase { } } - user_login_finalize($user); + // Log the user in. + $this->tfaContext->setUser($user); + $this->tfaContext->doUserLogin(); // @todo Should finalize() be after user_login_finalize or before?! // @todo This could be improved with EventDispatcher. $this->finalize(); $this->flood->clear('tfa.failed_validation', $this->floodIdentifier); - $form_state->setRedirect(''); + + // Retrieve temporary tfa data, primarily the original_route. + $tfa_session_data = $this->tfaContext->getTempData(); + + // If user has reset password, redirect to edit form with token. + if (isset($tfa_session_data['original_route']) && 'user.reset.login' === $tfa_session_data['original_route']) { + // Delete the temporary data. Otherwise it persists for a week. + $this->tfaContext->setTempData(['original_route' => NULL]); + + // Following code largely copied from UserController::resetPassLogin(). + $this->logger->notice('User %name used one-time login link and authorized with TFA at time %timestamp.', ['%name' => $user->getDisplayName(), '%timestamp' => \Drupal::time()->getRequestTime()]); + $this->messenger()->addStatus($this->t('You have just used your one-time login link. It is no longer necessary to use this link to log in. Please change your password.')); + // Let the user's password be changed without the current password check. + $token = Crypt::randomBytesBase64(55); + $_SESSION['pass_reset_' . $user->id()] = $token; + $form_state->setRedirect( + 'entity.user.edit_form', + ['user' => $user->id()], + [ + 'query' => ['pass-reset-token' => $token], + 'absolute' => TRUE, + ] + ); + } + else { + // @todo - Consider whether to eliminate this redirection. + $form_state->setRedirect(''); + } } /** diff --git a/src/Form/TfaLoginForm.php b/src/Form/TfaLoginForm.php index 66efcd6..76b9c6c 100644 --- a/src/Form/TfaLoginForm.php +++ b/src/Form/TfaLoginForm.php @@ -91,13 +91,16 @@ class TfaLoginForm extends UserLoginForm { * User data service. * @param \Drupal\Core\Routing\RedirectDestinationInterface $destination * Redirect destination. + * @param \Drupal\tfa\TfaContext $tfa_context + * Tfa context service. */ - public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, UserAuthInterface $user_auth, RendererInterface $renderer, TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager, UserDataInterface $user_data, RedirectDestinationInterface $destination) { + public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, UserAuthInterface $user_auth, RendererInterface $renderer, TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager, UserDataInterface $user_data, RedirectDestinationInterface $destination, TfaContext $tfa_context) { parent::__construct($flood, $user_storage, $user_auth, $renderer); $this->tfaValidationManager = $tfa_validation_manager; $this->tfaLoginManager = $tfa_plugin_manager; $this->userData = $user_data; $this->destination = $destination; + $this->tfaContext = $tfa_context; } /** @@ -112,7 +115,8 @@ class TfaLoginForm extends UserLoginForm { $container->get('plugin.manager.tfa.validation'), $container->get('plugin.manager.tfa.login'), $container->get('user.data'), - $container->get('redirect.destination') + $container->get('redirect.destination'), + $container->get('tfa.context') ); } @@ -143,14 +147,6 @@ class TfaLoginForm extends UserLoginForm { // Similar to tfa_user_login() but not required to force user logout. /** @var \Drupal\user\Entity\User $user */ $user = $this->userStorage->load($uid); - $this->tfaContext = new TfaContext( - $this->tfaValidationManager, - $this->tfaLoginManager, - $this->configFactory(), - $user, - $this->userData, - $this->getRequest() - ); /* Uncomment when things go wrong and you get logged out. user_login_finalize($user); @@ -158,6 +154,9 @@ class TfaLoginForm extends UserLoginForm { return; */ + // Update tfaContext with this user and current request. + $this->tfaContext->setUser($user); + // Stop processing if Tfa is not enabled. if (!$this->tfaContext->isModuleSetup() || !$this->tfaContext->isTfaRequired()) { parent::submitForm($form, $form_state); diff --git a/src/TfaContext.php b/src/TfaContext.php index 2636bb3..d4c39b5 100644 --- a/src/TfaContext.php +++ b/src/TfaContext.php @@ -3,9 +3,12 @@ namespace Drupal\tfa; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Messenger\MessengerTrait; +use Drupal\Core\Url; +use Drupal\user\TempStoreException; use Drupal\user\UserDataInterface; use Drupal\user\UserInterface; -use Symfony\Component\HttpFoundation\Request; /** * Provide context for the current login attempt. @@ -19,6 +22,10 @@ class TfaContext implements TfaContextInterface { use TfaDataTrait; // Provides the getLoginHash() method. use TfaLoginTrait; + // Provides access to the t function. + use StringTranslationTrait; + // Provides access to messenger service. + use MessengerTrait; /** * Validation plugin manager. @@ -69,13 +76,6 @@ class TfaContext implements TfaContextInterface { */ protected $userData; - /** - * Current request object. - * - * @var \Symfony\Component\HttpFoundation\Request - */ - protected $request; - /** * Array of login plugins for a given user. * @@ -93,19 +93,25 @@ class TfaContext implements TfaContextInterface { /** * {@inheritdoc} */ - public function __construct(TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager, ConfigFactoryInterface $config_factory, UserInterface $user, UserDataInterface $user_data, Request $request) { + public function __construct(TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager, ConfigFactoryInterface $config_factory, UserDataInterface $user_data) { $this->tfaValidationManager = $tfa_validation_manager; $this->tfaLoginManager = $tfa_plugin_manager; $this->tfaSettings = $config_factory->get('tfa.settings'); - $this->user = $user; $this->userData = $user_data; - $this->request = $request; - - $this->tfaLoginPlugins = $this->tfaLoginManager->getPlugins(['uid' => $user->id()]); - // If possible, set up an instance of tfaValidationPlugin and the user's - // list of plugins. $this->validationPluginName = $this->tfaSettings->get('default_validation_plugin'); - if (!empty($this->validationPluginName)) { + } + + /** + * Set the user object and plugins. + * + * If possible, set up an instance of tfaValidationPlugin and the user's list of plugins. + * + * @param \Drupal\user\UserInterface $user + * The user account object. + */ + public function setUser(UserInterface $user) { + $this->user = $user; + if (!empty($this->validationPluginName) && $this->user->id()) { $this->tfaValidationPlugin = $this->tfaValidationManager ->createInstance($this->validationPluginName, ['uid' => $user->id()]); $this->userLoginPlugins = $this->tfaLoginManager @@ -177,6 +183,31 @@ class TfaContext implements TfaContextInterface { $this->tfaSaveTfaData($this->getUser()->id(), $this->userData, $user_tfa_data); } + /** + * Message to user about skipping TFA setup. + * + * @param $remaining + * Number of skips remaining prior to current login. + * If $remaining <= 0, tells user to contact administrator. + */ + public function skipMessage($remaining) { + if ($remaining > 0) { + $tfa_setup_link = Url::fromRoute('tfa.overview', [ + 'user' => $this->getUser()->id(), + ])->toString(); + $this->messenger()->addError($this->t('You are required to setup two-factor + authentication here. You have @remaining attempt(s) + left. After this you will be unable to login.', [ + '@remaining' => $remaining - 1, + '@link' => $tfa_setup_link, + ])); + } + // @todo - This is not working to set messages for an anonymous user. +// else { +// $this->messenger()->addError($this->t('You cannot login because your account does not have a Two-factor authentication method configured properly. Please contact the site administrator.')); +// } + } + /** * {@inheritdoc} */ @@ -191,13 +222,93 @@ class TfaContext implements TfaContextInterface { return FALSE; } + /** - * {@inheritdoc} - * - * @todo Set a hash mark to indicate TFA authorization has passed. + * Set a hash mark to indicate TFA authorization has passed + * and run user_login_finalize(). */ public function doUserLogin() { + $this->setCurrentAuthHash(); user_login_finalize($this->getUser()); } + /** + * Updates the user's TFA data to indicate user has passed TFA. + */ + public function setCurrentAuthHash() { + $auth_hash = $this->getAuthHash($this->getUser()); + $this->tfaSaveTfaData($this->getUser()->id(), $this->userData, ['auth_hash' => $auth_hash]); + } + + /** + * Has user already passed TFA for the current login? + * + * @return bool + */ + public function isAuthorized() { + $hash = $this->getAuthHash($this->getUser()); + $tfa_data = $this->tfaGetTfaData($this->getUser()->id(), $this->userData); + return (isset($tfa_data['auth_hash']) && $hash === $tfa_data['auth_hash']); + } + + /** + * Stores tfa data in user-specific PrivateTempStore. + * + * This performs a shallow array_replace() on the existing user data, if any. + * Because this uses PrivateTempStore, this MUST be used while the relevant + * user is logged in. Data stored for anonymous visitors is not retrievable + * as of Drupal 8.4. + * + * @param array $user_data + * + * @return bool + * Returns FALSE if unable to save data. + */ + public function setTempData($user_data) { + $storage = $this->getTempStore(); + $tfa_session_vars = $storage->get('tfa_data'); + $tfa_session_vars = empty($tfa_session_vars) ? [] : $tfa_session_vars; + if (!is_array($user_data)) { + $user_data = [$user_data]; + } + $tfa_session_vars = array_replace($tfa_session_vars, $user_data); + try { + $storage->set('tfa_data', $tfa_session_vars); + } + catch (TempStoreException $e) { + return FALSE; + } + return TRUE; + } + + /** + * Retrieves tfa data from user-specific PrivateTempStore. + * + * This must be used while the relevant user is logged in. + * + * @return array + */ + public function getTempData() { + $storage = $this->getTempStore(); + $tfa_session_vars = $storage->get('tfa_data'); + return empty($tfa_session_vars) ? [] : $tfa_session_vars; + } + + /** + * Get temporary private storage of tfa data for the current logged in user. + * + * Note: Neither the storage factory nor the storage can be injected because + * the current user is determined at time of instantiation, and we need + * to be able to use this service class before and after login. + * + * @todo - Update when it becomes possible to shorten the expiration period. + * @see https://www.drupal.org/project/drupal/issues/2928639 + * + * @return \Drupal\Core\TempStore\PrivateTempStore + */ + protected function getTempStore() { + return \Drupal::service('tempstore.private')->get('tfa'); + } + + } diff --git a/src/TfaContextInterface.php b/src/TfaContextInterface.php index 8322ccf..3a251e5 100644 --- a/src/TfaContextInterface.php +++ b/src/TfaContextInterface.php @@ -5,7 +5,6 @@ namespace Drupal\tfa; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\user\UserDataInterface; use Drupal\user\UserInterface; -use Symfony\Component\HttpFoundation\Request; /** * Provide context for the current login attempt. @@ -21,14 +20,20 @@ interface TfaContextInterface { * The plugin manager for TFA login plugins. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The configuration service. - * @param \Drupal\user\UserInterface $user - * The user currently attempting to log in. * @param \Drupal\user\UserDataInterface $user_data * The user data service. - * @param \Symfony\Component\HttpFoundation\Request $request - * The current request. */ - public function __construct(TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager, ConfigFactoryInterface $config_factory, UserInterface $user, UserDataInterface $user_data, Request $request); + public function __construct(TfaValidationPluginManager $tfa_validation_manager, TfaLoginPluginManager $tfa_plugin_manager, ConfigFactoryInterface $config_factory, UserDataInterface $user_data); + + + /** + * Set the target user for the context. + * + * @param \Drupal\user\UserInterface $user + * + * @return mixed + */ + public function setUser(UserInterface $user); /** * Get the user object. diff --git a/src/TfaDataTrait.php b/src/TfaDataTrait.php index 48515f2..d08ade6 100644 --- a/src/TfaDataTrait.php +++ b/src/TfaDataTrait.php @@ -45,9 +45,9 @@ trait TfaDataTrait { * @return mixed|array * The stored value is returned, or NULL if no value was found. */ - protected function getUserData($module, $key, $uid, UserDataInterface $user_data) { - return $user_data->get($module, $uid, $key); - } + public function getUserData($module, $key, $uid, UserDataInterface $user_data) { + return $user_data->get($module, $uid, $key); + } /** * Deletes data stored for the current validated user account. @@ -89,6 +89,13 @@ trait TfaDataTrait { $validation_skipped = isset($data['validation_skipped']) ? $data['validation_skipped'] : 0; } + if (isset($existing['auth_hash']) && !isset($data['auth_hash'])) { + $auth_hash = $existing['auth_hash']; + } + else { + $auth_hash = isset($data['auth_hash']) ? $data['auth_hash'] : NULL; + } + if (!empty($existing['data'])) { $tfa_data = $existing['data']; } @@ -117,6 +124,7 @@ trait TfaDataTrait { 'status' => $status, 'data' => $tfa_data, 'validation_skipped' => $validation_skipped, + 'auth_hash' => $auth_hash, ], ]; @@ -143,6 +151,7 @@ trait TfaDataTrait { 'saved' => $result['saved'], 'data' => $result['data'], 'validation_skipped' => $result['validation_skipped'], + 'auth_hash' => isset($result['auth_hash']) ? $result['auth_hash'] : NULL, ]; } return []; diff --git a/src/TfaLoginTrait.php b/src/TfaLoginTrait.php index 34265ff..7c5f3b7 100644 --- a/src/TfaLoginTrait.php +++ b/src/TfaLoginTrait.php @@ -19,7 +19,7 @@ trait TfaLoginTrait { * @return string * The hash value representing the user. */ - protected function getLoginHash(UserInterface $account) { + public function getLoginHash(UserInterface $account) { // Using account login will mean this hash will become invalid once user has // authenticated via TFA. $data = implode(':', [ @@ -30,4 +30,22 @@ trait TfaLoginTrait { return Crypt::hashBase64($data); } + /** + * Generate a hash that can uniquely identify a user login process. + * + * @param \Drupal\user\UserInterface $account + * The user account for which a hash is required. + * + * @return string + * The hash value representing the user and current request. + */ + public function getAuthHash(UserInterface $account) { + $data = implode(':', [ + $account->getAccountName(), + $account->getPassword(), + \Drupal::time()->getRequestTime() + ]); + return Crypt::hashBase64($data); + } + } diff --git a/tfa.module b/tfa.module index 26237e4..df71f2f 100644 --- a/tfa.module +++ b/tfa.module @@ -10,6 +10,9 @@ use Drupal\Core\Session\AccountInterface; use Drupal\block\Entity\Block; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Url; +use Drupal\tfa\TfaContext; +use Drupal\user\UserInterface; /** * Implements hook_help(). @@ -68,3 +71,73 @@ function tfa_mail($key, &$message, $params) { $language_manager->setConfigOverrideLanguage($original_language); } + +/** + * Implements hook_user_login(). + * + * Checks TFA status for users who may have logged in through routes + * other than the login form, including password reset. + */ +function tfa_user_login(UserInterface $account) { + /** @var TfaContext $tfa */ + $tfa = \Drupal::service('tfa.context'); + + $tfa->setUser($account); + + // Stop processing if Tfa is not enabled or not required. + if (!$tfa->isModuleSetup() || !$tfa->isTfaRequired()) { + return; + } + + // Stop processing if user has been through Tfa authorization. + if ($tfa->isAuthorized()) { + return; + } + + // Stop processing if plugin otherwise allows login (trusted browser, usually) + if ($tfa->isReady() && $tfa->pluginAllowsLogin()) { + return; + } + + // If TFA is not ready, either permit skip or logout. + if (!$tfa->isReady()) { + // Get remaining skips (which may be zero). + $remaining_skips = $tfa->remainingSkips(); + // If there are remaining skips, use one and return. + if ($remaining_skips) { + // Set appropriate message to user. + $tfa->skipMessage($remaining_skips); + $tfa->hasSkipped(); + return; + } + // Otherwise just log the user out. + else { + user_logout(); + // @todo Set appropriate message to logged-out user. This does not work right now. +// $tfa->skipMessage($remaining_skips); + return; + } + } + + // TFA is ready, but user has logged in without TFA authorization. + + // Store the user's original route so we can recover it later if needed. + // See e.g. EntryForm::submitForm(). + $tfa->setTempData(['original_route' => \Drupal::routeMatch()->getRouteName()]); + + // Force logout. + user_logout(); + + // Redirect user to the tfa entry form. + $login_hash = $tfa->getLoginHash($account); + $destination = Url::fromRoute('tfa.entry', + [ + 'user' => $account->id(), + 'hash' => $login_hash, + ] + )->toString(); + + $request_stack = \Drupal::requestStack(); + $request_stack->getCurrentRequest()->query->set('destination', $destination); + +} diff --git a/tfa.services.yml b/tfa.services.yml index 44ae361..ee7a191 100644 --- a/tfa.services.yml +++ b/tfa.services.yml @@ -17,4 +17,11 @@ services: tfa.route_subscriber: class: Drupal\tfa\Routing\TfaRouteSubscriber tags: - - { name: event_subscriber } \ No newline at end of file + - { name: event_subscriber } + tfa.context: + class: Drupal\tfa\TfaContext + arguments: + - '@plugin.manager.tfa.validation' + - '@plugin.manager.tfa.login' + - '@config.factory' + - '@user.data'