database (Vigilante_Database),
* $this->policy() (the two_factor settings array) and log_event().
*
* @package Vigilante
* @since 2.11.0
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Trait Vigilante_Two_Factor_Session
*/
trait Vigilante_Two_Factor_Session {
/**
* The second factor mechanics in force, resolved once per request
*
* The method, the expiry and the grace period come from the main site on a
* network, so they are the same wherever the login arrives. Read here and not
* in the constructor because the constructors run on init on EVERY request of
* every site: reading the main site's settings there meant a switch_to_blog()
* and the whole autoloaded option set of the main site on every front page
* view of every subsite, measured at 318 rows and 89 KB by the third cross
* review of 2.11.10. Nothing outside a login needs this value.
*
* Whether an account NEEDS a second factor, and which class asks for it, are
* separate questions with their own answers: two_factor_required_for() and
* two_factor_handler_for().
*
* @since 2.11.10
*
* @return array
*/
protected function policy() {
if ( null === $this->options ) {
$this->options = Vigilante_Settings::two_factor_policy();
}
return $this->options;
}
/**
* Whether this class is the one that must ask this account for its factor
*
* @since 2.11.10
* @since 2.11.11 The enrolment can be passed in by a caller that has just read it.
*
* @param WP_User $user User being authenticated.
* @param string $method Method this class implements, 'email' or 'totp'.
* @param bool|null $enrolled Whether the account has a TOTP enrolment, when the
* caller already read its row. On a network that read
* can search every site the account belongs to, and
* the dashboard hooks run on every screen.
* @return bool
*/
protected function handles_second_factor( $user, $method, $enrolled = null ) {
if ( null === $enrolled ) {
$enrolled = $this->database && method_exists( $this->database, 'has_totp_enrolment' )
? $this->database->has_totp_enrolment( $user->ID )
: false;
}
return ( $method === Vigilante_Settings::two_factor_handler_for( $user, $enrolled ) );
}
/**
* Whether the verification pending in this request belongs to this class
*
* Both second factor classes hang off login_form_vigilante_2fa and login_form
* since 2.11.10, so without this the two of them printed a form on the same
* page and both tried to verify the same code. Measured as "the second factor
* is asked for twice" by the release matrix. The same election as the
* authenticate filter, so a given pending session is handled start to finish
* by one class.
*
* @since 2.11.10
*
* @param string $method Method this class implements, 'email' or 'totp'.
* @return bool True also when there is nothing pending, so each class goes on
* applying its own rules.
*/
protected function pending_belongs_to( $method ) {
$user_id = $this->get_pending_user_id();
if ( ! $user_id ) {
return true;
}
$user = get_userdata( $user_id );
return $user ? $this->handles_second_factor( $user, $method ) : true;
}
/**
* User ID authenticated through an application password in this request, or 0.
*
* Set by the core action application_password_did_authenticate, which only
* fires when the credentials were an application password. That is a second
* factor of its own, so the interactive verification does not apply (S16).
*
* @var int
*/
private $app_password_user_id = 0;
// =========================================================================
// Names
// =========================================================================
/**
* Cookie carrying the pending-verification token.
*
* @return string
*/
private function pending_cookie_name() {
return 'vigilante_2fa_token';
}
/**
* Cookie carrying the trusted-device secret.
*
* @return string
*/
private function device_cookie_name() {
return 'vigilante_2fa_device';
}
// =========================================================================
// Request context (S16)
// =========================================================================
/**
* Register the hook that flags application-password logins.
*
* Called from the module's init_hooks().
*/
protected function init_session_hooks() {
add_action( 'application_password_did_authenticate', array( $this, 'remember_app_password_user' ) );
// Why the verification session ended, explained on the login screen it
// sends the visitor back to. Both classes use the trait, so both
// register this; the notice itself prints once (see the method).
add_filter( 'login_message', array( $this, 'show_2fa_session_notice' ) );
}
/**
* Explain on the login screen why a verification session ended
*
* Until 2.11.12 running out of verification attempts cleared the pending
* session and redirected to wp-login.php with no message at all: the visitor
* was back at the password form with no idea why, typed the password again,
* and that correct password was counted as one more failed login.
*
* The query argument only picks one of the literal strings below. It decides
* nothing and it is not trusted for anything (rule 21): anyone can add it to
* a URL, and all it can produce is one of these notices on a login screen.
*
* @since 2.11.12
*
* @param string $message Login screen message so far.
* @return string
*/
public function show_2fa_session_notice( $message ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display of a static notice on the login screen; nothing is decided or written.
$notice = isset( $_GET['vigilante_2fa_notice'] ) ? sanitize_key( wp_unslash( $_GET['vigilante_2fa_notice'] ) ) : '';
if ( '' === $notice ) {
return $message;
}
// Both two-factor classes use this trait and both register the filter,
// so without this the notice would print twice on a site that has them
// both loaded. A trait gives each using class its own statics, hence the
// prefixed global rather than a static property.
if ( ! empty( $GLOBALS['vigilante_2fa_notice_printed'] ) ) {
return $message;
}
$texts = array(
'attempts' => __( 'Too many incorrect verification codes. The verification session was closed for security. Log in again to start a new one.', 'vigilante' ),
'expired' => __( 'The verification session expired. Log in again to start a new one.', 'vigilante' ),
);
if ( ! isset( $texts[ $notice ] ) ) {
return $message;
}
$GLOBALS['vigilante_2fa_notice_printed'] = true;
return $message . '
' . esc_html( $texts[ $notice ] ) . '
';
}
/**
* Send the visitor back to the login screen with an explanation
*
* @since 2.11.12
*
* @param string $notice One of the keys of show_2fa_session_notice().
* @return void
*/
private function redirect_to_login_with_notice( $notice ) {
wp_safe_redirect( add_query_arg( 'vigilante_2fa_notice', rawurlencode( $notice ), wp_login_url() ) );
exit;
}
/**
* Remember which user authenticated with an application password.
*
* @param WP_User $user Authenticated user.
*/
public function remember_app_password_user( $user ) {
if ( $user instanceof WP_User ) {
$this->app_password_user_id = (int) $user->ID;
}
}
/**
* Whether this user was authenticated with an application password in this request.
*
* @param WP_User|mixed $user User being authenticated.
* @return bool
*/
private function authenticated_with_app_password( $user ) {
return $user instanceof WP_User
&& $this->app_password_user_id > 0
&& (int) $user->ID === $this->app_password_user_id;
}
/**
* Whether the request comes through REST or XML-RPC, where no form can be shown.
*
* @return bool
*/
private function is_api_request() {
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return true;
}
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
return true;
}
return false;
}
/**
* The error returned to an API login that still needs its second factor.
*
* No pending session is created and no code is sent: a connector that
* retries with the main password used to trigger one email per attempt.
*
* @return WP_Error
*/
private function api_requires_2fa_error() {
// A controlled rejection, not a wrong password: the credentials were
// right and the account simply needs its second factor. The error code
// is what keeps it out of the brute force count; see
// Vigilante_Login_Security::CONTROLLED_REJECTIONS.
return new WP_Error(
'vigilante_2fa_required',
__( 'This account requires two-factor authentication. Log in from a browser, or use an application password for API access.', 'vigilante' )
);
}
// =========================================================================
// Pending verification session (S3, S15)
// =========================================================================
/**
* Set pending verification state
*
* The token travels only in an HttpOnly cookie (and in the hidden field of
* the form the cookie holder is shown). There is no lookup by IP address:
* behind a proxy or a CDN that used to hand one user's pending session to
* whoever shared the apparent address (S3).
*
* @param int $user_id User ID.
* @return string Token for the pending session
*/
private function set_pending_verification( $user_id ) {
$user_id = absint( $user_id );
$token = $this->get_existing_token_for_user( $user_id );
$data = $token ? get_transient( 'vigilante_2fa_pending_' . $token ) : false;
if ( ! $token ) {
$token = wp_generate_password( 32, false );
}
// The attempt counter survives a fresh password login within the hour,
// so re-authenticating does not reset it (S2).
$attempts = ( is_array( $data ) && isset( $data['attempts'] ) ) ? absint( $data['attempts'] ) : 0;
// Where the login was headed. The verification form is a second request
// with its own POST, so redirect_to has to travel in the session or it
// is lost and every login lands on the dashboard (2.11.12). Kept from a
// previous pending session when this request does not carry one.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Not a decision: stored as-is and validated against the site by wp_validate_redirect() before use, in pending_login_redirect().
$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
if ( '' === $redirect_to && is_array( $data ) && ! empty( $data['redirect_to'] ) ) {
$redirect_to = (string) $data['redirect_to'];
}
set_transient(
'vigilante_2fa_pending_' . $token,
array(
'user_id' => $user_id,
'created_at' => time(),
'attempts' => $attempts,
'redirect_to' => $redirect_to,
),
HOUR_IN_SECONDS
);
// Reverse lookup (user_id -> token), used only to reuse the token on a
// repeated password login. It is never handed to a visitor.
set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
$this->set_cookie( $this->pending_cookie_name(), $token, time() + HOUR_IN_SECONDS, 'Strict' );
// Make the token available in the current request.
$_COOKIE[ $this->pending_cookie_name() ] = $token;
return $token;
}
/**
* Get the existing pending token for a user if still valid
*
* @param int $user_id User ID.
* @return string|false Token or false if not found
*/
private function get_existing_token_for_user( $user_id ) {
$token = get_transient( 'vigilante_2fa_user_token_' . absint( $user_id ) );
if ( ! $token ) {
return false;
}
$data = get_transient( 'vigilante_2fa_pending_' . $token );
if ( ! is_array( $data ) || empty( $data['user_id'] ) || absint( $data['user_id'] ) !== absint( $user_id ) ) {
return false;
}
return $token;
}
/**
* The pending token presented by this request, from the form or the cookie.
*
* @return string Token or empty string.
*/
private function get_pending_token() {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Session token, not form data: the verification form nonce is checked in handle_2fa_form() before anything acts on it.
if ( isset( $_POST['vigilante_2fa_token'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same token as the line above.
return sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) );
}
if ( isset( $_COOKIE[ $this->pending_cookie_name() ] ) ) {
return sanitize_text_field( wp_unslash( $_COOKIE[ $this->pending_cookie_name() ] ) );
}
return '';
}
/**
* The pending session presented by this request.
*
* @return array|false Session data (user_id, created_at, attempts, token) or false.
*/
private function get_pending_session() {
$token = $this->get_pending_token();
if ( '' === $token ) {
return false;
}
$data = get_transient( 'vigilante_2fa_pending_' . $token );
if ( ! is_array( $data ) || empty( $data['user_id'] ) ) {
return false;
}
$data['user_id'] = absint( $data['user_id'] );
$data['attempts'] = isset( $data['attempts'] ) ? absint( $data['attempts'] ) : 0;
$data['token'] = $token;
return $data;
}
/**
* Get pending verification user ID
*
* @return int|false User ID or false if not pending
*/
private function get_pending_user_id() {
$session = $this->get_pending_session();
return $session ? $session['user_id'] : false;
}
/**
* Where to send the visitor once the second factor is verified
*
* @since 2.11.12
*
* @return string URL on this site.
*/
private function pending_login_redirect() {
$session = $this->get_pending_session();
$stored = ( is_array( $session ) && ! empty( $session['redirect_to'] ) ) ? (string) $session['redirect_to'] : '';
if ( '' === $stored ) {
return admin_url();
}
// Same gate core uses: anything off this site falls back to the
// dashboard, so a stored value cannot send anyone off-site.
return wp_validate_redirect( $stored, admin_url() );
}
/**
* Failed attempts recorded on the pending session presented by this request.
*
* @return int
*/
private function get_pending_attempts() {
$session = $this->get_pending_session();
return $session ? $session['attempts'] : 0;
}
/**
* Record one more failed attempt on the pending session.
*
* @return int Attempts after the increment, or 0 if there is no session.
*/
private function increment_pending_attempts() {
$session = $this->get_pending_session();
if ( ! $session ) {
return 0;
}
$token = $session['token'];
unset( $session['token'] );
$session['attempts']++;
set_transient( 'vigilante_2fa_pending_' . $token, $session, HOUR_IN_SECONDS );
return $session['attempts'];
}
/**
* Clear pending verification
*/
private function clear_pending_verification() {
$token = $this->get_pending_token();
if ( '' !== $token ) {
$data = get_transient( 'vigilante_2fa_pending_' . $token );
if ( is_array( $data ) && ! empty( $data['user_id'] ) ) {
delete_transient( 'vigilante_2fa_user_token_' . absint( $data['user_id'] ) );
}
delete_transient( 'vigilante_2fa_pending_' . $token );
}
$this->set_cookie( $this->pending_cookie_name(), '', time() - YEAR_IN_SECONDS, 'Strict' );
unset( $_COOKIE[ $this->pending_cookie_name() ] );
}
/**
* End a form submission whose nonce did not verify (S15).
*
* Until 2.11.0 this redirected to the login screen with no message and no
* record; the pending cookie was still set, so the form came back with no
* explanation. Now the holder of a pending session sees why and the
* attempt is logged. Either way the request ends here: a bare return would
* let wp-login.php fall through to wp_signon() without the second factor.
*
* @param int|false $user_id Pending user, if any.
*/
private function handle_invalid_nonce( $user_id ) {
if ( $user_id ) {
set_transient(
'vigilante_2fa_error_' . $user_id,
__( 'The verification form expired. Please try again.', 'vigilante' ),
60
);
$this->log_event( '2fa_nonce_failed', $user_id, __( 'Verification form submitted with an invalid or expired nonce', 'vigilante' ), 'warning' );
wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
exit;
}
wp_safe_redirect( wp_login_url() );
exit;
}
// =========================================================================
// Trusted devices (S1, S4)
// =========================================================================
/**
* Check if the current device is trusted
*
* The device presents a random secret from an HttpOnly cookie and only its
* SHA-256 is stored. Until 2.11.0 the identity was a hash of the User-Agent,
* so anyone with the password who reproduced the browser string skipped the
* second factor (S1). The option is enforced here as well: with it off no
* stored row is honoured, whatever the form sent (S4).
*
* @param int $user_id User ID.
* @return bool
*/
private function is_device_trusted( $user_id ) {
if ( empty( $this->policy()['allow_remember_device'] ) ) {
return false;
}
$token = $this->present_device_token();
if ( '' === $token ) {
return false;
}
return $this->database->is_device_trusted( absint( $user_id ), hash( 'sha256', $token ) );
}
/**
* Trust the current device
*
* Ignored silently when the option is off: a cached form may still send
* the checkbox, and that is no reason to refuse the login (S4).
*
* @param int $user_id User ID.
* @return bool True if a device row was written.
*/
private function trust_device( $user_id ) {
if ( empty( $this->policy()['allow_remember_device'] ) ) {
return false;
}
try {
$token = bin2hex( random_bytes( 32 ) );
} catch ( Exception $e ) {
return false;
}
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
if ( $remember_days < 1 ) {
$remember_days = 30;
}
$expires = time() + ( $remember_days * DAY_IN_SECONDS );
// The User-Agent is kept as a label for the device list only; it plays
// no part in recognising the device.
$written = $this->database->trust_device(
absint( $user_id ),
hash( 'sha256', $token ),
$user_agent,
gmdate( 'Y-m-d H:i:s', $expires )
);
if ( ! $written ) {
return false;
}
// Lax, not Strict: the cookie has to travel on the GET that brings the
// user back to wp-login.php from another site.
$this->set_cookie( $this->device_cookie_name(), $token, $expires, 'Lax' );
return true;
}
/**
* The device secret presented by this request, if well formed.
*
* @return string 64 hex characters or empty string.
*/
private function present_device_token() {
if ( ! isset( $_COOKIE[ $this->device_cookie_name() ] ) ) {
return '';
}
$token = sanitize_text_field( wp_unslash( $_COOKIE[ $this->device_cookie_name() ] ) );
return preg_match( '/^[0-9a-f]{64}$/', $token ) ? $token : '';
}
// =========================================================================
// Cookies
// =========================================================================
/**
* Set a plugin cookie with the attributes every 2FA cookie shares.
*
* @param string $name Cookie name.
* @param string $value Value (empty to clear).
* @param int $expires Expiry timestamp.
* @param string $samesite Lax or Strict.
*/
private function set_cookie( $name, $value, $expires, $samesite ) {
if ( headers_sent() ) {
return;
}
/*
* On a network these secrets used to travel to every site, while the rows
* that validate them carry the blog prefix and belong to one: a device
* trusted on one site handed its 64 hex secret to every other site,
* where a site administrator, or anything running there, could read it
* from the request and replay it.
*
* Both halves of the scope have to move, and the first attempt only moved
* one. An empty domain says "this host only", which isolates the sites of
* a network by subdomains; but in a network by subdirectories every site
* shares the host and the core leaves COOKIE_DOMAIN empty anyway
* (wp-includes/ms-default-constants.php sets it only for subdomain
* installs), so that change alone did nothing there. What separates those
* sites is the path. Found by the cross review of 2.11.10.
*
* So on a network the cookie is scoped to this site's own host and path,
* which is exactly the reach of the table that validates it. On a single
* site both come out as the core's own values and nothing changes.
*/
$domain = COOKIE_DOMAIN;
$path = COOKIEPATH;
if ( is_multisite() ) {
$domain = '';
$site_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$path = ( is_string( $site_path ) && '' !== $site_path ) ? $site_path : '/';
}
setcookie(
$name,
$value,
array(
'expires' => $expires,
'path' => $path,
'domain' => $domain,
'secure' => is_ssl(),
'httponly' => true,
'samesite' => $samesite,
)
);
}
}