Files
2026-04-28 15:13:50 +02:00

1551 lines
56 KiB
PHP

<?php
/**
* This package is based on the WordPress feature plugin https://wordpress.org/plugins/two-factor/
*
* Class for creating two-factor authorization.
*
* @since 7.0.6
* @noinspection OffsetOperationsInspection
* @noinspection UnknownInspectionInspection
* @package RSSSL\Pro\Security\WordPress\Two_Fa
*/
namespace RSSSL\Security\WordPress\Two_Fa;
use Exception;
use RSSSL\Security\WordPress\Two_Fa\Repositories\Rsssl_Two_Fa_User_Repository;
use RSSSL\Security\WordPress\Two_Fa\Services\Rsssl_Two_Fa_Reminder_Service;
use RSSSL\Security\WordPress\Two_Fa\Services\Rsssl_Two_Factor_Reset_Service;
use RSSSL\Security\WordPress\Two_Fa\Providers\Rsssl_Provider_Loader;
use RSSSL\Security\WordPress\Two_Fa\Providers\Rsssl_Two_Factor_Provider;
use RSSSL\Security\WordPress\Two_Fa\Providers\Rsssl_Two_Factor_Provider_Interface;
use RSSSL\Security\WordPress\Two_Fa\Traits\Rsssl_Email_Trait;
use WP_Error;
use WP_Session_Tokens;
use WP_User;
/**
* Class Rsssl_Two_Factor.
*
* The Rsssl_Two_Factor class provides methods for managing two-factor authentication for users.
*
* @package Rsssl
*/
class Rsssl_Two_Factor
{
use Rsssl_Email_Trait;
/**
* The user meta key to store the last failed timestamp.
*
* @type string
*/
public const RSSSL_USER_RATE_LIMIT_KEY = '_rsssl_two_factor_last_login_failure';
/**
* The user meta key to store the number of failed login attempts.
*
* @var string
*/
public const RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY = '_rsssl_two_factor_failed_login_attempts';
/**
* The user meta key to store whether the password was reset.
*
* @var string
*/
public const RSSSL_USER_PASSWORD_WAS_RESET_KEY = '_rsssl_two_factor_password_was_reset';
/**
* URL query parameter used for our custom actions.
*
* @var string
*/
public const RSSSL_USER_SETTINGS_ACTION_QUERY_VAR = 'rsssl_two_factor_action';
/**
* Nonce key for user settings.
*
* @var string
*/
public const RSSSL_USER_SETTINGS_ACTION_NONCE_QUERY_ARG = '_rsssl_two_factor_action_nonce';
public const RSSSL_USER_META_ONBOARDING_COMPLETE = 'rsssl_two_fa_onboarding_complete';
/**
* Namespace for plugin rest api endpoints.
*
* @var string
*/
public const REST_NAMESPACE = 'really-simple-security/v1/two-fa/v2';
/**
* Keep track of all the password-based authentication sessions that
* need to invalidated before the second factor authentication.
*
* @var array
*/
private static array $password_auth_tokens = array();
/**
* Set up filters and actions.
*
* @param object $compat A compatibility layer for plugins.
*
* @since 0.1-dev
*/
public static function add_hooks(object $compat): void
{
if ( ( defined( 'RSSSL_DISABLE_2FA' ) && RSSSL_DISABLE_2FA )
|| ( defined( 'RSSSL_SAFE_MODE' ) && RSSSL_SAFE_MODE )
) {
if ( rsssl_admin_logged_in() ) {
( new Rsssl_Two_Factor_Admin() );
}
( new Rsssl_Two_Factor_On_Board_Api() );
if ( is_user_logged_in() ) {
(Rsssl_Two_Factor_Profile_Settings::get_instance());
}
return;
}
/**
* Runs the fix for the reset error in 9.1.1
*/
if (filter_var(get_option('rsssl_reset_fix', false), FILTER_VALIDATE_BOOLEAN)) {
$repository = new Rsssl_Two_Fa_User_Repository();
(new Rsssl_Two_Factor_Reset_Service($repository))->resetFix();
}
// add_action( 'login_enqueue_scripts', array( __CLASS__, 'twofa_scripts' ) );
add_action('init', array(Rsssl_Provider_Loader::class, 'get_providers'));
add_action('wp_login', array(__CLASS__, 'rsssl_wp_login'), 10, 2);
add_action('wp_login_errors', array(__CLASS__, 'show_expired_onboarding_error'));
add_filter('wp_login_errors', array(__CLASS__, 'rsssl_maybe_show_reset_password_notice'));
add_action('after_password_reset', array(__CLASS__, 'rsssl_clear_password_reset_notice'));
add_action('login_form_validate_2fa', array(__CLASS__, 'rsssl_login_form_validate_2fa'));
// Loading the styles.
add_action('login_enqueue_scripts', array(__CLASS__, 'enqueue_onboarding_styles'));
if (rsssl_admin_logged_in()) {
(new Rsssl_Two_Factor_Admin());
}
( new Rsssl_Two_Factor_On_Board_Api() );
if(is_user_logged_in()) {
Rsssl_Two_Factor_Profile_Settings::get_instance();
}
//add_action('rsssl_upgrade', array(__CLASS__, 'upgrade'));
self::upgrade();
// Add the localized script for WP_REST.
/**
* Keep track of all the user sessions for which we need to invalidate the
* authentication cookies set during the initial password check.
*
* Is there a better way of doing this?
*/
add_action('set_auth_cookie', array(__CLASS__, 'rsssl_collect_auth_cookie_tokens'));
add_action('set_logged_in_cookie', array(__CLASS__, 'rsssl_collect_auth_cookie_tokens'));
if ( isset( $_GET['rsssl_one_time_login'], $_GET['_wpnonce'] ) ) {
$nonce = sanitize_text_field(wp_unslash($_GET['_wpnonce']));
if (wp_verify_nonce($nonce)) {
add_action('init', array(__CLASS__, 'maybe_skip_auth'));
}
self::maybe_skip_auth();
}
add_action('init', array(__CLASS__, 'rsssl_collect_auth_cookie_tokens'));
// Run only after the core wp_authenticate_username_password() check.
add_filter('authenticate', array(__CLASS__, 'rsssl_filter_authenticate'));
// Run as late as possible to prevent other plugins from unintentionally bypassing.
add_filter('authenticate', array(__CLASS__, 'rsssl_filter_authenticate_block_cookies'), PHP_INT_MAX);
add_action('admin_init', array(__CLASS__, 'rsssl_enable_dummy_method_for_debug'));
add_filter('rsssl_two_factor_providers', array(__CLASS__, 'enable_dummy_method_for_debug'));
add_action( 'rsssl_daily_cron', array( __CLASS__, 'maybe_send_reminder_email' ) );
add_action( 'user_register', [__CLASS__, 'set_2fa_activation_date'], 10, 1 );
$compat->init();
}
/**
* @return void
*
* Send a reminder e-mail if Two FA has not been configured within 3 days.
*/
public static function maybe_send_reminder_email():void {
$forcedRoles = rsssl_get_option('two_fa_forced_roles', []);
if(empty($forcedRoles)) {
return;
}
(new Rsssl_Two_Fa_Reminder_Service())->maybeSendReminderEmails($forcedRoles);
}
/**
* Simple Date setter for Two Factor Forced roles.
* @param $user_id
* @return void
*/
public static function set_2fa_activation_date($user_id): void {
// Get the user data; if not found, return early.
$user_data = get_userdata($user_id);
if (!$user_data) {
return;
}
$user_roles = $user_data->roles;
// Ensure forced roles is an array (empty if not set).
$forcedRoles = rsssl_get_option('two_fa_forced_roles') ?: [];
// If there is no intersection between forced roles and user's roles, do nothing.
if (!array_intersect($forcedRoles, $user_roles)) {
return;
}
// TODO: I really regret the meta_key name here. It should be rsssl_two_fa_activation_date. Need to fix this in the future.
update_user_meta($user_id, 'rsssl_two_fa_last_login', gmdate('Y-m-d H:i:s'));
}
/**
* Upgrade the two-factor login configuration.
*
* This method updates the configuration of two-factor login if necessary.
* It checks if the login protection is enabled, if the plugin has been upgraded,
* and if the enabled roles for email and TOTP need to be updated.
*
* @return void
*/
public static function upgrade(): void
{
if (rsssl_get_option('login_protection_enabled') && get_option('rsssl_two_fa_upgrade', false) === false) {
// The way roles configuration was has now been changed. This means the forced roles and enabled roles need to change.
$forced_roles = rsssl_get_option('two_fa_forced_roles');
$optional_roles = rsssl_get_option('two_fa_optional_roles');
$forced_roles = ($forced_roles !== false) ? $forced_roles : [];
$optional_roles = ($optional_roles !== false) ? $optional_roles : [];
// Merge the forced and optional roles into one array with unique values.
$enabled_roles = array_unique(array_merge($forced_roles, $optional_roles));
if (empty($optional_roles)) {
// no roles were set so ending the upgrade.
return;
}
if (function_exists('rsssl_update_option')) {
// Update the enabled roles for only email.
rsssl_update_option('two_fa_enabled_roles_email', $enabled_roles);
rsssl_update_option('two_fa_enabled_roles_totp', ['administrator']);
// update the forced roles.
rsssl_update_option('two_fa_forced_roles', $forced_roles);
}
// fetching the users that have active 2FA enabled.
$users = get_users(array('meta_key' => 'rsssl_two_fa_status_email', 'meta_value' => 'active'));
foreach ($users as $user) {
Rsssl_Two_Fa_Status::set_active_provider($user->ID, 'email');
}
update_option('rsssl_two_fa_upgrade', rsssl_version, false);
}
}
/**
* Enqueue the two-factor authentication scripts.
*
* @return void
*
* Allow 2FA bypass if status is open.
*/
public static function maybe_skip_auth(): void
{
if (isset($_GET['rsssl_one_time_login'], $_GET['token'], $_GET['_wpnonce'])) {
// Unslash and sanitize.
$rsssl_one_time_login = sanitize_text_field(wp_unslash($_GET['rsssl_one_time_login']));
$user_id = (int)Rsssl_Two_Factor_Settings::deobfuscate_user_id($rsssl_one_time_login);
$user = get_user_by('id', $user_id);
// Verify the nonce.
$nonce = sanitize_text_field(wp_unslash($_GET['_wpnonce']));
if (!wp_verify_nonce($nonce, 'one_time_login_' . $user_id)) {
wp_safe_redirect(wp_login_url() . '?login_error=nonce_invalid');
exit;
}
// Retrieve the stored token from the transient.
$stored_token = get_transient('skip_two_fa_token_' . $user_id);
// Check if the token is valid and not expired.
$token = sanitize_text_field(wp_unslash($_GET['token']));
if ($user && $stored_token && hash_equals($stored_token, $token)) {
// Delete the transient to invalidate the token.
delete_transient('skip_two_fa_token_' . $user_id);
$status = get_user_meta($user->ID, 'rsssl_two_fa_status_email', true);
// Only allow skipping for users which have 2FA value open.
if (isset($_GET['rsssl_two_fa_disable']) && 'open' === $status) {
update_user_meta($user_id, 'rsssl_two_fa_status_email', 'disabled');
}
if ('open' === Rsssl_Two_Factor_Settings::get_user_status('email', $user_id)) {
update_user_meta($user_id, 'rsssl_two_fa_status_email', 'active');
update_user_meta($user_id, 'rsssl_two_fa_status_totp', 'disabled');
}
delete_user_meta( $user_id, '_rsssl_factor_email_token' );
delete_user_meta( $user_id, '_rsssl_two_factor_backup_codes' );
wp_set_auth_cookie($user_id);
wp_safe_redirect(admin_url());
exit;
}
// The token is invalid or expired.
// Redirect to the login page with an error message or handle it as needed.
wp_safe_redirect(wp_login_url() . '?login_error=token_invalid');
exit;
}
}
/**
* Enable the dummy method only during debugging.
*
* @param array $methods List of enabled methods.
*
* @return array
*/
public static function enable_dummy_method_for_debug(array $methods): array
{
if (!self::is_wp_debug()) {
unset($methods['Two_Factor_Dummy']);
}
return $methods;
}
/**
* Check if the debug mode is enabled.
*
* @return boolean
*/
protected static function is_wp_debug(): bool
{
return (defined('WP_DEBUG') && WP_DEBUG);
}
/**
* Check if a user action is valid.
*
* @param integer $user_id User ID.
* @param string $action User action ID.
*
* @return boolean
*/
public static function is_valid_user_action(int $user_id, string $action): bool
{
$request_nonce = isset($_REQUEST[self::RSSSL_USER_SETTINGS_ACTION_NONCE_QUERY_ARG]) ? sanitize_text_field(wp_unslash($_REQUEST[self::RSSSL_USER_SETTINGS_ACTION_NONCE_QUERY_ARG])) : '';
if (!$user_id || !$action || !$request_nonce) {
return false;
}
return wp_verify_nonce(
$request_nonce,
sprintf('%d-%s', $user_id, $action)
);
}
/**
* Get the ID of the user being edited.
*
* @return integer
*/
public static function current_user_being_edited(): int
{
// Try to resolve the user ID from the request first.
if (!empty($_REQUEST['rsssl_user_id']) && !empty($_REQUEST['rsssl-action-nonce'])) {
if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['rsssl-action-nonce'])), 'rsssl-user-action')) {
wp_die('Invalid nonce');
}
$user_id = (int)$_REQUEST['rsssl_user_id'];
if (current_user_can('edit_user', $user_id)) {
return $user_id;
}
}
return get_current_user_id();
}
/**
* Trigger our custom update action if a valid
* action request is detected and passes the nonce check.
*
* @return void
*/
public static function rsssl_enable_dummy_method_for_debug(): void
{
$nonce = isset($_POST['nonce_field']) ? sanitize_text_field(wp_unslash($_POST['nonce_field'])) : '';
// Verify the nonce.
if (!wp_verify_nonce($nonce, 'rsssl_user_action')) {
return;
}
$action = isset($_REQUEST[self::RSSSL_USER_SETTINGS_ACTION_QUERY_VAR]) ? sanitize_text_field(wp_unslash($_REQUEST[self::RSSSL_USER_SETTINGS_ACTION_QUERY_VAR])) : '';
$user_id = self::current_user_being_edited();
if (self::is_valid_user_action($user_id, $action)) {
/**
* This action is triggered when a valid Two Factor settings
* action is detected, and it passes the nonce validation.
*
* @param integer $user_id User ID.
* @param string $action Settings action.
*/
do_action('rsssl_two_factor_user_settings_action', $user_id, $action);
}
}
/**
* Keep track of all the authentication cookies that need to be
* invalidated before the second factor authentication.
*
* @param string $cookie Cookie string.
*
* @return void
*/
public static function rsssl_collect_auth_cookie_tokens(string $cookie): void
{
$parsed = wp_parse_auth_cookie($cookie);
if (!empty($parsed['token'])) {
self::$password_auth_tokens[] = $parsed['token'];
}
}
/**
* Get all Two-Factor Auth providers that are both enabled and configured for the specified|current user.
*
* @param WP_User $user Optional. User ID, or WP_User object of the user. Defaults to current user.
*
* @return array
*/
public static function get_available_providers_for_user(WP_User $user): array
{
$loader = Rsssl_Provider_Loader::get_loader();
return $loader::available_providers();
}
/**
* Gets the Two-Factor Auth provider for the specified|current user.
*
* @param WP_User $user Optional. User ID, or WP_User object of the user. Defaults to current user.
*
* @return string
* @since 0.1-dev
*/
public static function get_primary_provider_for_user(WP_User $user): string
{
$loader = Rsssl_Provider_Loader::get_loader();
$available_providers = $loader::get_configured_providers_for_user($user);
// If there's only one available provider, force that to be the primary.
if (empty($available_providers)) {
return '';
}
if (1 === count($available_providers)) {
$provider = key($available_providers);
} else {
$provider = Rsssl_Provider_Loader::get_user_enabled_providers($user);
// Check if already a provider is active.
// If the provider specified isn't enabled, just grab the first one that is based on the Weight.
$best_valued_provider = 'totp';
if (isset($available_providers[$best_valued_provider]) && $available_providers[$best_valued_provider]::is_enabled($user)) {
$provider = $best_valued_provider;
} else {
$provider = key($available_providers);
}
}
return get_class($available_providers[$provider]) ?? '';
}
/**
* Quick boolean check for whether a given user is using two-step.
* TODO: No longer needed?
*
* @param WP_User $user Optional. User ID, or WP_User object of the user. Defaults to current user.
*
* @return bool
* @since 0.1-dev
*/
public static function is_user_using_two_factor(WP_User $user): bool
{
$provider = self::get_primary_provider_for_user($user);
$enabled_providers_meta = Rsssl_Provider_Loader::get_user_enabled_providers($user);
// Initialize as empty arrays if they are empty.
$two_fa_forced_roles = rsssl_get_option('two_fa_forced_roles');
$two_fa_optional_roles = rsssl_get_option('two_fa_enabled_roles_email');
$two_fa_optional_roles_totp = rsssl_get_option('two_fa_enabled_roles_totp');
//ensure an array for all.
if (!is_array($two_fa_forced_roles)) {
$two_fa_forced_roles = [];
}
if (!is_array($two_fa_optional_roles)) {
$two_fa_optional_roles = [];
}
if (!is_array($two_fa_optional_roles_totp)) {
$two_fa_optional_roles_totp = [];
}
$two_fa_optional_roles = array_unique(array_merge($two_fa_optional_roles, $two_fa_optional_roles_totp));
foreach ($enabled_providers_meta as $enabled_provider) {
$status = $enabled_provider::get_status($user);
if ( ( 'disabled' === $status ) && is_object( $provider ) && get_class( $provider ) === $enabled_provider ) {
$provider = [];
}
if ('active' === $status ) {
return true;
}
if ('open' === $status) {
return true;
}
}
foreach ($user->roles as $role) {
// If not forced, and not optional, or disabled, or provider not enabled.
if (!in_array($role, $two_fa_forced_roles, true)
&& !in_array($role, $two_fa_optional_roles, true)
) {
// Skip 2FA.
return false;
}
}
return !empty($provider);
}
/**
* Show an expired onboarding error message.
*
* @param WP_Error $errors Error object to add the error to.
*
* @return WP_Error The updated error object.
*/
public static function show_expired_onboarding_error(WP_Error $errors): WP_Error
{
if ( isset( $_GET['nonce'], $_GET['errors'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'rsssl_expired' ) && $_GET['errors'] === 'expired' ) {
$errors->add('expired', __('Your 2FA grace period expired. Please contact your site administrator to regain access and to configure 2FA.', 'really-simple-ssl'));
}
return $errors;
}
/**
* Handle the browser-based login.
*
* @param string $user_login Username.
* @param WP_User $user WP_User object of the logged-in user.
*
* @throws Exception If the onboarding process fails.
* @since 0.1-dev
*/
public static function rsssl_wp_login(string $user_login, WP_User $user): void
{
switch (Rsssl_Two_Factor_Settings::get_login_action($user->ID)) {
case 'onboarding':
self::is_onboarding_complete($user);
exit;
case 'expired':
// Destroy the current session for the user.
self::destroy_current_session_for_user($user);
wp_clear_auth_cookie();
self::display_expired_onboarding_error();
exit;
case 'totp':
case 'email':
case 'passkey':
wp_clear_auth_cookie();
self::show_two_factor_login($user);
exit;
case 'login':
default:
break;
}
}
/**
* Destroy the known password-based authentication sessions for the current user.
*
* Is there a better way of finding the current session token without
* having access to the authentication cookies which are just being set
* on the first password-based authentication request.
*
* @param WP_User $user User object.
*
* @return void
*/
public static function destroy_current_session_for_user(WP_User $user): void
{
$session_manager = WP_Session_Tokens::get_instance($user->ID);
foreach (self::$password_auth_tokens as $auth_token) {
$session_manager->destroy($auth_token);
}
}
/**
* Prevent login through XML-RPC and REST API for users with at least one
* two-factor method enabled.
*
* @param WP_User|WP_Error $user Valid WP_User only if the previous filters
* have verified and confirmed the
* authentication credentials.
*
* @return WP_User|WP_Error
*/
public static function rsssl_filter_authenticate($user)
{
if ($user instanceof WP_User && self::is_api_request() && self::is_user_using_two_factor($user) && !self::is_user_api_login_enabled($user->ID)) {
return new WP_Error(
'invalid_application_credentials',
__('API login for user disabled.', 'really-simple-ssl')
);
}
return $user;
}
/**
* Prevent login cookies being set on login for Two Factor users.
*
* This makes it so that Core never sends the auth cookies. `login_form_validate_2fa()` will send them manually once the 2nd factor has been verified.
*
* @param WP_User|WP_Error $user Valid WP_User only if the previous filters
* have verified and confirmed the
* authentication credentials.
*
* @return WP_User|WP_Error
*/
public static function rsssl_filter_authenticate_block_cookies($user)
{
/*
* NOTE: The `login_init` action is checked for here to ensure we're within the regular login flow,
* rather than through an unsupported 3rd-party login process which this plugin doesn't support.
*/
if ($user instanceof WP_User && self::is_user_using_two_factor($user) && did_action('login_init')) {
add_filter('send_auth_cookies', '__return_false', PHP_INT_MAX);
}
return $user;
}
/**
* If the current user can log in via API requests such as XML-RPC and REST.
*
* @param integer $user_id User ID.
*
* @return boolean
*/
public static function is_user_api_login_enabled(int $user_id): bool
{
return (bool)apply_filters('rsssl_two_factor_user_api_login_enable', false, $user_id);
}
/**
* Is the current request an XML-RPC or REST request.
*
* @return boolean
*/
public static function is_api_request(): bool
{
if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) {
return true;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return true;
}
return false;
}
/**
* Display the login form.
*
* @param WP_User $user WP_User object of the logged-in user.
*
* @throws Exception If the login nonce creation fails.
* @since 0.1-dev
*/
public static function show_two_factor_login(WP_User $user): void
{
$redirect_to = isset($_REQUEST['redirect_to']) ? wp_validate_redirect(wp_unslash($_REQUEST['redirect_to']), admin_url()) : admin_url();
$provider = Rsssl_Two_Factor_Settings::get_login_action($user->ID);
$login_nonce = Rsssl_Two_Fa_Authentication::create_login_nonce($user->ID)['rsssl_key'];
self::login_html($user, $login_nonce ,$redirect_to);
}
/**
* Displays a message informing the user that their account has had failed login attempts.
*
* @param WP_User $user WP_User object of the logged-in user.
*/
public static function maybe_show_last_login_failure_notice(WP_User $user): void
{
$last_failed_two_factor_login = (int)get_user_meta($user->ID, self::RSSSL_USER_RATE_LIMIT_KEY, true);
$failed_login_count = (int)get_user_meta(
$user->ID,
self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY,
true
);
if ($last_failed_two_factor_login) {
echo '<div id="login_notice" class="message"><strong>';
// translators: %1$s is the number of failed login attempts, %2$s is the time since the last failed login.
printf(
esc_html(
_n(
'Warning: There has been %1$s failed login attempt on your account without providing a valid two-factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.',
'Warning: %1$s failed login attempts have been detected on your account without providing a valid two-factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.',
$failed_login_count,
'really-simple-ssl'
)
),
esc_html(number_format_i18n($failed_login_count)),
esc_html(human_time_diff($last_failed_two_factor_login, time()))
);
echo '</strong></div>';
}
}
/**
* Show the password reset notice if the user's password was reset.
*
* They were also sent an email notification in `send_password_reset_email()`, but email sent from a typical
* web server is not reliable enough to trust completely.
*
* @param WP_Error $errors The error object.
*
* @return WP_Error
*/
public static function rsssl_maybe_show_reset_password_notice(WP_Error $errors): WP_Error
{
if ('incorrect_password' !== $errors->get_error_code()) {
return $errors;
}
if (!isset($_POST['log'])) {
return $errors;
}
$user_name = sanitize_user(wp_unslash($_POST['log']));
$attempted_user = get_user_by('login', $user_name);
if ( $user_name && ! $attempted_user && strpos( $user_name, '@') !== false ) {
$attempted_user = get_user_by('email', $user_name);
}
if (!$attempted_user) {
return $errors;
}
$password_was_reset = get_user_meta($attempted_user->ID, self::RSSSL_USER_PASSWORD_WAS_RESET_KEY, true);
if (!$password_was_reset) {
return $errors;
}
$errors->remove('incorrect_password');
$errors->add(
'rsssl_two_factor_password_reset',
sprintf(
/* translators: %s: URL to reset password */
__(
'Your password was reset because of too many failed Two Factor attempts. You will need to <a href="%s">create a new password</a> to regain access. Please check your email for more information.',
'really-simple-ssl'
),
esc_url(add_query_arg('action', 'lostpassword', rsssl_wp_login_url()))
)
);
return $errors;
}
/**
* Clear the password reset notice after the user resets their password.
*
* @param WP_User $user WP_User object of the logged-in user.
*/
public static function rsssl_clear_password_reset_notice(WP_User $user): void
{
delete_user_meta($user->ID, self::RSSSL_USER_PASSWORD_WAS_RESET_KEY);
}
/**
* Generates the html form for the second step of the authentication process.
*
* @param WP_User $user WP_User object of the logged-in user.
* @param string $login_nonce A string nonce stored in usermeta.
* @param string $redirect_to The URL to which the user would like to be redirected.
* @param string $error_msg Optional. Login error message.
* @param string|object $provider An override to the provider.
*
* @throws Exception If the login nonce creation fails.
* @since 0.1-dev
*/
public static function login_html(
WP_User $user,
string $login_nonce,
string $redirect_to,
string $error_msg = '',
$provider = null
): void
{
if (empty($provider)) {
$provider = self::get_primary_provider_for_user($user);
} elseif (is_string($provider) && method_exists($provider, 'get_instance')) {
$provider = call_user_func(array($provider, 'get_instance'));
}
if (!$provider) {
return;
}
$provider_class = $provider::get_instance();
$available_providers = self::get_available_providers_for_user($user);
// $backup_providers = array_diff_key($available_providers, array($provider => null));
$interim_login = isset($_REQUEST['interim-login']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$rememberme = (int)self::rememberme();
if (!function_exists('login_header')) {
// We really should migrate login_header() out of `wp-login.php` so it can be called from an includes file.
include_once __DIR__ . '/function-login-header.php';
}
// Enqueue two-fa JavaScript assets
$uri = trailingslashit(rsssl_url) . 'assets/features/two-fa/assets.min.js';
$uri_file = trailingslashit(rsssl_path) . 'assets/features/two-fa/assets.min.js';
add_filter('wp_script_attributes', [self::class, 'handle_script_attributes'], 10, 2);
wp_enqueue_script('rsssl-frontend-settings', $uri, array(), filemtime($uri_file), true);
wp_localize_script('rsssl-frontend-settings', 'rsssl_validate', array(
'nonce' => wp_create_nonce('wp_rest'),
'root' => esc_url_raw(rest_url(self::REST_NAMESPACE)),
'login_nonce' => $login_nonce,
'redirect_to' => $redirect_to,
'user_id' => $user->ID,
'origin' => 'validation',
'translatables' => apply_filters('rsssl_two_factor_translatables', []),
));
// Load the login template.
rsssl_load_template(
'login.php',
compact(
'login_nonce',
'redirect_to',
'error_msg',
'provider',
// 'backup_providers',
'interim_login',
'rememberme',
'provider_class',
'user'
),
rsssl_path . 'assets/templates/two_fa/'
);
if (!function_exists('login_footer')) {
include_once __DIR__ . '/function-login-footer.php';
}
login_footer();
}
/**
* Generate the two-factor login form URL.
*
* @param array $params List of query argument pairs to add to the URL.
* @param string $scheme URL scheme context.
*
* @return string
*/
public static function login_url(array $params = array(), string $scheme = 'login'): string
{
$params = urlencode_deep($params);
return add_query_arg($params, site_url('wp-login.php', $scheme));
}
/**
* Determine the minimum wait between two factor attempts for a user.
*
* This implements an increasing backoff, requiring an attacker to wait longer
* each time to attempt to brute-force the login.
*
* @param WP_User $user The user being operated upon.
*
* @return int Time delay in seconds between login attempts.
*/
public static function get_user_time_delay(WP_User $user): int
{
/**
* Filter the minimum time duration between two factor attempts.
*
* @param int $rate_limit The number of seconds between two factor attempts.
*/
$rate_limit = apply_filters('rsssl_two_factor_rate_limit', 1);
$user_failed_logins = get_user_meta($user->ID, self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY, true);
if ($user_failed_logins) {
$rate_limit = (2 ** $user_failed_logins) * $rate_limit;
/**
* Filter the maximum time duration a user may be locked out from retrying two-factor authentications.
*
* @param int $max_rate_limit The maximum number of seconds a user might be locked out for. Default 15 minutes.
*/
$max_rate_limit = apply_filters('rsssl_two_factor_max_rate_limit', 15 * MINUTE_IN_SECONDS);
$rate_limit = min($max_rate_limit, $rate_limit);
}
/**
* Filters the per-user time duration between two-factor login attempts.
*
* @param int $rate_limit The number of seconds between two factor attempts.
* @param WP_User $user The user attempting to log in.
*/
return apply_filters('rsssl_two_factor_user_rate_limit', $rate_limit, $user);
}
/**
* Determine if a time delay between user two-factor login attempts should be triggered.
*
* @param WP_User $user The User.
*
* @return bool True if rate limit is okay, false if not.
* @since 0.8.0
*/
public static function is_user_rate_limited(WP_User $user): bool
{
$rate_limit = self::get_user_time_delay($user);
$last_failed = get_user_meta($user->ID, self::RSSSL_USER_RATE_LIMIT_KEY, true);
$rate_limited = false;
if ($last_failed && $last_failed + $rate_limit > time()) {
$rate_limited = true;
}
/**
* Filter whether this login attempt is rate limited or not.
*
* This allows for dedicated plugins to rate limit two-factor login attempts
* based on their own rules.
*
* @param bool $rate_limited Whether the user login is rate limited.
* @param WP_User $user The user attempting to log in.
*/
return apply_filters('rsssl_two_factor_is_user_rate_limited', $rate_limited, $user);
}
/**
* Validates the two-factor authentication code. for all providers.
*
* @return void
* @throws Exception
*/
public static function rsssl_login_form_validate_2fa(): void {
[$wp_auth_id, $nonce, $provider_key, $redirect_to] = self::get_request_data();
if (isset($_SERVER['REQUEST_METHOD']) && 'POST' === strtoupper((sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD']))))) {
$is_post_request = true;
} else {
$is_post_request = false;
}
if (!$wp_auth_id || !$nonce) {
return;
}
$user = get_userdata($wp_auth_id);
if (!$user) {
return;
}
// Verify the nonce
if (true !== Rsssl_Two_Fa_Authentication::verify_login_nonce($user->ID, $nonce)) {
wp_safe_redirect(home_url());
exit;
}
$loader = Rsssl_Provider_Loader::get_loader();
// Get the provider
$providers = $loader::get_enabled_providers_for_user($user);
if ($provider_key && isset($providers[$provider_key])) {
$provider_class = get_class($providers[$provider_key]);
} else {
wp_die(esc_html__('Authentication provider not specified or invalid.', 'really-simple-ssl'), 403);
}
/** @var Rsssl_Two_Factor_Provider $provider_instance */
$provider_instance = $provider_class::get_instance();
// Check for corrupted/empty TOTP key before attempting authentication
self::validate_totp_key_exists( $user, $provider_key );
// Allow the provider to re-send codes, etc.
if ( ( 'email' === $provider_key ) && true === $provider_instance->pre_process_authentication( $user ) ) {
// Always generate a new nonce.
$new_nonce = self::generate_login_nonce_for_user($user->ID);
self::login_html($user, $new_nonce, $redirect_to, '', $provider_class);
exit;
}
// If the form hasn't been submitted, just display the auth form.
if (!$is_post_request) {
self::handle_not_post_request($user, $provider_class);
exit;
}
if (self::is_user_rate_limited($user)) {
$time_delay = self::get_user_time_delay($user);
$last_failed = get_user_meta($user->ID, self::RSSSL_USER_RATE_LIMIT_KEY, true);
$error = new WP_Error(
'rsssl_two_factor_too_fast',
sprintf(
/* translators: %s: time delay between login attempts */
__(
'Too many invalid verification codes, you can try again in %s. This limit protects your account against automated attacks.',
'really-simple-ssl'
),
human_time_diff($last_failed + $time_delay)
)
);
do_action('rsssl_wp_login_failed', $user->user_login, $error);
// Display the login form with an error message
self::login_html(
$user,
$redirect_to,
esc_html($error->get_error_message()),
$provider_key
);
exit;
}
// Validate authentication
if (!$provider_instance->validate_authentication($user)) {
// Handle rate limiting and failed attempts
self::handle_failed_attempt($user, $provider_class, $redirect_to, $nonce);
exit;
}
// Successful authentication
self::complete_authentication($user, $redirect_to);
}
/**
* Handles the case when a two-factor authentication attempt fails.
*
*
* @return void
* @throws Exception
*/
protected static function handle_failed_attempt(WP_User $user, string $provider_class, string $redirect_to, string $login_nonce): void {
// Store the last time a failed login occurred.
update_user_meta($user->ID, self::RSSSL_USER_RATE_LIMIT_KEY, time());
// Store the number of failed login attempts.
update_user_meta(
$user->ID,
self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY,
1 + (int)get_user_meta($user->ID, self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY, true)
);
if (self::should_reset_password($user->ID)) {
self::reset_compromised_password($user);
self::send_password_reset_emails($user);
self::show_password_reset_error();
exit;
}
/** @var Rsssl_Two_Factor_Provider_Interface $provider_class */
$provider_class::get_instance();
self::login_html(
$user,
$login_nonce,
$redirect_to,
esc_html__('Invalid verification code.', 'really-simple-ssl'),
$provider_class
);
}
/**
* Completes the two-factor authentication process. After a successful authentication, the user is redirected to the appropriate page.
*
* @return void
*/
protected static function complete_authentication(WP_User $user, string $redirect_to): void {
$rememberme = false;
if (isset($_REQUEST['rememberme']) && filter_var(wp_unslash($_REQUEST['rememberme']), FILTER_VALIDATE_BOOLEAN)) {
$rememberme = true;
}
// Authenticate the user.
wp_set_auth_cookie($user->ID, $rememberme);
do_action('rsssl_two_factor_user_authenticated', $user);
$redirect_to = apply_filters('login_redirect', $redirect_to, $redirect_to, $user);
// cleaning up the user meta.
delete_user_meta( $user->ID, self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY);
delete_user_meta( $user->ID, self::RSSSL_USER_RATE_LIMIT_KEY);
wp_safe_redirect($redirect_to);
exit;
}
/**
* Handle the case when the request method is not POST.
*
* @param WP_User $user The user object.
* @param string $provider The provider name.
*
* @return void
* @throws Exception If the login nonce cannot be created.
*/
private static function handle_not_post_request(WP_User $user, string $provider): void
{
$login_nonce = self::generate_login_nonce_for_user($user->ID);
self::login_html(
$user,
$login_nonce,
isset($_REQUEST['redirect_to']) ? wp_validate_redirect(wp_unslash($_REQUEST['redirect_to']), '') : '',
'',
$provider
);
}
/**
* Get the request data for two-factor authentication.
*
* @return array An array containing the sanitized values of wp_auth_id, nonce, and provider.
*/
private static function get_request_data(): array
{
$wp_auth_id = self::sanitize_request_data('rsssl-wp-auth-id', 0, 'absint');
$nonce = self::sanitize_request_data('rsssl-wp-auth-nonce', '', 'wp_unslash');
$provider = self::sanitize_request_data('provider', false, 'wp_unslash');
$redirect_to = self::sanitize_request_data('redirect_to', '', 'wp_unslash');
return array($wp_auth_id, $nonce, $provider, $redirect_to);
}
/**
* Sanitize request data.
*
* @param string $key The key to retrieve from the $_REQUEST array.
* @param mixed $default_value The default value to return if the key does not exist in the $_REQUEST array.
* @param callable $sanitize_callback The callback function used to sanitize the value.
*
* @return mixed The sanitized value if it exists in the $_REQUEST array, otherwise the default value.
*/
private static function sanitize_request_data(string $key, $default_value, callable $sanitize_callback)
{
return !empty($_REQUEST[$key]) ? $sanitize_callback(sanitize_text_field(wp_unslash($_REQUEST[$key]))) : $default_value;
}
/**
* Checks if a user's password should be reset based on the number of failed login attempts on the 2nd factor.
*
* @param int $user_id The ID of the user.
*
* @return bool True if the password should be reset, false otherwise.
*/
public static function should_reset_password(int $user_id): bool
{
$failed_attempts = (int)get_user_meta($user_id, self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY, true);
/**
* Filters the maximum number of failed attempts on a 2nd factor before the user's
* password will be reset. After a reasonable number of attempts, it's safe to assume
* that the password has been compromised and an attacker is trying to brute force the 2nd
* factor.
*
* ⚠️ `get_user_time_delay()` mitigates brute force attempts, but many 2nd factors --
* like TOTP and backup codes -- are very weak on their own, so it's not safe to give
* attackers unlimited attempts. Setting this to a very large number is strongly
* discouraged.
*
* @param int $limit The number of attempts before the password is reset.
*/
$failed_attempt_limit = apply_filters('rsssl_two_factor_failed_attempt_limit', 30);
return $failed_attempts >= $failed_attempt_limit;
}
/**
* Reset a compromised password.
*
* If we know that the password is compromised, we have the responsibility to reset it and inform the
* user. `get_user_time_delay()` mitigates brute force attempts, but this acts as an extra layer of defense
* which guarantees that attackers can't brute force it (unless they compromise the new password).
*
* @param WP_User $user The user who failed to log in.
*/
public static function reset_compromised_password(WP_User $user): void
{
// Unhook because `wp_password_change_notification()` wouldn't notify the site admin when
// their password is compromised.
remove_action('after_password_reset', 'wp_password_change_notification');
reset_password($user, wp_generate_password(25));
update_user_meta($user->ID, self::RSSSL_USER_PASSWORD_WAS_RESET_KEY, true);
add_action('after_password_reset', 'wp_password_change_notification');
Rsssl_Two_Fa_Authentication::delete_login_nonce($user->ID);
delete_user_meta($user->ID, self::RSSSL_USER_RATE_LIMIT_KEY);
delete_user_meta($user->ID, self::RSSSL_USER_FAILED_LOGIN_ATTEMPTS_KEY);
}
/**
* Notify the user and admin that a password was reset for being compromised.
*
* @param WP_User $user The user whose password should be reset.
*/
public static function send_password_reset_emails(WP_User $user): void
{
self::notify_user_password_reset($user);
/**
* Filters whether to email the site admin when a user's password has been
* compromised and reset.
*
* @param bool $reset `true` to notify the admin, `false` to not notify them.
*/
$notify_admin = apply_filters('rsssl_two_factor_notify_admin_user_password_reset', true);
$admin_email = get_option('admin_email');
if ($notify_admin && $admin_email !== $user->user_email) {
self::notify_admin_user_password_reset($user);
}
}
/**
* Show the password reset error when on the login screen.
*/
public static function show_password_reset_error(): void
{
$error = new WP_Error(
'too_many_attempts',
sprintf(
'<p>%s</p>
<p style="margin-top: 1em;">%s</p>',
__(
'There have been too many failed two-factor authentication attempts, which often indicates that the password has been compromised. The password has been reset in order to protect the account.',
'really-simple-ssl'
),
__(
'If you are the owner of this account, please check your email for instructions on regaining access.',
'really-simple-ssl'
)
)
);
login_header(__('Password Reset', 'really-simple-ssl'), '', $error);
login_footer();
}
/**
* Should the login session persist between sessions.
*
* @return boolean
*/
public static function rememberme(): bool
{
$rememberme = false;
if (!empty($_REQUEST['rememberme'])) {
$rememberme = true;
}
return (bool)apply_filters('rsssl_two_factor_rememberme', $rememberme);
}
/**
* Check if the user has completed the onboarding process.
*
* @param WP_User $user The WP_User object representing the user.
*
* @return void
* @throws Exception If the onboarding screen template cannot be loaded.
*/
private static function is_onboarding_complete(WP_User $user): void
{
// If the user has not completed the onboarding process, they should be shown the onboarding screen.
$onboarding_complete = get_user_meta($user->ID, self::RSSSL_USER_META_ONBOARDING_COMPLETE, true);
if (!$onboarding_complete) {
self::onboarding_user_html($user);
}
}
/**
* Display the expired onboarding error. Manually load our login header and
* footer functions to ensure they are available.
*/
private static function display_expired_onboarding_error(): void
{
if (!function_exists('login_header')) {
include_once __DIR__ . '/function-login-header.php';
}
if (!function_exists('login_footer')) {
include_once __DIR__ . '/function-login-footer.php';
}
rsssl_load_template('expired.php', [
'message' => esc_html__('Your 2FA grace period expired. Please contact your site administrator to regain access and to configure 2FA.', 'really-simple-ssl'),
], rsssl_path . 'assets/templates/two_fa/');
}
/**
* Validate that TOTP key exists for the user when TOTP provider is used.
* Destroys session and displays error if key is corrupted/missing.
*
* @param WP_User $user The user object.
* @param string $provider_key The provider key being used.
*
* @return void
*/
private static function validate_totp_key_exists( WP_User $user, string $provider_key ): void
{
if ( 'totp' !== $provider_key ) {
return;
}
if ( ! class_exists( 'RSSSL\Pro\Security\WordPress\Two_Fa\Providers\Rsssl_Two_Factor_Totp' ) ) {
return;
}
$totp_key = get_user_meta(
$user->ID,
\RSSSL\Pro\Security\WordPress\Two_Fa\Providers\Rsssl_Two_Factor_Totp::SECRET_META_KEY,
true
);
if ( empty( $totp_key ) ) {
// Verify we have a valid user before destroying their session
if ( ! $user instanceof WP_User || ! $user->exists() ) {
wp_die( esc_html__( 'Invalid user.', 'really-simple-ssl' ), 403 );
}
// TOTP key is missing/corrupted
self::destroy_current_session_for_user( $user );
wp_clear_auth_cookie();
self::display_corrupted_totp_error();
exit;
}
}
/**
* Display error when TOTP key is corrupted/missing. Manually load our login header and
* footer functions to ensure they are available.
* Follows the same template as the expired onboarding error.
*/
private static function display_corrupted_totp_error(): void
{
if (!function_exists('login_header')) {
include_once __DIR__ . '/function-login-header.php';
}
if (!function_exists('login_footer')) {
include_once __DIR__ . '/function-login-footer.php';
}
rsssl_load_template('expired.php', [
'message' => esc_html__('Your Two-Factor Authentication configuration is corrupted. Please contact your site administrator to regain access.', 'really-simple-ssl'),
], rsssl_path . 'assets/templates/two_fa/');
}
/**
* Generate the HTML for the onboarding screen for a given user.
*
* @param WP_User $user The user object.
*
* @return void
* @throws Exception If the onboarding screen template cannot be loaded.
*/
private static function onboarding_user_html(WP_User $user): void
{
$passkey_onboarding = get_user_meta($user->ID, 'rsssl_two_fa_status_passkey', true) === 'open';
// Variables needed for the template and scripts
$onboarding_url = self::login_url(array('action' => 'rsssl_onboarding'), 'login_post');
$provider_loader = Rsssl_Provider_Loader::get_loader();
$provider = self::get_primary_provider_for_user($user);
$redirect_to = isset($_REQUEST['redirect_to']) ? wp_validate_redirect(wp_unslash($_REQUEST['redirect_to']), admin_url()) : admin_url();
$enabled_providers = $provider_loader::get_user_enabled_providers($user);
$login_nonce = self::generate_login_nonce_for_user($user->ID);
$is_forced = Rsssl_Two_Factor_Settings::is_user_forced_to_use_2fa($user->ID);
$grace_period = Rsssl_Two_Factor_Settings::is_user_in_grace_period($user);
$is_today = Rsssl_Two_Factor_Settings::is_today($user);
if ($passkey_onboarding) {
$is_forced = false;
//if only passkey is available, set it as the only provider
if (count($enabled_providers) === 1 && isset($enabled_providers['passkey'])) {
$provider = 'passkey';
}
}
// Ensure login_header and login_footer functions are available
if (!function_exists('login_header')) {
include_once __DIR__ . '/function-login-header.php';
}
if (!function_exists('login_footer')) {
include_once __DIR__ . '/function-login-footer.php';
}
//Add the styles for the two-factor authentication.
add_action('login_enqueue_styles', array(__CLASS__, 'enqueue_onboarding_styles'));
$uri = trailingslashit(rsssl_url) . 'assets/features/two-fa/assets.min.js';
$uri_file = trailingslashit(rsssl_path) . 'assets/features/two-fa/assets.min.js';
add_filter('wp_script_attributes', [self::class, 'handle_script_attributes'], 10, 2);
wp_enqueue_script('rsssl-frontend-settings', $uri, array(), filemtime($uri_file), true);
wp_localize_script('rsssl-frontend-settings', 'rsssl_onboard', array(
'nonce' => wp_create_nonce('wp_rest'),
'root' => esc_url_raw(rest_url(self::REST_NAMESPACE)),
'login_nonce' => $login_nonce,
'redirect_to' => $redirect_to,
'user_id' => $user->ID,
'origin' => 'onboarding',
'translatables' => apply_filters('rsssl_two_factor_translatables', []),
));
login_header(
__('Two-Factor Authentication Setup', 'really-simple-ssl'),
'',
null
);
rsssl_load_template(
'onboarding.php',
array(
'user' => $user,
'login_nonce' => $login_nonce,
'url' => $onboarding_url,
'provider' => $provider,
'redirect_to' => $redirect_to,
'available_providers' => $enabled_providers,
'interim_login' => isset($_REQUEST['interim-login']),
'rememberme' => (int)self::rememberme(),
'primary_provider' => $provider,
'is_forced' => $is_forced,
'grace_period' => $grace_period,
'is_today' => $is_today,
'skip_two_fa_url' => Rsssl_Two_Factor_Settings::rsssl_one_time_login_url($user->ID),
),
rsssl_path . 'assets/templates/two_fa/'
);
wp_enqueue_script('rsssl-rest-settings');
login_footer();
if (ob_get_level() > 0) {
ob_flush();
}
flush();
exit; //This was the original exit.
}
/**
* Handles the script attributes.
*
*
* @param array $attributes
* @param string $handle
*
* @return array
*/
public static function handle_script_attributes( array $attributes, string $handle = ''):array
{
if ( $handle === 'rsssl-profile-settings' ) {
$attributes['type'] = 'module';
}
return $attributes;
}
/**
* Enqueues the RSSSL profile settings stylesheet.
*
* @return void
*/
public static function enqueue_onboarding_styles(): void
{
$url = trailingslashit(rsssl_url) . 'assets/features/two-fa/styles.css';
$file = trailingslashit(rsssl_path) . 'assets/features/two-fa/styles.css';
wp_enqueue_style('rsssl-profile-settings', $url, array(), filemtime($file));
}
/**
* Return the translatable strings for the two-factor authentication.
* @return array
*/
public static function translatables(): array {
return self::rsssl_translatables([]);
}
/**
* places all translatable strings.
*
*
* @return array
*/
public static function rsssl_translatables(array $translatables): array {
$new_translatables = [
'download_codes' => esc_html__('Download Backup Codes', 'really-simple-ssl'),
'keyCopied' => __('Key copied', 'really-simple-ssl'),
'keyCopiedFailed' => __('Could not copy text: ', 'really-simple-ssl'),
];
return array_merge($translatables, $new_translatables);
}
/**
* Generates a login nonce for a user. and returns the key.
*
* @param $user_id
*
* @return string
*/
protected static function generate_login_nonce_for_user( $user_id ): string {
$login_nonce = Rsssl_Two_Fa_Authentication::create_login_nonce( $user_id );
if ( ! $login_nonce ) {
$error = new WP_Error();
$error->add(
'login_nonce_creation_failed',
__( 'Failed to create a login nonce.', 'really-simple-ssl' )
);
}
return $login_nonce['rsssl_key'];
}
}
/**
* Hook as soon as the file is required. Which is the plugins_loaded hook.
* @see security/integrations.php
*/
$rsssl_two_factor_compat = new Rsssl_Two_Factor_Compat();
Rsssl_Two_Factor::add_hooks($rsssl_two_factor_compat);