updated plugin `Two Factor` version 0.8.1

This commit is contained in:
KawaiiPunk 2023-03-29 18:20:25 +00:00 committed by Gitium
parent 16a556be53
commit faf8c388d3
8 changed files with 3371 additions and 303 deletions

View File

@ -35,6 +35,27 @@ class Two_Factor_Core {
*/
const USER_META_NONCE_KEY = '_two_factor_nonce';
/**
* The user meta key to store the last failed timestamp.
*
* @type string
*/
const USER_RATE_LIMIT_KEY = '_two_factor_last_login_failure';
/**
* The user meta key to store the number of failed login attempts.
*
* @var string
*/
const USER_FAILED_LOGIN_ATTEMPTS_KEY = '_two_factor_failed_login_attempts';
/**
* The user meta key to store whether or not the password was reset.
*
* @var string
*/
const USER_PASSWORD_WAS_RESET_KEY = '_two_factor_password_was_reset';
/**
* URL query paramater used for our custom actions.
*
@ -49,6 +70,13 @@ class Two_Factor_Core {
*/
const USER_SETTINGS_ACTION_NONCE_QUERY_ARG = '_two_factor_action_nonce';
/**
* Namespace for plugin rest api endpoints.
*
* @var string
*/
const REST_NAMESPACE = 'two-factor/1.0';
/**
* Keep track of all the password-based authentication sessions that
* need to invalidated before the second factor authentication.
@ -60,7 +88,7 @@ class Two_Factor_Core {
/**
* Set up filters and actions.
*
* @param object $compat A compaitbility later for plugins.
* @param object $compat A compatibility layer for plugins.
*
* @since 0.1-dev
*/
@ -68,8 +96,9 @@ class Two_Factor_Core {
add_action( 'plugins_loaded', array( __CLASS__, 'load_textdomain' ) );
add_action( 'init', array( __CLASS__, 'get_providers' ) );
add_action( 'wp_login', array( __CLASS__, 'wp_login' ), 10, 2 );
add_filter( 'wp_login_errors', array( __CLASS__, 'maybe_show_reset_password_notice' ) );
add_action( 'after_password_reset', array( __CLASS__, 'clear_password_reset_notice' ) );
add_action( 'login_form_validate_2fa', array( __CLASS__, 'login_form_validate_2fa' ) );
add_action( 'login_form_backup_2fa', array( __CLASS__, 'backup_2fa' ) );
add_action( 'show_user_profile', array( __CLASS__, 'user_two_factor_options' ) );
add_action( 'edit_user_profile', array( __CLASS__, 'user_two_factor_options' ) );
add_action( 'personal_options_update', array( __CLASS__, 'user_two_factor_options_update' ) );
@ -90,6 +119,9 @@ class Two_Factor_Core {
// Run only after the core wp_authenticate_username_password() check.
add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 50 );
// Run as late as possible to prevent other plugins from unintentionally bypassing.
add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate_block_cookies' ), PHP_INT_MAX );
add_action( 'admin_init', array( __CLASS__, 'trigger_user_settings_action' ) );
add_filter( 'two_factor_providers', array( __CLASS__, 'enable_dummy_method_for_debug' ) );
@ -244,7 +276,11 @@ class Two_Factor_Core {
* @return boolean
*/
public static function is_valid_user_action( $user_id, $action ) {
$request_nonce = filter_input( INPUT_GET, self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG, FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) );
$request_nonce = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) : '';
if ( ! $user_id || ! $action || ! $request_nonce ) {
return false;
}
return wp_verify_nonce(
$request_nonce,
@ -277,10 +313,10 @@ class Two_Factor_Core {
* @return void
*/
public static function trigger_user_settings_action() {
$action = filter_input( INPUT_GET, self::USER_SETTINGS_ACTION_QUERY_VAR, FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) );
$action = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) : '';
$user_id = self::current_user_being_edited();
if ( ! empty( $action ) && self::is_valid_user_action( $user_id, $action ) ) {
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.
@ -308,15 +344,39 @@ class Two_Factor_Core {
}
}
/**
* Fetch the WP_User object for a provided input.
*
* @since 0.8.0
*
* @param int|WP_User $user Optional. The WP_User or user ID. Defaults to current user.
*
* @return false|WP_User WP_User on success, false on failure.
*/
public static function fetch_user( $user = null ) {
if ( null === $user ) {
$user = wp_get_current_user();
} elseif ( ! ( $user instanceof WP_User ) ) {
$user = get_user_by( 'id', $user );
}
if ( ! $user || ! $user->exists() ) {
return false;
}
return $user;
}
/**
* Get all Two-Factor Auth providers that are enabled for the specified|current user.
*
* @param WP_User $user WP_User object of the logged-in user.
* @param int|WP_User $user Optonal. User ID, or WP_User object of the the user. Defaults to current user.
* @return array
*/
public static function get_enabled_providers_for_user( $user = null ) {
if ( empty( $user ) || ! is_a( $user, 'WP_User' ) ) {
$user = wp_get_current_user();
$user = self::fetch_user( $user );
if ( ! $user ) {
return array();
}
$providers = self::get_providers();
@ -338,12 +398,13 @@ class Two_Factor_Core {
/**
* Get all Two-Factor Auth providers that are both enabled and configured for the specified|current user.
*
* @param WP_User $user WP_User object of the logged-in user.
* @param int|WP_User $user Optonal. User ID, or WP_User object of the the user. Defaults to current user.
* @return array
*/
public static function get_available_providers_for_user( $user = null ) {
if ( empty( $user ) || ! is_a( $user, 'WP_User' ) ) {
$user = wp_get_current_user();
$user = self::fetch_user( $user );
if ( ! $user ) {
return array();
}
$providers = self::get_providers();
@ -364,16 +425,17 @@ class Two_Factor_Core {
*
* @since 0.1-dev
*
* @param int $user_id Optional. User ID. Default is 'null'.
* @param int|WP_User $user Optonal. User ID, or WP_User object of the the user. Defaults to current user.
* @return object|null
*/
public static function get_primary_provider_for_user( $user_id = null ) {
if ( empty( $user_id ) || ! is_numeric( $user_id ) ) {
$user_id = get_current_user_id();
public static function get_primary_provider_for_user( $user = null ) {
$user = self::fetch_user( $user );
if ( ! $user ) {
return null;
}
$providers = self::get_providers();
$available_providers = self::get_available_providers_for_user( get_userdata( $user_id ) );
$available_providers = self::get_available_providers_for_user( $user );
// If there's only one available provider, force that to be the primary.
if ( empty( $available_providers ) ) {
@ -381,7 +443,7 @@ class Two_Factor_Core {
} elseif ( 1 === count( $available_providers ) ) {
$provider = key( $available_providers );
} else {
$provider = get_user_meta( $user_id, self::PROVIDER_USER_META_KEY, true );
$provider = get_user_meta( $user->ID, self::PROVIDER_USER_META_KEY, true );
// If the provider specified isn't enabled, just grab the first one that is.
if ( ! isset( $available_providers[ $provider ] ) ) {
@ -395,7 +457,7 @@ class Two_Factor_Core {
* @param string $provider The provider currently being used.
* @param int $user_id The user ID.
*/
$provider = apply_filters( 'two_factor_primary_provider_for_user', $provider, $user_id );
$provider = apply_filters( 'two_factor_primary_provider_for_user', $provider, $user->ID );
if ( isset( $providers[ $provider ] ) ) {
return $providers[ $provider ];
@ -409,11 +471,11 @@ class Two_Factor_Core {
*
* @since 0.1-dev
*
* @param int $user_id Optional. User ID. Default is 'null'.
* @param int|WP_User $user Optonal. User ID, or WP_User object of the the user. Defaults to current user.
* @return bool
*/
public static function is_user_using_two_factor( $user_id = null ) {
$provider = self::get_primary_provider_for_user( $user_id );
public static function is_user_using_two_factor( $user = null ) {
$provider = self::get_primary_provider_for_user( $user );
return ! empty( $provider );
}
@ -480,10 +542,33 @@ class Two_Factor_Core {
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 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->ID ) && did_action( 'login_init' ) ) {
add_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX );
}
return $user;
}
/**
* If the current user can login via API requests such as XML-RPC and REST.
*
* @param integer $user_id User ID.
* @param integer $user_id User ID.
*
* @return boolean
*/
@ -531,40 +616,82 @@ class Two_Factor_Core {
}
/**
* Display the Backup code 2fa screen.
* Displays a message informing the user that their account has had failed login attempts.
*
* @since 0.1-dev
* @param WP_User $user WP_User object of the logged-in user.
*/
public static function backup_2fa() {
$wp_auth_id = filter_input( INPUT_GET, 'wp-auth-id', FILTER_SANITIZE_NUMBER_INT );
$nonce = filter_input( INPUT_GET, 'wp-auth-nonce', FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) );
$provider = filter_input( INPUT_GET, 'provider', FILTER_CALLBACK, array( 'options' => 'sanitize_text_field' ) );
public static function maybe_show_last_login_failure_notice( $user ) {
$last_failed_two_factor_login = (int) get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true );
$failed_login_count = (int) get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true );
if ( ! $wp_auth_id || ! $nonce || ! $provider ) {
return;
if ( $last_failed_two_factor_login ) {
echo '<div id="login_notice" class="message"><strong>';
printf(
_n(
'WARNING: Your account has attempted to login without providing a valid two factor token. The last failed login occured %2$s ago. If this wasn\'t you, you should reset your password.',
'WARNING: Your account has attempted to login %1$s times without providing a valid two factor token. The last failed login occured %2$s ago. If this wasn\'t you, you should reset your password.',
$failed_login_count,
'two-factor'
),
number_format_i18n( $failed_login_count ),
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
*/
public static function maybe_show_reset_password_notice( $errors ) {
if ( 'incorrect_password' !== $errors->get_error_code() ) {
return $errors;
}
$user = get_userdata( $wp_auth_id );
if ( ! $user ) {
return;
if ( ! isset( $_POST['log'] ) ) {
return $errors;
}
if ( true !== self::verify_login_nonce( $user->ID, $nonce ) ) {
wp_safe_redirect( home_url() );
exit;
$user_name = sanitize_user( wp_unslash( $_POST['log'] ) );
$attempted_user = get_user_by( 'login', $user_name );
if ( ! $attempted_user && str_contains( $user_name, '@' ) ) {
$attempted_user = get_user_by( 'email', $user_name );
}
$providers = self::get_available_providers_for_user( $user );
if ( isset( $providers[ $provider ] ) ) {
$provider = $providers[ $provider ];
} else {
wp_die( esc_html__( 'Cheatin&#8217; uh?', 'two-factor' ), 403 );
if ( ! $attempted_user ) {
return $errors;
}
$redirect_to = filter_input( INPUT_GET, 'redirect_to', FILTER_SANITIZE_URL );
self::login_html( $user, $nonce, $redirect_to, '', $provider );
$password_was_reset = get_user_meta( $attempted_user->ID, self::USER_PASSWORD_WAS_RESET_KEY, true );
exit;
if ( ! $password_was_reset ) {
return $errors;
}
$errors->remove( 'incorrect_password' );
$errors->add(
'two_factor_password_reset',
sprintf(
__( '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.', 'two-factor' ),
esc_url( add_query_arg( 'action', 'lostpassword', wp_login_url() ) )
)
);
return $errors;
}
/**
* Clear the password reset notice after the user resets their password.
*
* @param WP_User $user
*/
public static function clear_password_reset_notice( $user ) {
delete_user_meta( $user->ID, self::USER_PASSWORD_WAS_RESET_KEY );
}
/**
@ -602,6 +729,8 @@ class Two_Factor_Core {
if ( ! empty( $error_msg ) ) {
echo '<div id="login_error"><strong>' . esc_html( $error_msg ) . '</strong><br /></div>';
} else {
self::maybe_show_last_login_failure_notice( $user );
}
?>
@ -625,7 +754,7 @@ class Two_Factor_Core {
$backup_provider = $backup_providers[ $backup_classname ];
$login_url = self::login_url(
array(
'action' => 'backup_2fa',
'action' => 'validate_2fa',
'provider' => $backup_classname,
'wp-auth-id' => $user->ID,
'wp-auth-nonce' => $login_nonce,
@ -661,7 +790,7 @@ class Two_Factor_Core {
foreach ( $backup_providers as $backup_classname => $backup_provider ) :
$login_url = self::login_url(
array(
'action' => 'backup_2fa',
'action' => 'validate_2fa',
'provider' => $backup_classname,
'wp-auth-id' => $user->ID,
'wp-auth-nonce' => $login_nonce,
@ -698,8 +827,51 @@ class Two_Factor_Core {
.jetpack-sso-form-display #loginform > div {
display: block;
}
#login form p.two-factor-prompt {
margin-bottom: 1em;
}
.input.authcode {
letter-spacing: .3em;
}
.input.authcode::placeholder {
opacity: 0.5;
}
</style>
<script>
(function() {
// Enforce numeric-only input for numeric inputmode elements.
const form = document.querySelector( '#loginform' ),
inputEl = document.querySelector( 'input.authcode[inputmode="numeric"]' ),
expectedLength = inputEl?.dataset.digits || 0;
if ( inputEl ) {
let spaceInserted = false;
inputEl.addEventListener(
'input',
function() {
let value = this.value.replace( /[^0-9 ]/g, '' ).trimStart();
if ( ! spaceInserted && expectedLength && value.length === Math.floor( expectedLength / 2 ) ) {
value += ' ';
spaceInserted = true;
} else if ( spaceInserted && ! this.value ) {
spaceInserted = false;
}
this.value = value;
// Auto-submit if it's the expected length.
if ( expectedLength && value.replace( / /g, '' ).length == expectedLength ) {
if ( undefined !== form.requestSubmit ) {
form.requestSubmit();
form.submit.disabled = "disabled";
}
}
}
);
}
})();
</script>
<?php
if ( ! function_exists( 'login_footer' ) ) {
include_once TWO_FACTOR_DIR . 'includes/function.login-footer.php';
@ -731,12 +903,20 @@ class Two_Factor_Core {
/**
* Get the hash of a nonce for storage and comparison.
*
* @param string $nonce Nonce value to be hashed.
* @param array $nonce Nonce array to be hashed. ⚠️ This must contain user ID and expiration,
* to guarantee the nonce only works for the intended user during the
* intended time window.
*
* @return string
* @return string|false
*/
protected static function hash_login_nonce( $nonce ) {
return wp_hash( $nonce, 'nonce' );
$message = wp_json_encode( $nonce );
if ( ! $message ) {
return false;
}
return wp_hash( $message, 'nonce' );
}
/**
@ -745,11 +925,12 @@ class Two_Factor_Core {
* @since 0.1-dev
*
* @param int $user_id User ID.
* @return array
* @return array|false
*/
public static function create_login_nonce( $user_id ) {
$login_nonce = array(
'expiration' => time() + HOUR_IN_SECONDS,
'user_id' => $user_id,
'expiration' => time() + ( 10 * MINUTE_IN_SECONDS ),
);
try {
@ -759,14 +940,20 @@ class Two_Factor_Core {
}
// Store the nonce hashed to avoid leaking it via database access.
$login_nonce_stored = $login_nonce;
$login_nonce_stored['key'] = self::hash_login_nonce( $login_nonce['key'] );
$hashed_key = self::hash_login_nonce( $login_nonce );
if ( ! update_user_meta( $user_id, self::USER_META_NONCE_KEY, $login_nonce_stored ) ) {
return false;
if ( $hashed_key ) {
$login_nonce_stored = array(
'expiration' => $login_nonce['expiration'],
'key' => $hashed_key,
);
if ( update_user_meta( $user_id, self::USER_META_NONCE_KEY, $login_nonce_stored ) ) {
return $login_nonce;
}
}
return $login_nonce;
return false;
}
/**
@ -797,7 +984,16 @@ class Two_Factor_Core {
return false;
}
if ( hash_equals( $login_nonce['key'], self::hash_login_nonce( $nonce ) ) && time() < $login_nonce['expiration'] ) {
$unverified_nonce = array(
'user_id' => $user_id,
'expiration' => $login_nonce['expiration'],
'key' => $nonce,
);
$unverified_hash = self::hash_login_nonce( $unverified_nonce );
$hashes_match = $unverified_hash && hash_equals( $login_nonce['key'], $unverified_hash );
if ( $hashes_match && time() < $login_nonce['expiration'] ) {
return true;
}
@ -807,14 +1003,85 @@ class Two_Factor_Core {
return false;
}
/**
* 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( $user ) {
/**
* 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( 'two_factor_rate_limit', 1 );
$user_failed_logins = get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true );
if ( $user_failed_logins ) {
$rate_limit = pow( 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( '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 login.
*/
return apply_filters( 'two_factor_user_rate_limit', $rate_limit, $user );
}
/**
* Determine if a time delay between user two factor login attempts should be triggered.
*
* @since 0.8.0
*
* @param WP_User $user The User.
* @return bool True if rate limit is okay, false if not.
*/
public static function is_user_rate_limited( $user ) {
$rate_limit = self::get_user_time_delay( $user );
$last_failed = get_user_meta( $user->ID, self::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 login.
*/
return apply_filters( 'two_factor_is_user_rate_limited', $rate_limited, $user );
}
/**
* Login form validation.
*
* @since 0.1-dev
*/
public static function login_form_validate_2fa() {
$wp_auth_id = filter_input( INPUT_POST, 'wp-auth-id', FILTER_SANITIZE_NUMBER_INT );
$nonce = filter_input( INPUT_POST, 'wp-auth-nonce', FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) );
$wp_auth_id = ! empty( $_REQUEST['wp-auth-id'] ) ? absint( $_REQUEST['wp-auth-id'] ) : 0;
$nonce = ! empty( $_REQUEST['wp-auth-nonce'] ) ? wp_unslash( $_REQUEST['wp-auth-nonce'] ) : '';
$provider = ! empty( $_REQUEST['provider'] ) ? wp_unslash( $_REQUEST['provider'] ) : false;
$is_post_request = ( 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) );
if ( ! $wp_auth_id || ! $nonce ) {
return;
@ -830,7 +1097,6 @@ class Two_Factor_Core {
exit;
}
$provider = filter_input( INPUT_POST, 'provider', FILTER_CALLBACK, array( 'options' => 'sanitize_text_field' ) );
if ( $provider ) {
$providers = self::get_available_providers_for_user( $user );
if ( isset( $providers[ $provider ] ) ) {
@ -853,10 +1119,58 @@ class Two_Factor_Core {
exit;
}
// If the form hasn't been submitted, just display the auth form.
if ( ! $is_post_request ) {
$login_nonce = self::create_login_nonce( $user->ID );
if ( ! $login_nonce ) {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
}
self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], '', $provider );
exit;
}
// Rate limit two factor authentication attempts.
if ( true === self::is_user_rate_limited( $user ) ) {
$time_delay = self::get_user_time_delay( $user );
$last_login = get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true );
$error = new WP_Error(
'two_factor_too_fast',
sprintf(
__( 'ERROR: Too many invalid verification codes, you can try again in %s. This limit protects your account against automated attacks.', 'two-factor' ),
human_time_diff( $last_login + $time_delay )
)
);
do_action( 'wp_login_failed', $user->user_login, $error );
$login_nonce = self::create_login_nonce( $user->ID );
if ( ! $login_nonce ) {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
}
self::login_html( $user, $login_nonce['key'], $_REQUEST['redirect_to'], esc_html( $error->get_error_message() ), $provider );
exit;
}
// Ask the provider to verify the second factor.
if ( true !== $provider->validate_authentication( $user ) ) {
do_action( 'wp_login_failed', $user->user_login, new WP_Error( 'two_factor_invalid', __( 'ERROR: Invalid verification code.', 'two-factor' ) ) );
// Store the last time a failed login occured.
update_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, time() );
// Store the number of failed login attempts.
update_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 + (int) get_user_meta( $user->ID, self::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;
}
$login_nonce = self::create_login_nonce( $user->ID );
if ( ! $login_nonce ) {
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
@ -867,12 +1181,20 @@ class Two_Factor_Core {
}
self::delete_login_nonce( $user->ID );
delete_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY );
delete_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY );
$rememberme = false;
if ( isset( $_REQUEST['rememberme'] ) && $_REQUEST['rememberme'] ) {
$rememberme = true;
}
/*
* NOTE: This filter removal is not normally required, this is included for protection against
* a plugin/two factor provider which runs the `authenticate` filter during it's validation.
* Such a plugin would cause self::filter_authenticate_block_cookies() to run and add this filter.
*/
remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX );
wp_set_auth_cookie( $user->ID, $rememberme );
do_action( 'two_factor_user_authenticated', $user );
@ -908,6 +1230,154 @@ class Two_Factor_Core {
exit;
}
/**
* Determine if the user's password should be reset.
*
* @param int $user_id
*
* @return bool
*/
public static function should_reset_password( $user_id ) {
$failed_attempts = (int) get_user_meta( $user_id, self::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( 'two_factor_failed_attempt_limit', 30 );
return $failed_attempts >= $failed_attempt_limit;
}
/**
* Reset a compromised password.
*
* If we know that the 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 login
*/
public static function reset_compromised_password( $user ) {
// 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::USER_PASSWORD_WAS_RESET_KEY, true );
add_action( 'after_password_reset', 'wp_password_change_notification' );
self::delete_login_nonce( $user->ID );
delete_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY );
delete_user_meta( $user->ID, self::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( $user ) {
self::notify_user_password_reset( $user );
/**
* Filters whether or not 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( '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 );
}
}
/**
* Notify the user that their password has been compromised and reset.
*
* @param WP_User $user The user to notify
*
* @return bool `true` if the email was sent, `false` if it failed.
*/
public static function notify_user_password_reset( $user ) {
$user_message = sprintf(
'Hello %1$s, an unusually high number of failed login attempts have been detected on your account at %2$s.
These attempts successfully entered your password, and were only blocked because they failed to enter your second authentication factor. Despite not being able to access your account, this behavior indicates that the attackers have compromised your password. The most common reasons for this are that your password was easy to guess, or was reused on another site which has been compromised.
To protect your account, your password has been reset, and you will need to create a new one. For advice on setting a strong password, please read %3$s
To pick a new password, please visit %4$s
This is an automated notification. If you would like to speak to a site administrator, please contact them directly.',
esc_html( $user->user_login ),
home_url(),
'https://wordpress.org/documentation/article/password-best-practices/',
esc_url( add_query_arg( 'action', 'lostpassword', wp_login_url() ) )
);
$user_message = str_replace( "\t", '', $user_message );
return wp_mail( $user->user_email, 'Your password was compromised and has been reset', $user_message );
}
/**
* Notify the admin that a user's password was compromised and reset.
*
* @param WP_User $user The user whose password was reset.
*
* @return bool `true` if the email was sent, `false` if it failed.
*/
public static function notify_admin_user_password_reset( $user ) {
$admin_email = get_option( 'admin_email' );
$subject = sprintf( 'Compromised password for %s has been reset', esc_html( $user->user_login ) );
$message = sprintf(
'Hello, this is a notice from the Two Factor plugin to inform you that an unusually high number of failed login attempts have been detected on the %1$s account (ID %2$d).
Those attempts successfully entered the user\'s password, and were only blocked because they entered invalid second authentication factors.
To protect their account, the password has automatically been reset, and they have been notified that they will need to create a new one.
If you do not wish to receive these notifications, you can disable them with the `two_factor_notify_admin_user_password_reset` filter. See %3$s for more information.
Thank you',
esc_html( $user->user_login ),
$user->ID,
'https://developer.wordpress.org/plugins/hooks/'
);
$message = str_replace( "\t", '', $message );
return wp_mail( $admin_email, $subject, $message );
}
/**
* Show the password reset error when on the login screen.
*/
public static function show_password_reset_error() {
$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.', 'two-factor' ),
__( 'If you are the owner of this account, please check your email for instructions on regaining access.', 'two-factor' )
)
);
login_header( __( 'Password Reset', 'two-factor' ), '', $error );
login_footer();
}
/**
* Filter the columns on the Users admin screen.
*
@ -1020,6 +1490,41 @@ class Two_Factor_Core {
do_action( 'show_user_security_settings', $user );
}
/**
* Enable a provider for a user.
*
* @param int $user_id The ID of the user.
* @param string $new_provider The name of the provider class.
*
* @return bool True if the provider was enabled, false otherwise.
*/
public static function enable_provider_for_user( $user_id, $new_provider ) {
$available_providers = self::get_providers();
if ( ! array_key_exists( $new_provider, $available_providers ) ) {
return false;
}
$user = get_userdata( $user_id );
$enabled_providers = self::get_enabled_providers_for_user( $user );
if ( in_array( $new_provider, $enabled_providers ) ) {
return true;
}
$enabled_providers[] = $new_provider;
$enabled = update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, $enabled_providers );
// Primary provider must be enabled.
$has_primary = is_object( self::get_primary_provider_for_user( $user_id ) );
if ( ! $has_primary ) {
$has_primary = update_user_meta( $user_id, self::PROVIDER_USER_META_KEY, $new_provider );
}
return $enabled && $has_primary;
}
/**
* Update the user meta value.
*

File diff suppressed because it is too large Load Diff

View File

@ -46,19 +46,53 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
* Class constructor.
*
* @since 0.1-dev
*
* @codeCoverageIgnore
*/
protected function __construct() {
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) );
add_action( 'admin_notices', array( $this, 'admin_notices' ) );
add_action( 'wp_ajax_two_factor_backup_codes_generate', array( $this, 'ajax_generate_json' ) );
return parent::__construct();
}
/**
* Register the rest-api endpoints required for this provider.
*
* @codeCoverageIgnore
*/
public function register_rest_routes() {
register_rest_route(
Two_Factor_Core::REST_NAMESPACE,
'/generate-backup-codes',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_generate_codes' ),
'permission_callback' => function( $request ) {
return current_user_can( 'edit_user', $request['user_id'] );
},
'args' => array(
'user_id' => array(
'required' => true,
'type' => 'number',
),
'enable_provider' => array(
'required' => false,
'type' => 'boolean',
'default' => false,
),
),
)
);
}
/**
* Displays an admin notice when backup codes have run out.
*
* @since 0.1-dev
*
* @codeCoverageIgnore
*/
public function admin_notices() {
$user = wp_get_current_user();
@ -125,8 +159,7 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
* @param WP_User $user WP_User object of the logged-in user.
*/
public function user_options( $user ) {
$ajax_nonce = wp_create_nonce( 'two-factor-backup-codes-generate-json-' . $user->ID );
$count = self::codes_remaining_for_user( $user );
$count = self::codes_remaining_for_user( $user );
?>
<p id="two-factor-backup-codes">
<button type="button" class="button button-two-factor-backup-codes-generate button-secondary hide-if-no-js">
@ -154,39 +187,26 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
<script type="text/javascript">
( function( $ ) {
$( '.button-two-factor-backup-codes-generate' ).click( function() {
$.ajax( {
wp.apiRequest( {
method: 'POST',
url: ajaxurl,
path: <?php echo wp_json_encode( Two_Factor_Core::REST_NAMESPACE . '/generate-backup-codes' ); ?>,
data: {
action: 'two_factor_backup_codes_generate',
user_id: '<?php echo esc_js( $user->ID ); ?>',
nonce: '<?php echo esc_js( $ajax_nonce ); ?>'
},
dataType: 'JSON',
success: function( response ) {
var $codesList = $( '.two-factor-backup-codes-unused-codes' );
$( '.two-factor-backup-codes-wrapper' ).show();
$codesList.html( '' );
// Append the codes.
for ( i = 0; i < response.data.codes.length; i++ ) {
$codesList.append( '<li>' + response.data.codes[ i ] + '</li>' );
}
// Update counter.
$( '.two-factor-backup-codes-count' ).html( response.data.i18n.count );
// Build the download link.
var txt_data = 'data:application/text;charset=utf-8,' + '\n';
txt_data += response.data.i18n.title.replace( /%s/g, document.domain ) + '\n\n';
for ( i = 0; i < response.data.codes.length; i++ ) {
txt_data += i + 1 + '. ' + response.data.codes[ i ] + '\n';
}
$( '#two-factor-backup-codes-download-link' ).attr( 'href', encodeURI( txt_data ) );
user_id: <?php echo wp_json_encode( $user->ID ); ?>
}
} ).then( function( response ) {
var $codesList = $( '.two-factor-backup-codes-unused-codes' );
$( '.two-factor-backup-codes-wrapper' ).show();
$codesList.html( '' );
// Append the codes.
for ( i = 0; i < response.codes.length; i++ ) {
$codesList.append( '<li>' + response.codes[ i ] + '</li>' );
}
// Update counter.
$( '.two-factor-backup-codes-count' ).html( response.i18n.count );
$( '#two-factor-backup-codes-download-link' ).attr( 'href', response.download_link );
} );
} );
} )( jQuery );
@ -233,30 +253,53 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
}
/**
* Generates a JSON object of backup codes.
* Generates Backup Codes for returning through the WordPress Rest API.
*
* @since 0.1-dev
* @since 0.8.0
*/
public function ajax_generate_json() {
$user = get_user_by( 'id', filter_input( INPUT_POST, 'user_id', FILTER_SANITIZE_NUMBER_INT ) );
check_ajax_referer( 'two-factor-backup-codes-generate-json-' . $user->ID, 'nonce' );
public function rest_generate_codes( $request ) {
$user_id = $request['user_id'];
$user = get_user_by( 'id', $user_id );
// Setup the return data.
$codes = $this->generate_codes( $user );
$count = self::codes_remaining_for_user( $user );
$i18n = array(
/* translators: %s: count */
'count' => esc_html( sprintf( _n( '%s unused code remaining.', '%s unused codes remaining.', $count, 'two-factor' ), $count ) ),
/* translators: %s: the site's domain */
'title' => esc_html__( 'Two-Factor Backup Codes for %s', 'two-factor' ),
// Hardcode these, the user shouldn't be able to choose them.
$args = array(
'number' => self::NUMBER_OF_CODES,
'method' => 'replace',
);
// Send the response.
wp_send_json_success(
array(
'codes' => $codes,
'i18n' => $i18n,
)
// Setup the return data.
$codes = $this->generate_codes( $user, $args );
$count = self::codes_remaining_for_user( $user );
$title = sprintf(
/* translators: %s: the site's domain */
__( 'Two-Factor Backup Codes for %s', 'two-factor' ),
home_url( '/' )
);
// Generate download content.
$download_link = 'data:application/text;charset=utf-8,';
$download_link .= rawurlencode( "{$title}\r\n\r\n" );
$i = 1;
foreach ( $codes as $code ) {
$download_link .= rawurlencode( "{$i}. {$code}\r\n" );
$i++;
}
$i18n = array(
/* translators: %s: count */
'count' => esc_html( sprintf( _n( '%s unused code remaining.', '%s unused codes remaining.', $count, 'two-factor' ), $count ) ),
);
if ( $request->get_param( 'enable_provider' ) && ! Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Backup_Codes' ) ) {
return new WP_Error( 'db_error', __( 'Unable to enable Backup Codes provider for this user.', 'two-factor' ), array( 'status' => 500 ) );
}
return array(
'codes' => $codes,
'download_link' => $download_link,
'remaining' => $count,
'i18n' => $i18n,
);
}
@ -284,10 +327,10 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
public function authentication_page( $user ) {
require_once ABSPATH . '/wp-admin/includes/template.php';
?>
<p><?php esc_html_e( 'Enter a backup verification code.', 'two-factor' ); ?></p><br/>
<p class="two-factor-prompt"><?php esc_html_e( 'Enter a backup verification code.', 'two-factor' ); ?></p>
<p>
<label for="authcode"><?php esc_html_e( 'Verification Code:', 'two-factor' ); ?></label>
<input type="tel" name="two-factor-backup-code" id="authcode" class="input" value="" size="20" pattern="[0-9]*" />
<input type="text" inputmode="numeric" name="two-factor-backup-code" id="authcode" class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="1234 5678" data-digits="8" />
</p>
<?php
submit_button( __( 'Submit', 'two-factor' ) );
@ -304,7 +347,11 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
* @return boolean
*/
public function validate_authentication( $user ) {
$backup_code = isset( $_POST['two-factor-backup-code'] ) ? sanitize_text_field( wp_unslash( $_POST['two-factor-backup-code'] ) ) : '';
$backup_code = $this->sanitize_code_from_request( 'two-factor-backup-code' );
if ( ! $backup_code ) {
return false;
}
return $this->validate_code( $user, $backup_code );
}

View File

@ -266,10 +266,10 @@ class Two_Factor_Email extends Two_Factor_Provider {
require_once ABSPATH . '/wp-admin/includes/template.php';
?>
<p><?php esc_html_e( 'A verification code has been sent to the email address associated with your account.', 'two-factor' ); ?></p>
<p class="two-factor-prompt"><?php esc_html_e( 'A verification code has been sent to the email address associated with your account.', 'two-factor' ); ?></p>
<p>
<label for="authcode"><?php esc_html_e( 'Verification Code:', 'two-factor' ); ?></label>
<input type="tel" name="two-factor-email-code" id="authcode" class="input" value="" size="20" />
<input type="text" inputmode="numeric" name="two-factor-email-code" id="authcode" class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="1234 5678" data-digits="8" />
<?php submit_button( __( 'Log In', 'two-factor' ) ); ?>
</p>
<p class="two-factor-email-resend">
@ -313,13 +313,11 @@ class Two_Factor_Email extends Two_Factor_Provider {
* @return boolean
*/
public function validate_authentication( $user ) {
if ( ! isset( $user->ID ) || ! isset( $_REQUEST['two-factor-email-code'] ) ) {
$code = $this->sanitize_code_from_request( 'two-factor-email-code' );
if ( ! isset( $user->ID ) || ! $code ) {
return false;
}
// Ensure there are no spaces or line breaks around the code.
$code = trim( sanitize_text_field( $_REQUEST['two-factor-email-code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, handled by the core method already.
return $this->validate_token( $user->ID, $code );
}

View File

@ -89,7 +89,7 @@ abstract class Two_Factor_Provider {
* @param string|array $chars Valid auth code characters.
* @return string
*/
public function get_code( $length = 8, $chars = '1234567890' ) {
public static function get_code( $length = 8, $chars = '1234567890' ) {
$code = '';
if ( is_array( $chars ) ) {
$chars = implode( '', $chars );
@ -99,4 +99,27 @@ abstract class Two_Factor_Provider {
}
return $code;
}
/**
* Sanitizes a numeric code to be used as an auth code.
*
* @param string $field The _REQUEST field to check for the code.
* @param int $length The valid expected length of the field.
* @return false|string Auth code on success, false if the field is not set or not expected length.
*/
public static function sanitize_code_from_request( $field, $length = 0 ) {
if ( empty( $_REQUEST[ $field ] ) ) {
return false;
}
$code = wp_unslash( $_REQUEST[ $field ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, handled by the core method already.
$code = preg_replace( '/\s+/', '', $code );
// Maybe validate the length.
if ( $length && strlen( $code ) !== $length ) {
return false;
}
return (string) $code;
}
}

View File

@ -11,25 +11,18 @@
class Two_Factor_Totp extends Two_Factor_Provider {
/**
* The user meta token key.
* The user meta key for the TOTP Secret key.
*
* @var string
*/
const SECRET_META_KEY = '_two_factor_totp_key';
/**
* The user meta token key.
* The user meta key for the last successful TOTP token timestamp logged in with.
*
* @var string
*/
const NOTICES_META_KEY = '_two_factor_totp_notices';
/**
* Action name for resetting the secret token.
*
* @var string
*/
const ACTION_SECRET_DELETE = 'totp-delete';
const LAST_SUCCESSFUL_LOGIN_META_KEY = '_two_factor_totp_last_successful_login';
const DEFAULT_KEY_BIT_SIZE = 160;
const DEFAULT_CRYPTO = 'sha1';
@ -44,20 +37,6 @@ class Two_Factor_Totp extends Two_Factor_Provider {
*/
private static $base_32_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Class constructor. Sets up hooks, etc.
*
* @codeCoverageIgnore
*/
protected function __construct() {
add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) );
add_action( 'personal_options_update', array( $this, 'user_two_factor_options_update' ) );
add_action( 'edit_user_profile_update', array( $this, 'user_two_factor_options_update' ) );
add_action( 'two_factor_user_settings_action', array( $this, 'user_settings_action' ), 10, 2 );
return parent::__construct();
}
/**
* Ensures only one instance of this class exists in memory at any one time.
*
@ -71,6 +50,75 @@ class Two_Factor_Totp extends Two_Factor_Provider {
return $instance;
}
/**
* Class constructor. Sets up hooks, etc.
*
* @codeCoverageIgnore
*/
protected function __construct() {
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) );
return parent::__construct();
}
/**
* Register the rest-api endpoints required for this provider.
*
* @codeCoverageIgnore
*/
public function register_rest_routes() {
register_rest_route(
Two_Factor_Core::REST_NAMESPACE,
'/totp',
array(
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'rest_delete_totp' ),
'permission_callback' => function( $request ) {
return current_user_can( 'edit_user', $request['user_id'] );
},
'args' => array(
'user_id' => array(
'required' => true,
'type' => 'number',
),
),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_setup_totp' ),
'permission_callback' => function( $request ) {
return current_user_can( 'edit_user', $request['user_id'] );
},
'args' => array(
'user_id' => array(
'required' => true,
'type' => 'number',
),
'key' => array(
'type' => 'string',
'default' => '',
'validate_callback' => null, // Note: validation handled in ::rest_setup_totp().
),
'code' => array(
'type' => 'string',
'default' => '',
'validate_callback' => null, // Note: validation handled in ::rest_setup_totp().
),
'enable_provider' => array(
'required' => false,
'type' => 'boolean',
'default' => false,
),
),
),
)
);
}
/**
* Returns the name of the provider.
*/
@ -79,32 +127,137 @@ class Two_Factor_Totp extends Two_Factor_Provider {
}
/**
* Trigger our custom user settings actions.
*
* @param integer $user_id User ID.
* @param string $action Action ID.
*
* @return void
* Enqueue scripts
*
* @codeCoverageIgnore
*/
public function user_settings_action( $user_id, $action ) {
if ( self::ACTION_SECRET_DELETE === $action ) {
$this->delete_user_totp_key( $user_id );
}
public function enqueue_assets( $hook_suffix ) {
$environment_prefix = file_exists( TWO_FACTOR_DIR . '/dist' ) ? '/dist' : '';
wp_register_script(
'two-factor-qr-code-generator',
plugins_url( $environment_prefix . '/includes/qrcode-generator/qrcode.js', __DIR__ ),
array(),
TWO_FACTOR_VERSION,
true
);
}
/**
* Get the URL for deleting the secret token.
* Rest API endpoint for handling deactivation of TOTP.
*
* @param integer $user_id User ID.
* @param WP_Rest_Request $request The Rest Request object.
* @return array Success array.
*/
public function rest_delete_totp( $request ) {
$user_id = $request['user_id'];
$user = get_user_by( 'id', $user_id );
$this->delete_user_totp_key( $user_id );
ob_start();
$this->user_two_factor_options( $user );
$html = ob_get_clean();
return [
'success' => true,
'html' => $html,
];
}
/**
* REST API endpoint for setting up TOTP.
*
* @param WP_Rest_Request $request The Rest Request object.
* @return WP_Error|array Array of data on success, WP_Error on error.
*/
public function rest_setup_totp( $request ) {
$user_id = $request['user_id'];
$user = get_user_by( 'id', $user_id );
$key = $request['key'];
$code = preg_replace( '/\s+/', '', $request['code'] );
if ( ! $this->is_valid_key( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid Two Factor Authentication secret key.', 'two-factor' ), array( 'status' => 400 ) );
}
if ( ! $this->is_valid_authcode( $key, $code ) ) {
return new WP_Error( 'invalid_key_code', __( 'Invalid Two Factor Authentication code.', 'two-factor' ), array( 'status' => 400 ) );
}
if ( ! $this->set_user_totp_key( $user_id, $key ) ) {
return new WP_Error( 'db_error', __( 'Unable to save Two Factor Authentication code. Please re-scan the QR code and enter the code provided by your application.', 'two-factor' ), array( 'status' => 500 ) );
}
if ( $request->get_param( 'enable_provider' ) && ! Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Totp' ) ) {
return new WP_Error( 'db_error', __( 'Unable to enable TOTP provider for this user.', 'two-factor' ), array( 'status' => 500 ) );
}
ob_start();
$this->user_two_factor_options( $user );
$html = ob_get_clean();
return [
'success' => true,
'html' => $html,
];
}
/**
* Generates a URL that can be used to create a QR code.
*
* @param WP_User $user The user to generate a URL for.
* @param string $key The secret key.
*
* @return string
*
* @codeCoverageIgnore
*/
protected function get_token_delete_url_for_user( $user_id ) {
return Two_Factor_Core::get_user_update_action_url( $user_id, self::ACTION_SECRET_DELETE );
public static function generate_qr_code_url( $user, $secret_key ) {
$issuer = get_bloginfo( 'name', 'display' );
/**
* Filter the Issuer for the TOTP.
*
* Must follow the TOTP format for a "issuer". Do not URL Encode.
*
* @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format#issuer
* @param string $issuer The issuer for TOTP.
*/
$issuer = apply_filters( 'two_factor_totp_issuer', $issuer );
/**
* Filter the Label for the TOTP.
*
* Must follow the TOTP format for a "label". Do not URL Encode.
*
* @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format#label
* @param string $totp_title The label for the TOTP.
* @param WP_User $user The User object.
* @param string $issuer The issuer of the TOTP. This should be the prefix of the result.
*/
$totp_title = apply_filters( 'two_factor_totp_title', $issuer . ':' . $user->user_login, $user, $issuer );
$totp_url = add_query_arg(
array(
'secret' => rawurlencode( $secret_key ),
'issuer' => rawurlencode( $issuer ),
),
'otpauth://totp/' . rawurlencode( $totp_title )
);
/**
* Filter the TOTP generated URL.
*
* Must follow the TOTP format. Do not URL Encode.
*
* @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format
* @param string $totp_url The TOTP URL.
* @param WP_User $user The user object.
*/
$totp_url = apply_filters( 'two_factor_totp_url', $totp_url, $user );
$totp_url = esc_url( $totp_url, array( 'otpauth' ) );
return $totp_url;
}
/**
@ -120,96 +273,145 @@ class Two_Factor_Totp extends Two_Factor_Provider {
return false;
}
wp_nonce_field( 'user_two_factor_totp_options', '_nonce_user_two_factor_totp_options', false );
$key = $this->get_user_totp_key( $user->ID );
$this->admin_notices( $user->ID );
wp_enqueue_script( 'two-factor-qr-code-generator' );
?>
<div id="two-factor-totp-options">
<?php
if ( empty( $key ) ) :
$key = $this->generate_key();
$site_name = get_bloginfo( 'name', 'display' );
$totp_title = apply_filters( 'two_factor_totp_title', $site_name . ':' . $user->user_login, $user );
$key = $this->generate_key();
$totp_url = $this->generate_qr_code_url( $user, $key );
?>
<p>
<?php esc_html_e( 'Please scan the QR code or manually enter the key, then enter an authentication code from your app in order to complete setup.', 'two-factor' ); ?>
</p>
<p>
<img src="<?php echo esc_url( $this->get_google_qr_code( $totp_title, $key, $site_name ) ); ?>" id="two-factor-totp-qrcode" />
<p id="two-factor-qr-code">
<a href="<?php echo $totp_url; ?>">
Loading...
<img src="<?php echo esc_url( admin_url( 'images/spinner.gif' ) ); ?>" alt="" />
</a>
</p>
<style>
#two-factor-qr-code {
/* The size of the image will change based on the length of the URL inside it. */
min-width: 205px;
min-height: 205px;
}
</style>
<script>
(function(){
var qr_generator = function() {
/*
* 0 = Automatically select the version, to avoid going over the limit of URL
* length.
* L = Least amount of error correction, because it's not needed when scanning
* on a monitor, and it lowers the image size.
*/
var qr = qrcode( 0, 'L' );
qr.addData( <?php echo wp_json_encode( $totp_url ); ?> );
qr.make();
document.querySelector( '#two-factor-qr-code a' ).innerHTML = qr.createSvgTag( 5 );
};
// Run now if the document is loaded, otherwise on DOMContentLoaded.
if ( document.readyState === 'complete' ) {
qr_generator();
} else {
window.addEventListener( 'DOMContentLoaded', qr_generator );
}
})();
</script>
<p>
<code><?php echo esc_html( $key ); ?></code>
</p>
<p>
<input type="hidden" name="two-factor-totp-key" value="<?php echo esc_attr( $key ); ?>" />
<input type="hidden" id="two-factor-totp-key" name="two-factor-totp-key" value="<?php echo esc_attr( $key ); ?>" />
<label for="two-factor-totp-authcode">
<?php esc_html_e( 'Authentication Code:', 'two-factor' ); ?>
<input type="tel" name="two-factor-totp-authcode" id="two-factor-totp-authcode" class="input" value="" size="20" pattern="[0-9]*" />
<?php
/* translators: Example auth code. */
$placeholder = sprintf( __( 'eg. %s', 'two-factor' ), '123456' );
?>
<input type="tel" name="two-factor-totp-authcode" id="two-factor-totp-authcode" class="input" value="" size="20" pattern="[0-9 ]*" placeholder="<?php echo esc_attr( $placeholder ); ?>" />
</label>
<input type="submit" class="button" name="two-factor-totp-submit" value="<?php esc_attr_e( 'Submit', 'two-factor' ); ?>" />
<input type="submit" class="button totp-submit" name="two-factor-totp-submit" value="<?php esc_attr_e( 'Submit', 'two-factor' ); ?>" />
</p>
<script>
(function($){
$('.totp-submit').click( function( e ) {
e.preventDefault();
var key = $('#two-factor-totp-key').val(),
code = $('#two-factor-totp-authcode').val();
wp.apiRequest( {
method: 'POST',
path: <?php echo wp_json_encode( Two_Factor_Core::REST_NAMESPACE . '/totp' ); ?>,
data: {
user_id: <?php echo wp_json_encode( $user->ID ); ?>,
key: key,
code: code,
}
} ).fail( function( response, status ) {
var errorMessage = response.responseJSON.message || status,
$error = $( '#totp-setup-error' );
if ( ! $error.length ) {
$error = $('<div class="error" id="totp-setup-error"><p></p></div>').insertAfter( $('.totp-submit') );
}
$error.find('p').text( errorMessage );
$('#two-factor-totp-authcode').val('');
} ).then( function( response ) {
$( '#two-factor-totp-options' ).html( response.html );
} );
} );
})(jQuery);
</script>
<?php else : ?>
<p class="success">
<?php esc_html_e( 'Secret key is configured and registered. It is not possible to view it again for security reasons.', 'two-factor' ); ?>
</p>
<p>
<a class="button" href="<?php echo esc_url( self::get_token_delete_url_for_user( $user->ID ) ); ?>"><?php esc_html_e( 'Reset Key', 'two-factor' ); ?></a>
<a class="button reset-totp-key" href="#"><?php esc_html_e( 'Reset Key', 'two-factor' ); ?></a>
<em class="description">
<?php esc_html_e( 'You will have to re-scan the QR code on all devices as the previous codes will stop working.', 'two-factor' ); ?>
</em>
<script>
( function( $ ) {
$( 'a.reset-totp-key' ).click( function( e ) {
e.preventDefault();
wp.apiRequest( {
method: 'DELETE',
path: <?php echo wp_json_encode( Two_Factor_Core::REST_NAMESPACE . '/totp' ); ?>,
data: {
user_id: <?php echo wp_json_encode( $user->ID ); ?>,
}
} ).then( function( response ) {
$( '#two-factor-totp-options' ).html( response.html );
} );
} );
} )( jQuery );
</script>
</p>
<?php endif; ?>
</div>
<?php
}
/**
* Save the options specified in `::user_two_factor_options()`
*
* @param integer $user_id The user ID whose options are being updated.
*
* @return void
*
* @codeCoverageIgnore
*/
public function user_two_factor_options_update( $user_id ) {
$notices = array();
$errors = array();
if ( isset( $_POST['_nonce_user_two_factor_totp_options'] ) ) {
check_admin_referer( 'user_two_factor_totp_options', '_nonce_user_two_factor_totp_options' );
// Validate and store a new secret key.
if ( ! empty( $_POST['two-factor-totp-authcode'] ) && ! empty( $_POST['two-factor-totp-key'] ) ) {
// Don't use filter_input() because we can't mock it during tests for now.
$authcode = filter_var( sanitize_text_field( $_POST['two-factor-totp-authcode'] ), FILTER_SANITIZE_NUMBER_INT );
$key = sanitize_text_field( $_POST['two-factor-totp-key'] );
if ( $this->is_valid_key( $key ) ) {
if ( $this->is_valid_authcode( $key, $authcode ) ) {
if ( ! $this->set_user_totp_key( $user_id, $key ) ) {
$errors[] = __( 'Unable to save Two Factor Authentication code. Please re-scan the QR code and enter the code provided by your application.', 'two-factor' );
}
} else {
$errors[] = __( 'Invalid Two Factor Authentication code.', 'two-factor' );
}
} else {
$errors[] = __( 'Invalid Two Factor Authentication secret key.', 'two-factor' );
}
}
if ( ! empty( $errors ) ) {
$notices['error'] = $errors;
}
if ( ! empty( $notices ) ) {
update_user_meta( $user_id, self::NOTICES_META_KEY, $notices );
}
}
}
/**
* Get the TOTP secret key for a user.
*
@ -241,6 +443,7 @@ class Two_Factor_Totp extends Two_Factor_Provider {
* @return boolean If the key was deleted successfully.
*/
public function delete_user_totp_key( $user_id ) {
delete_user_meta( $user_id, self::LAST_SUCCESSFUL_LOGIN_META_KEY );
return delete_user_meta( $user_id, self::SECRET_META_KEY );
}
@ -261,68 +464,74 @@ class Two_Factor_Totp extends Two_Factor_Provider {
return false;
}
/**
* Display any available admin notices.
*
* @param integer $user_id User ID.
*
* @return void
*
* @codeCoverageIgnore
*/
public function admin_notices( $user_id ) {
$notices = get_user_meta( $user_id, self::NOTICES_META_KEY, true );
if ( ! empty( $notices ) ) {
delete_user_meta( $user_id, self::NOTICES_META_KEY );
foreach ( $notices as $class => $messages ) {
?>
<div class="<?php echo esc_attr( $class ); ?>">
<?php
foreach ( $messages as $msg ) {
?>
<p>
<span><?php echo esc_html( $msg ); ?><span>
</p>
<?php
}
?>
</div>
<?php
}
}
}
/**
* Validates authentication.
*
* @param WP_User $user WP_User object of the logged-in user.
*
* @return bool Whether the user gave a valid code
*
* @codeCoverageIgnore
*/
public function validate_authentication( $user ) {
if ( ! empty( $_REQUEST['authcode'] ) ) {
return $this->is_valid_authcode(
$this->get_user_totp_key( $user->ID ),
sanitize_text_field( $_REQUEST['authcode'] )
);
$code = $this->sanitize_code_from_request( 'authcode', self::DEFAULT_DIGIT_COUNT );
if ( ! $code ) {
return false;
}
return false;
return $this->validate_code_for_user( $user, $code );
}
/**
* Checks if a given code is valid for a given key, allowing for a certain amount of time drift
* Validates an authentication code for a given user, preventing re-use and older TOTP keys.
*
* @param WP_User $user WP_User object of the logged-in user.
* @param int $code The TOTP token to validate.
*
* @return bool Whether the code is valid for the user and a newer code has not been used.
*/
public function validate_code_for_user( $user, $code ) {
$valid_timestamp = $this->get_authcode_valid_ticktime(
$this->get_user_totp_key( $user->ID ),
$code
);
if ( ! $valid_timestamp ) {
return false;
}
$last_totp_login = (int) get_user_meta( $user->ID, self::LAST_SUCCESSFUL_LOGIN_META_KEY, true );
// The TOTP authentication is not valid, if we've seen the same or newer code.
if ( $last_totp_login && $last_totp_login >= $valid_timestamp ) {
return false;
}
update_user_meta( $user->ID, self::LAST_SUCCESSFUL_LOGIN_META_KEY, $valid_timestamp );
return true;
}
/**
* Checks if a given code is valid for a given key, allowing for a certain amount of time drift.
*
* @param string $key The share secret key to use.
* @param string $authcode The code to test.
*
* @return bool Whether the code is valid within the time frame
* @return bool Whether the code is valid within the time frame.
*/
public static function is_valid_authcode( $key, $authcode ) {
return (bool) self::get_authcode_valid_ticktime( $key, $authcode );
}
/**
* Checks if a given code is valid for a given key, allowing for a certain amount of time drift.
*
* @param string $key The share secret key to use.
* @param string $authcode The code to test.
*
* @return false|int Returns the timestamp of the authcode on success, False otherwise.
*/
public static function get_authcode_valid_ticktime( $key, $authcode ) {
/**
* Filter the maximum ticks to allow when checking valid codes.
*
@ -340,14 +549,16 @@ class Two_Factor_Totp extends Two_Factor_Provider {
$ticks = range( - $max_ticks, $max_ticks );
usort( $ticks, array( __CLASS__, 'abssort' ) );
$time = time() / self::DEFAULT_TIME_STEP_SEC;
$time = floor( time() / self::DEFAULT_TIME_STEP_SEC );
foreach ( $ticks as $offset ) {
$log_time = $time + $offset;
if ( hash_equals(self::calc_totp( $key, $log_time ), $authcode ) ) {
return true;
if ( hash_equals( self::calc_totp( $key, $log_time ), $authcode ) ) {
// Return the tick timestamp.
return $log_time * self::DEFAULT_TIME_STEP_SEC;
}
}
return false;
}
@ -429,27 +640,6 @@ class Two_Factor_Totp extends Two_Factor_Provider {
return str_pad( $code, $digits, '0', STR_PAD_LEFT );
}
/**
* Uses the Google Charts API to build a QR Code for use with an otpauth url
*
* @param string $name The name to display in the Authentication app.
* @param string $key The secret key to share with the Authentication app.
* @param string $title The title to display in the Authentication app.
*
* @return string A URL to use as an img src to display the QR code
*
* @codeCoverageIgnore
*/
public static function get_google_qr_code( $name, $key, $title = null ) {
// Encode to support spaces, question marks and other characters.
$name = rawurlencode( $name );
$google_url = urlencode( 'otpauth://totp/' . $name . '?secret=' . $key );
if ( isset( $title ) ) {
$google_url .= urlencode( '&issuer=' . rawurlencode( $title ) );
}
return 'https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl=' . $google_url;
}
/**
* Whether this Two Factor provider is configured and available for the user specified.
*
@ -474,12 +664,12 @@ class Two_Factor_Totp extends Two_Factor_Provider {
public function authentication_page( $user ) {
require_once ABSPATH . '/wp-admin/includes/template.php';
?>
<p>
<p class="two-factor-prompt">
<?php esc_html_e( 'Please enter the code generated by your authenticator app.', 'two-factor' ); ?>
</p>
<p>
<label for="authcode"><?php esc_html_e( 'Authentication Code:', 'two-factor' ); ?></label>
<input type="tel" autocomplete="one-time-code" name="authcode" id="authcode" class="input" value="" size="20" pattern="[0-9]*" />
<input type="text" inputmode="numeric" autocomplete="one-time-code" name="authcode" id="authcode" class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="123 456" data-digits="<?php echo esc_attr( self::DEFAULT_DIGIT_COUNT ); ?>" />
</p>
<script type="text/javascript">
setTimeout( function(){

View File

@ -1,10 +1,10 @@
=== Two-Factor ===
Contributors: georgestephanis, valendesigns, stevenkword, extendwings, sgrant, aaroncampbell, johnbillion, stevegrunwell, netweb, kasparsd, alihusnainarshad, passoniate
Tags: two factor, two step, authentication, login, totp, fido u2f, u2f, email, backup codes, 2fa, yubikey
Contributors: georgestephanis, valendesigns, stevenkword, extendwings, sgrant, aaroncampbell, johnbillion, stevegrunwell, netweb, kasparsd, alihusnainarshad, passoniate
Tags: two factor, two step, authentication, login, totp, fido u2f, u2f, email, backup codes, 2fa, yubikey
Requires at least: 4.3
Tested up to: 6.0
Requires PHP: 5.6
Stable tag: 0.7.3
Tested up to: 6.0
Requires PHP: 5.6
Stable tag: 0.8.1
Enable Two-Factor Authentication using time-based one-time passwords (OTP, Google Authenticator), Universal 2nd Factor (FIDO U2F, YubiKey), email and backup verification codes.
@ -29,16 +29,24 @@ Here is a list of action and filter hooks provided by the plugin:
- `two_factor_user_authenticated` action which receives the logged in `WP_User` object as the first argument for determining the logged in user right after the authentication workflow.
- `two_factor_token_ttl` filter overrides the time interval in seconds that an email token is considered after generation. Accepts the time in seconds as the first argument and the ID of the `WP_User` object being authenticated.
== Frequently Asked Questions ==
= How can I send feedback or get help with a bug? =
The best place to report bugs, feature suggestions, or any other (non-security) feedback is at <a href="https://github.com/WordPress/two-factor/issues">the Two Factor GitHub issues page</a>. Before submitting a new issue, please search the existing issues to check if someone else has reported the same feedback.
= Where can I report security bugs? =
The plugin contributors and WordPress community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, please visit the [WordPress HackerOne](https://hackerone.com/wordpress) program.
== Screenshots ==
1. Two-factor options under User Profile.
2. U2F Security Keys section under User Profile.
3. Email Code Authentication during WordPress Login.
== Get Involved ==
Development happens [on GitHub](https://github.com/wordpress/two-factor/).
== Changelog ==
See the [release history](https://github.com/wordpress/two-factor/releases).

View File

@ -9,12 +9,12 @@
*
* @wordpress-plugin
* Plugin Name: Two Factor
* Plugin URI: https://wordpress.org/plugins/two-factor/
* Plugin URI: https://wordpress.org/plugins/two-factor/
* Description: Two-Factor Authentication using time-based one-time passwords, Universal 2nd Factor (FIDO U2F), email and backup verification codes.
* Author: Plugin Contributors
* Version: 0.7.3
* Author URI: https://github.com/wordpress/two-factor/graphs/contributors
* Network: True
* Author: Plugin Contributors
* Version: 0.8.1
* Author URI: https://github.com/wordpress/two-factor/graphs/contributors
* Network: True
* Text Domain: two-factor
*/
@ -26,7 +26,7 @@ define( 'TWO_FACTOR_DIR', plugin_dir_path( __FILE__ ) );
/**
* Version of the plugin.
*/
define( 'TWO_FACTOR_VERSION', '0.7.3' );
define( 'TWO_FACTOR_VERSION', '0.8.1' );
/**
* Include the base class here, so that other plugins can also extend it.