array( 'type' => 'array', 'default' => array( 'administrator' ), 'ui' => 'roles', ), 'trust_device' => array( 'type' => 'bool', 'default' => true, ), 'trust_days' => array( 'type' => 'int', 'default' => 30, ), ); } /** * Field labels. Render-time only. * * @param string $key Field key. * @return string */ public function field_label( $key ) { $labels = array( 'roles' => __( 'Ask these roles for a code (none checked = nobody is asked)', 'adminkeep' ), 'trust_device' => __( 'Offer "Remember this device" on the code screen', 'adminkeep' ), 'trust_days' => __( 'Days a remembered device skips the code', 'adminkeep' ), ); return isset( $labels[ $key ] ) ? $labels[ $key ] : ''; } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'After the password is accepted on the login screen, the user is not signed in yet. A second screen shows the email address on their account, partly hidden, and a button that emails a six-digit code there; nothing is sent until it is pressed. The code works once, for up to ten minutes from when it is sent, and the login attempt ends 30 minutes after the password at the latest. At most five code emails go to one user in ten minutes, however often the password is typed.', 'adminkeep' ), __( 'Five wrong codes lock the code step for that account for 15 minutes, doubling each time it happens again, up to six hours. Typing the password again does not get round it. A device remembered earlier is not affected, and the right code clears the count.', 'adminkeep' ), __( 'The code is sent with wp_mail() in the Nice Default Emails layout, so the SMTP feature delivers it. If the site cannot send email, the screen says so instead of signing the user in.', 'adminkeep' ), __( '"Remember this device" keeps a token in that browser so it skips the code for the number of days set here. The token is stored hashed; a copied cookie does not work for another user. Changing or resetting a password forgets every device that user remembered.', 'adminkeep' ), __( 'The code is asked for on the main login screen. Other login forms, such as WooCommerce\'s My Account or a custom form, refuse these users and point them to the main login screen. A plugin that only moves the login screen to a different address is fine. If another plugin replaces the login screen with its own form, the code screen cannot be reached there, so keep the recovery email. The REST API, application passwords and XML-RPC are not asked for a code; Disable XML-RPC is its own feature.', 'adminkeep' ), __( 'Switching this on emails you a recovery link. Locked out, because email is down or the code screen cannot be reached? Open that link: it turns Two-Factor Login off without logging in, works once, and is replaced each time this is switched on. Or run the WP-CLI command wp adminkeep feature disable two_factor, or define the constant ADMINKEEP_DISABLE_TWO_FACTOR as true in wp-config.php.', 'adminkeep' ), ); } /** * A word for what the feature is doing. * * @return string */ public function state_label() { if ( self::disabled_by_constant() ) { return __( 'Off by constant', 'adminkeep' ); } $count = count( (array) $this->settings_value( 'roles' ) ); return sprintf( /* translators: %s: number of roles. */ _n( '%s role', '%s roles', $count, 'adminkeep' ), number_format_i18n( $count ) ); } /** * Register hooks. * * @param array $settings Resolved settings. * @return void */ public function boot( array $settings ) { $this->settings = $settings; // The constant is the way back in when email is down, so it must win over the stored // "on" without anyone reaching the database. if ( self::disabled_by_constant() ) { return; } // Last, after every other check has passed, so a user another plugin refuses never gets a code. add_filter( 'authenticate', array( $this, 'interrupt' ), PHP_INT_MAX, 3 ); $screen = new TwoFactorScreen( $this ); add_action( 'login_form_' . self::ACTION, array( $screen, 'handle' ) ); // The code screen sends people back to the password form with a flag on the URL, because // a message cannot survive a redirect any other way; this turns the flag into the sentence. add_filter( 'wp_login_errors', array( $this, 'expired_message' ) ); // The recovery link opens on admin-ajax.php (see RecoveryLink::url() for why). Whoever // opens it is usually signed out, but an administrator on a remembered device may be // signed in, so both actions. Registered here and not for a feature that is off: a link // exists only while the feature is on. $recovery = new TwoFactorRecovery( $this ); add_action( 'wp_ajax_nopriv_' . RecoveryLink::ACTION, array( $recovery, 'handle' ) ); add_action( 'wp_ajax_' . RecoveryLink::ACTION, array( $recovery, 'handle' ) ); // A new password forgets every remembered browser, so each one is asked for a code again. // Not wp_set_password: core also calls that at login to rehash an unchanged password, // which must forget nothing. add_action( 'after_password_reset', array( $this, 'forget_devices_after_reset' ) ); add_action( 'profile_update', array( $this, 'forget_devices_on_password_change' ), 10, 2 ); } /** * After a password reset through the "Lost your password?" link: forget the user's devices. * * @param WP_User $user The user whose password was reset. * @return void */ public function forget_devices_after_reset( $user ) { if ( $user instanceof WP_User ) { $this->forget_user( $user->ID ); } } /** * What a new password forgets: the remembered devices, and the count of code emails sent in * the current window. The cap exists so that someone holding the password cannot fill the * owner's inbox; while it is reached the owner gets no code either. A reset goes through the * mailbox, which is what a code proves, so the owner need not sit the window out after one. * * @param int $user_id The user. * @return void */ private function forget_user( $user_id ) { TrustedDevice::revoke_all( $user_id ); PendingLogin::clear_sends( $user_id ); } /** * After a profile save, from the profile screen, WP-CLI or any other wp_update_user() caller: * forget the user's devices when the save changed the password, and only then. * * Compared by the stored hash. Core hashes a password only when one was posted, so an * unchanged hash means the password was not touched. * * @param int $user_id The user. * @param WP_User $old_user_data The user as they were before the save. * @return void */ public function forget_devices_on_password_change( $user_id, $old_user_data = null ) { $user = get_userdata( $user_id ); if ( ! $user || ! $old_user_data instanceof WP_User ) { return; } if ( ! hash_equals( (string) $old_user_data->user_pass, (string) $user->user_pass ) ) { $this->forget_user( $user->ID ); } } /** * Called by SettingsWriter when this feature is switched on or off, by any door. * * On: a new recovery link is emailed, replacing the one before. Nothing is asked first and the * save does not wait on the result; whether the email went out is noted for the settings * screen. Off: the link is forgotten, since there is nothing left for it to switch off and an * old email must not work against a later switch-on. * * @param bool $on The new state. * @return void */ public function enabled_changed( $on ) { if ( ! $on ) { RecoveryLink::revoke(); return; } RecoveryLink::send( $this->recovery_recipient(), RecoveryLink::issue(), $this->asked_role_names() ); } /** * Who gets the recovery link: the person switching the feature on, since they are the one at * risk of locking themselves out and are still signed in to see whether it arrives. WP-CLI * usually runs as nobody, so there it goes to the site's administration address. * * @return string An email address, or '' when neither has a usable one. */ private function recovery_recipient() { $user = wp_get_current_user(); if ( $user->exists() && is_email( $user->user_email ) ) { return $user->user_email; } $admin_email = (string) get_option( 'admin_email' ); return is_email( $admin_email ) ? $admin_email : ''; } /** * Display names of the roles that owe a code, for the recovery email. * * Read from the stored settings, not from boot(): the feature being switched on in this * request was off when the request started, so it was never booted. * * @return string[] */ private function asked_role_names() { $stored = Settings::get( $this->id() ); $roles = isset( $stored['roles'] ) && is_array( $stored['roles'] ) ? $stored['roles'] : $this->settings_schema()['roles']['default']; $known = wp_roles()->get_names(); $names = array(); foreach ( array_map( 'strval', $roles ) as $role ) { $names[] = isset( $known[ $role ] ) ? translate_user_role( $known[ $role ] ) : $role; } return $names; } /** * A line for this feature's panel on the settings screen: where the current recovery link * went, or that it could not be sent. * * @return array{text: string, tone: string}|null Null when there is nothing to say. */ public function panel_notice() { $record = Settings::is_enabled( $this->id() ) ? RecoveryLink::record() : null; if ( null === $record ) { return null; } if ( ! $record['sent'] ) { return array( 'text' => __( 'The recovery link could not be emailed. This site may not be sending email; fix that before you log out.', 'adminkeep' ), 'tone' => 'warning', ); } $masked = TwoFactorScreen::mask_email( $record['email'] ); // Just sent: the person who switched it on is still here and can check their inbox. Later // the same line would read as if something had just happened, so it becomes a plain // record of where the link is, for whoever needs it months on. if ( time() - $record['time'] < HOUR_IN_SECONDS ) { return array( /* translators: %s: partly hidden email address. */ 'text' => sprintf( __( 'A recovery link was emailed to %s. If it does not arrive, fix email before you log out.', 'adminkeep' ), $masked ), 'tone' => 'info', ); } return array( 'text' => sprintf( /* translators: 1: partly hidden email address, 2: date. */ __( 'The current recovery link was emailed to %1$s on %2$s.', 'adminkeep' ), $masked, wp_date( get_option( 'date_format' ), $record['time'] ) ), 'tone' => 'info', ); } /** * The "start again" sentence on the password form. * * Only the one known flag value prints anything, and the text is our own, so the URL cannot * be used to put other words on the login screen. * * @param WP_Error $errors Core's login errors. * @return WP_Error */ public function expired_message( $errors ) { if ( $errors instanceof WP_Error && isset( $_GET['adminkeep_2fa'] ) && 'expired' === $_GET['adminkeep_2fa'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a display flag, nothing is changed. $errors->add( 'adminkeep_2fa', __( 'Your login code has expired or was entered wrongly too many times. Please enter your password again.', 'adminkeep' ) ); } return $errors; } /** * The escape hatch for a site whose email is down. * * @return bool */ public static function disabled_by_constant() { return defined( 'ADMINKEEP_DISABLE_TWO_FACTOR' ) && ADMINKEEP_DISABLE_TWO_FACTOR; } /** * Resolved settings, for the screen. * * @return array */ public function settings() { return $this->settings; } /** * Does this user owe a code right now? * * @param WP_User $user The user whose password was accepted. * @return bool */ public function requires_code( WP_User $user ) { $roles = array_map( 'strval', (array) $this->settings_value( 'roles' ) ); if ( ! $this->in_target_roles( $user, $roles ) ) { return false; } if ( $this->settings_value( 'trust_device' ) && isset( $_COOKIE[ TrustedDevice::COOKIE ] ) ) { // Raw on purpose: the value is a token, matched by hash, never printed or stored. if ( TrustedDevice::is_trusted( $user->ID, (string) wp_unslash( $_COOKIE[ TrustedDevice::COOKIE ] ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return false; } } return true; } /** * Is the user in one of the roles that owe a code? * * @param WP_User $user The user. * @param string[] $roles The configured roles. * @return bool */ private function in_target_roles( WP_User $user, array $roles ) { if ( array() !== array_intersect( $roles, (array) $user->roles ) ) { return true; } // On multisite, roles are per site and a super admin often has none on the site they log // in to, yet they hold more power than any administrator there. Asking administrators // for a code must therefore cover them too. return in_array( 'administrator', $roles, true ) && is_multisite() && is_super_admin( $user->ID ); } /** * The authenticate filter: a WP_User here means the password was right. * * Instead of returning the user (which signs them in), start a pending login and send the * browser to the code screen, where the user asks for the code to be emailed. The exit after the redirect is what keeps * wp-login.php from setting the cookie. * * Any other form that calls wp_signon(), such as WooCommerce's My Account, cannot show the * code screen. Letting it through would turn a stolen password into a full session, so it * gets an error pointing to wp-login.php instead. * * @param WP_User|WP_Error|null $user The result so far. * @param string $username Posted username. * @param string $password Posted password. * @return WP_User|WP_Error|null Unchanged when not interrupting. */ public function interrupt( $user, $username, $password ) { if ( ! $user instanceof WP_User ) { return $user; } // wp-login.php calls wp_signon() with no credentials on every request, a plain visit // included, and wp_authenticate_cookie turns a valid auth cookie into a WP_User. That is // an existing session, not an accepted password, and pausing it would mail a code to // everyone who is already signed in and opens the page. Empty credentials alone do not // prove it, though: a single sign-on or magic-link plugin hands its user over the same // way, and that login is as new as a password. Only the browser's own logged-in cookie // for this very user says the session was already open. if ( '' === (string) $username && '' === (string) $password && $this->session_is_open( $user ) ) { return $user; } if ( ! $this->requires_code( $user ) ) { return $user; } if ( $this->exempt_context() ) { return $user; } // Too many wrong codes recently: no pending login, no code, on any login form. The error // goes through core's wp_login_failed like any refused password, so login limiters see it. // A remembered device was let through above and never gets here. $until = Lockout::locked_until( $user->ID ); if ( $until > 0 ) { return new WP_Error( 'adminkeep_two_factor_locked', sprintf( /* translators: %s: "Too many wrong codes. Try again in 15 minutes." */ __( 'Error: %s', 'adminkeep' ), esc_html( TwoFactorScreen::locked_message( $until ) ) ) ); } if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) { return new WP_Error( 'adminkeep_two_factor_required', sprintf( /* translators: %s: URL of the main login screen. */ __( 'Error: Two-factor login is on for this account. Log in on the main login screen to get your code.', 'adminkeep' ), esc_url( wp_login_url() ) ) ); } // The pending login travels in a cookie and the browser is sent on with a redirect; once // output has started neither can be sent, and the code screen would only find nothing // and say the login had expired. Core refuses a password login the same way when its // cookies cannot be set, so this says what is wrong instead. if ( $this->output_started() ) { return new WP_Error( 'adminkeep_two_factor_output', __( 'Error: Two-factor login could not start because this site sent output before the login finished, so the code screen cannot be reached. Try again; if it keeps happening, a plugin or theme is printing something too early.', 'adminkeep' ) ); } $remember = ! empty( $_POST['rememberme'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- login form, core has no nonce here either. $redirect_to = isset( $_REQUEST['redirect_to'] ) ? wp_unslash( $_REQUEST['redirect_to'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by wp_safe_redirect() when used. // The "session expired" window inside the admin logs in through wp-login.php too, with this // flag, and expects a small page it can close rather than a redirect. The flag rides along // in the record so the code screen can keep that promise. $interim = ! empty( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a display flag core reads the same way. // No code is sent here. It goes out when the user asks for it on the code screen, so a slow // mail server cannot hold up this redirect, and someone signing in to an account whose // mailbox belongs to another person can warn them first. The empty hash matches nothing // until then. An account with no usable address is noted now, so the screen can say so // straight away instead of offering a button that cannot work. $token = PendingLogin::create( $user->ID, $remember, is_string( $redirect_to ) ? $redirect_to : '', '', $interim ); if ( ! is_email( $user->user_email ) ) { PendingLogin::set_reason( $token, 'no_email' ); } $this->last_token = $token; self::set_pending_cookie( $token ); wp_safe_redirect( $this->screen_url( $interim ) ); exit; } /** * Is the user the one this browser is already signed in as? * * The logged-in cookie is the one core sets for the whole site, so it reaches wp-login.php; * the auth cookie is scoped to the admin and usually does not. * * @param WP_User $user The user the authenticate filters produced. * @return bool */ private function session_is_open( WP_User $user ) { return (int) $user->ID === (int) wp_validate_auth_cookie( '', 'logged_in' ); } /** * Has this request already started its output, so no cookie or redirect can be sent? * * A separate method because PHPUnit's own output makes headers_sent() true for every test; * the tests stand in for it, as they do for exempt_context(). * * @return bool */ protected function output_started() { return headers_sent(); } /** * Hand the browser the pending-login token, for a full window from now. * * Set at the password and again when the first code goes out, because that send restarts the * pending login's window and the cookie must last as long as the record. * * @param string $token The pending-login token. * @return void */ public static function set_pending_cookie( $token ) { // Headers are already out when PHPUnit drives this, and setcookie() would only warn. if ( headers_sent() ) { return; } // Only wp-login.php reads this cookie, and it lives under the site URL, whose path can differ from the home path that COOKIEPATH holds. setcookie( self::PENDING_COOKIE, $token, time() + PendingLogin::TTL, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); } /** * Is this a login that is not asked for a code at all? * * The REST API, XML-RPC and application passwords are machine logins: there is no screen to * show a code on, and refusing them would break integrations the site owner set up on * purpose. A separate method so tests can stand in for the constants, which a test process * cannot define and undefine. * * @return bool */ protected function exempt_context() { if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return true; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } // Core fires this only when an application password, not the account password, matched. return did_action( 'application_password_did_authenticate' ) > 0; } /** * The code screen. * * @param bool $interim Whether the login runs inside the admin's "session expired" window; * wp-login.php reads the flag off the request to draw its smaller frame. * @return string */ public function screen_url( $interim = false ) { $args = array( 'action' => self::ACTION ); if ( $interim ) { $args['interim-login'] = '1'; } return add_query_arg( $args, wp_login_url() ); } /** * The token the interrupt just created. * * @return string */ public function last_token() { return $this->last_token; } /** * A setting, with the schema default when the feature has not been booted. * * @param string $key Field key. * @return mixed */ private function settings_value( $key ) { if ( array_key_exists( $key, $this->settings ) ) { return $this->settings[ $key ]; } $schema = $this->settings_schema(); return isset( $schema[ $key ]['default'] ) ? $schema[ $key ]['default'] : null; } }