feature = $feature; } /** * Entry point from login_form_adminkeep-two-factor. * * @return void */ public function handle() { $token = $this->pending_token(); $record = PendingLogin::load( $token ); if ( null === $record ) { $this->back_to_login(); } $errors = new WP_Error(); $notice = ''; $posted = $this->posted_action(); if ( '' !== $posted ) { // The nonce is tied to the pending-login token, so a form from another pending login // (another tab, an earlier attempt) is refused rather than acted on. A failure ends // the request in wp_die(). check_admin_referer( self::NONCE_ACTION . $token, self::NONCE_FIELD ); } if ( 'verify' === $posted ) { // Raw on purpose: the code is only ever compared by hash, never printed or stored. $input = isset( $_POST['code'] ) ? wp_unslash( $_POST['code'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- checked above. $trust = ! empty( $_POST['trust'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above. $result = $this->verify( $token, $input ); if ( $result['ok'] ) { $this->complete( $token, $record, $trust ); } if ( $result['locked'] ) { $this->locked( $token, $record, $result['error'] ); } if ( $result['discarded'] ) { $this->back_to_login(); } $errors->add( 'adminkeep_2fa', $result['error'] ); } elseif ( 'send' === $posted || 'resend' === $posted ) { $result = 'send' === $posted ? $this->send( $token ) : $this->resend( $token ); if ( $result['ok'] ) { $notice = $result['notice']; // The first send restarted the pending login's window; the cookie must last as long. if ( 'send' === $posted ) { TwoFactor::set_pending_cookie( $token ); } } elseif ( ! empty( $result['locked'] ) ) { $this->locked( $token, $record, $result['error'] ); } else { $errors->add( 'adminkeep_2fa', $result['error'] ); } } elseif ( '' === $record['code_hash'] && '' !== (string) $record['reason'] ) { // Reloaded after a send that did not go out, or arrived for an account with no usable // address: say so rather than leave the user waiting for an email that is not coming. $errors->add( 'adminkeep_2fa', $this->undelivered_message( $record ) ); } // A send may have stored or replaced the code; render what is stored now. $current = PendingLogin::load( $token ); $current = null === $current ? $record : $current; $this->render( $token, $current, $this->state( $current ), $errors, $notice ); exit; } /** * What the screen shows for a pending login. * * @param array $record The pending login. * @return string "code" once a code is out; "send" while the button can help, before the first * send or after one that failed; "stopped" when sending cannot work right now * (no usable address, or the send cap), so there is nothing to press. */ public function state( array $record ) { if ( '' !== (string) $record['code_hash'] ) { return 'code'; } $reason = isset( $record['reason'] ) ? (string) $record['reason'] : ''; return in_array( $reason, array( 'no_email', 'limit' ), true ) ? 'stopped' : 'send'; } /** * Email the first code, when the user presses "Email me the code". * * The same path as a resend, so the send cap and every failure reason apply. The resend wait * does not hold it back, because the wait only applies while a code is out. Once the first * email is away the pending login's window starts again, so the code gets its full ten minutes * however long the user sat on the screen first. A "send" posted after that is a resend: same * wait, same window, same notice. * * @param string $token The pending-login token. * @return array{ok: bool, error: string, notice: string, locked?: bool} */ public function send( $token ) { $result = $this->resend( $token ); if ( ! $result['ok'] ) { return $result; } if ( ! PendingLogin::restart_window( $token ) ) { return $result; } $record = PendingLogin::load( $token ); $user = null === $record ? false : get_user_by( 'id', $record['user_id'] ); $minutes = (int) ( PendingLogin::TTL / MINUTE_IN_SECONDS ); $result['notice'] = sprintf( /* translators: 1: masked email address, such as ow***@example.com, 2: minutes the code works for. */ _n( 'We emailed a code to %1$s. It works for %2$d minute.', 'We emailed a code to %1$s. It works for %2$d minutes.', $minutes, 'adminkeep' ), // login_header() prints this as HTML. $user ? esc_html( self::mask_email( $user->user_email ) ) : '', $minutes ); return $result; } /** * Check a code. * * A wrong code fires core's wp_login_failed, so plugins that limit login attempts count code * guessing the way they count password guessing. It also counts towards the user's lock * (Lockout), which spans all their pending logins; the wrong code that reaches the limit locks * the code step for the user and throws this pending login away. While the user is locked no * code is checked at all, so a pending login started before the lock cannot keep guessing. * * Codes are checked one at a time per user, under Lockout's mutex, so guesses fired in * parallel cannot all pass the lock check before any of them is counted. If another check is * running, this one is refused without being counted and the user is asked to try again. * * @param string $token The pending-login token. * @param mixed $input What was typed. * @return array{ok: bool, error: string, discarded: bool, locked: bool} */ public function verify( $token, $input ) { $record = PendingLogin::load( $token ); if ( null === $record ) { return $this->expired_result(); } $user_id = (int) $record['user_id']; if ( ! Lockout::acquire( $user_id ) ) { return array( 'ok' => false, 'error' => __( 'Your code could not be checked just now. Please try again.', 'adminkeep' ), 'discarded' => false, 'locked' => false, ); } try { $result = $this->check_code( $token, $input ); } finally { Lockout::release( $user_id ); } $wrong = $result['wrong']; unset( $result['wrong'] ); $user = $wrong ? get_user_by( 'id', $user_id ) : false; // Outside the mutex, so a slow listener does not hold up the user's next check. No login // name to report for an account that is gone; the attempt is counted anyway. if ( $user ) { /** This action is documented in wp-includes/pluggable.php */ do_action( 'wp_login_failed', $user->user_login, new WP_Error( 'adminkeep_two_factor_wrong_code', $result['error'] ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own hook, fired so plugins that limit login attempts see wrong codes as failed logins. } return $result; } /** * The part of verify() that runs under the user's mutex: the lock check, the counting and the * comparison. * * The guess is counted before it is compared, both on the pending login and on the user, and * stays counted unless it turns out right; a right code then clears the user's count rather * than taking one back. Counting first means a request can never be compared without having * been counted, whatever happens after. * * @param string $token The pending-login token. * @param mixed $input What was typed. * @return array{ok: bool, error: string, discarded: bool, locked: bool, wrong: bool} */ private function check_code( $token, $input ) { // Read again under the mutex, past this request's cached copy: a check that finished while // this one waited may have used the pending login up or counted a try, and writing back a // stale copy would undo that. $record = PendingLogin::load( $token, true ); if ( null === $record ) { return $this->expired_result() + array( 'wrong' => false ); } $user_id = (int) $record['user_id']; $until = Lockout::locked_until( $user_id ); if ( $until > 0 ) { PendingLogin::discard( $token ); return array( 'ok' => false, 'error' => self::locked_message( $until ), 'discarded' => true, 'locked' => true, 'wrong' => false, ); } $attempts = PendingLogin::record_attempt( $token, $record ); $locked = Lockout::note_wrong( $user_id ); // An empty hash means the email never left the site; nothing may match it, not even an // empty input hashed the same way. if ( '' !== $record['code_hash'] && ( new EmailCode() )->verify( $input, $record['code_hash'], $token ) ) { // Used up here, while the mutex is held, so a parallel post of the same code finds it // gone and cannot finish a second login. complete() works from the caller's copy. PendingLogin::discard( $token ); Lockout::clear( $user_id ); return array( 'ok' => true, 'error' => '', 'discarded' => false, 'locked' => false, 'wrong' => false, ); } $discarded = $locked || $attempts >= PendingLogin::MAX_ATTEMPTS; if ( $locked ) { PendingLogin::discard( $token ); $message = self::locked_message( Lockout::locked_until( $user_id ) ); } elseif ( $discarded ) { // Reached only if the user's count was lost (an object cache evicted it, or a right // code in another browser cleared it); the pending login's own five tries still hold. PendingLogin::discard( $token ); $message = __( 'Too many wrong codes. Please enter your password again.', 'adminkeep' ); } else { // Whichever runs out first: this pending login's tries, or the user's before the lock. $left = min( PendingLogin::MAX_ATTEMPTS - $attempts, Lockout::tries_left( $user_id ) ); $message = sprintf( /* translators: %d: attempts left. */ _n( 'That code is not right. %d try left.', 'That code is not right. %d tries left.', $left, 'adminkeep' ), $left ); } return array( 'ok' => false, 'error' => $message, 'discarded' => $discarded, 'locked' => $locked, 'wrong' => true, ); } /** * The answer verify() gives for a pending login that is gone. * * @return array{ok: bool, error: string, discarded: bool, locked: bool} */ private function expired_result() { return array( 'ok' => false, 'error' => $this->expired_message(), 'discarded' => true, 'locked' => false, ); } /** * Send a fresh code, no sooner than RESEND_AFTER seconds after the last one, and only while * the user's send cap (PendingLogin::MAX_SENDS) allows and the user is not locked out. * * @param string $token The pending-login token. * @return array{ok: bool, error: string, notice: string, locked?: bool} */ public function resend( $token ) { $record = PendingLogin::load( $token ); if ( null === $record ) { return array( 'ok' => false, 'error' => $this->expired_message(), 'notice' => '', ); } // The lock already threw away the pending login that reached it; this covers one started // before the lock, in another browser or tab. $until = Lockout::locked_until( $record['user_id'] ); if ( $until > 0 ) { return array( 'ok' => false, 'error' => self::locked_message( $until ), 'notice' => '', 'locked' => true, ); } // The wait applies only while a code is out. After a failed send there is nothing to wait // for, and making the user sit out the timer would only delay their retry. if ( '' !== $record['code_hash'] && time() - (int) $record['sent_at'] < PendingLogin::RESEND_AFTER ) { return array( 'ok' => false, 'error' => __( 'A code was sent a moment ago. Give it half a minute before asking for another.', 'adminkeep' ), 'notice' => '', ); } $user = get_user_by( 'id', $record['user_id'] ); if ( ! $user ) { PendingLogin::discard( $token ); return array( 'ok' => false, 'error' => $this->expired_message(), 'notice' => '', ); } // Neither refusal below touches a code already delivered: nothing new went out, so the // one in the user's inbox is still the right one. if ( ! is_email( $user->user_email ) ) { PendingLogin::set_reason( $token, 'no_email' ); return array( 'ok' => false, 'error' => $this->login_reason( 'no_email' ), 'notice' => '', ); } // The count is read, raised and written back, so sends for the same user are counted one at // a time under the user's mutex, the one code checks use, for just that step: the email // goes out after it is given back, so a slow mail server never holds up a code check. A // send that finds it busy counts nothing. if ( ! Lockout::acquire( $user->ID ) ) { return array( 'ok' => false, 'error' => __( 'Your code could not be sent just now. Please try again.', 'adminkeep' ), 'notice' => '', ); } try { $allowed = PendingLogin::note_send( $user->ID ); } finally { Lockout::release( $user->ID ); } if ( ! $allowed ) { PendingLogin::set_reason( $token, 'limit' ); return array( 'ok' => false, 'error' => $this->login_reason( 'limit' ), 'notice' => '', ); } $method = new EmailCode(); $code = $method->generate(); // Clear first: if the send fails, the old code must not stay valid either, and the new // one was never delivered. PendingLogin::replace_code( $token, '' ); if ( ! $method->send( $user, $code ) ) { PendingLogin::set_reason( $token, 'send_failed' ); return array( 'ok' => false, 'error' => $this->login_reason( 'send_failed' ), 'notice' => '', ); } PendingLogin::replace_code( $token, $method->hash( $code, $token ) ); return array( 'ok' => true, 'error' => '', 'notice' => __( 'A new code is on its way.', 'adminkeep' ), ); } /** * Finish the login the interrupt paused. * * Mirrors what wp_signon() and wp-login.php do after a good password: the auth cookie, the * wp_login action other plugins listen to, then the login_redirect filter and the redirect. * The pending login is thrown away first, so the same code can never finish a second login. * The redirect target was captured before the interrupt and goes through wp_safe_redirect(), * which refuses off-site hosts. * * @param string $token The pending-login token. * @param array $record The pending login. * @param bool $trust Whether to remember this device. * @return void */ public function complete( $token, array $record, $trust ) { PendingLogin::discard( $token ); // The right code proves the user holds the mailbox; earlier wrong codes and locks no longer // say anything about this account. Lockout::clear( (int) $record['user_id'] ); $user = get_user_by( 'id', $record['user_id'] ); if ( ! $user ) { $this->back_to_login(); } $remember = ! empty( $record['remember'] ); // This is the one place the plugin sets the session cookie itself, so it makes the // secure-cookie decision exactly as wp-login.php and wp_signon() do: force_ssl_admin(), // the user's own "use_ssl" option, then the secure_signon_cookie filter. Passing is_ssl() // alone would hand a plain cookie to a site that forces SSL for the admin, or ignore a // plugin that filters the decision. $secure_cookie = is_ssl() || force_ssl_admin(); if ( ! $secure_cookie && get_user_option( 'use_ssl', $user->ID ) ) { $secure_cookie = true; force_ssl_admin( true ); } /** This filter is documented in wp-includes/user.php */ $secure_cookie = apply_filters( 'secure_signon_cookie', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own filter, applied as wp_signon() would. $secure_cookie, array( 'user_login' => $user->user_login, 'user_password' => '', 'remember' => $remember, ) ); wp_set_auth_cookie( $user->ID, $remember, (bool) $secure_cookie ); wp_set_current_user( $user->ID ); $this->clear_pending_cookie(); $settings = $this->feature->settings(); if ( $trust && ! empty( $settings['trust_device'] ) ) { $days = isset( $settings['trust_days'] ) ? (int) $settings['trust_days'] : 30; $value = TrustedDevice::issue( $user->ID, $days ); // Headers are already out when PHPUnit drives this, and setcookie() would only warn. if ( ! headers_sent() ) { TrustedDevice::set_cookie( $value, $days ); } } /** This action is documented in wp-includes/user.php */ do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own hook, fired here because the sign-in finishes on our screen, so plugins that listen for logins still hear this one. $requested = isset( $record['redirect_to'] ) ? (string) $record['redirect_to'] : ''; $redirect_to = '' !== $requested ? $requested : admin_url(); /** This filter is documented in wp-login.php */ $redirect_to = apply_filters( 'login_redirect', $redirect_to, $requested, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own filter, applied as wp-login.php would, so redirects other plugins set still apply. // Inside the admin's "session expired" window there is nowhere to go: the window waits // for a page that says the login worked, then closes itself. After the filter, as in // wp-login.php, so a plugin listening on it still hears this login. if ( ! empty( $record['interim'] ) ) { $this->render_interim_success(); } // Nobody asked for a place, so the same defaults as wp-login.php: a user with no site of // their own on a network goes to their own admin, one who cannot write goes to their // profile, and one who cannot even read the admin goes to the front page. These are // addresses the site made, not ones the request carried, so core sends to them unchecked; // on a subdomain network the user admin is on another host, which wp_safe_redirect() would refuse. if ( '' === $redirect_to || 'wp-admin/' === $redirect_to || admin_url() === $redirect_to ) { if ( is_multisite() && ! get_active_blog_for_user( $user->ID ) && ! is_super_admin( $user->ID ) ) { $redirect_to = user_admin_url(); } elseif ( is_multisite() && ! $user->has_cap( 'read' ) ) { $redirect_to = get_dashboard_url( $user->ID ); } elseif ( ! $user->has_cap( 'edit_posts' ) ) { $redirect_to = $user->has_cap( 'read' ) ? admin_url( 'profile.php' ) : home_url(); } wp_redirect( $redirect_to ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- every address above comes from the site itself, as in wp-login.php. exit; } wp_safe_redirect( $redirect_to ); exit; } /** * The page the admin's "session expired" window waits for: core's own success message in the * login chrome, whose body class tells the window's script to close it. The same markup as * wp-login.php prints for an interim login, since that script is what reads it. * * A method of its own so the tests, which cannot call login_header(), can stand in for it. * * @return void */ protected function render_interim_success() { // login_header() reads this global for the body classes; wp-login.php sets it the same way. $GLOBALS['interim_login'] = 'success'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- core's own flag for its login chrome, set as wp-login.php sets it. login_header( '', '

' . esc_html__( 'You have logged in successfully.', 'adminkeep' ) . '

' ); echo ''; /** This action is documented in wp-login.php */ do_action( 'login_footer' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own hook, fired as wp-login.php does on this page. echo ''; exit; } /** * Print the screen inside the login chrome. * * @param string $token The pending-login token. * @param array $record The pending login. * @param string $state What to show (see state()), or "locked". * @param WP_Error $errors Errors to show. login_header() prints them as HTML, so only our own * fixed sentences may go in. * @param string $notice A success line to show. * @return void */ private function render( $token, array $record, $state, WP_Error $errors, $notice ) { if ( '' !== $notice ) { $errors->add( 'adminkeep_2fa_sent', $notice, 'message' ); } $title = 'code' === $state ? __( 'Enter your login code', 'adminkeep' ) : __( 'Two-factor login', 'adminkeep' ); $focus = array( 'code' => 'adminkeep-2fa-code', 'send' => 'wp-submit', ); login_header( $title, '', $errors ); $this->form( $token, $record, $state ); // The id makes core's footer script focus the code field, or the send button before it. login_footer( isset( $focus[ $state ] ) ? $focus[ $state ] : '' ); } /** * The screen's own markup, between core's login header and footer. * * "send": one button that emails the code. "code": the code form; "Sign in" is its first submit * button, so pressing Enter in the code field signs in, and "Send a new code" sits under the * box with core's other links and joins the same form through its form attribute. "stopped" * and "locked": only the way back, since the message above says why there is nothing to press. * * @param string $token The pending-login token. * @param array $record The pending login. * @param string $state What to show (see state()), or "locked". * @return void */ public function form( $token, array $record, $state ) { $user = get_user_by( 'id', $record['user_id'] ); $settings = $this->feature->settings(); $masked = $user ? self::mask_email( $user->user_email ) : ''; if ( 'send' === $state ) : ?>

' . esc_html( $masked ) . '' ); ?>

print_sending_script(); elseif ( 'code' === $state ) : ?>

print_sending_script(); else : ?> clear_pending_cookie(); $this->render( $token, $record, 'locked', new WP_Error( 'adminkeep_2fa', $message ), '' ); exit; } /** * "Too many wrong codes. Try again in 15 minutes.", the minutes rounded up. Shared with the * password form, which says the same while the lock lasts. * * @param int $until When the lock ends, as a Unix timestamp. * @return string */ public static function locked_message( $until ) { $minutes = max( 1, (int) ceil( ( (int) $until - time() ) / MINUTE_IN_SECONDS ) ); return sprintf( /* translators: %d: minutes until the user may try again. */ _n( 'Too many wrong codes. Try again in %d minute.', 'Too many wrong codes. Try again in %d minutes.', $minutes, 'adminkeep' ), $minutes ); } /** * Drop the pending login and send the browser back to the password form. * * The reason cannot survive the redirect as text, so it travels as a known flag that * TwoFactor::expired_message() turns into the sentence on the password form. * * @return void */ private function back_to_login() { PendingLogin::discard( $this->pending_token() ); $this->clear_pending_cookie(); wp_safe_redirect( add_query_arg( 'adminkeep_2fa', 'expired', wp_login_url() ) ); exit; } /** * Expire the pending-login cookie in the browser. * * @return void */ private function clear_pending_cookie() { // Headers are already out when PHPUnit drives this, and setcookie() would only warn. if ( headers_sent() ) { return; } // The same path the interrupt set it on: 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. A // cookie is only cleared on the path it was set on. setcookie( TwoFactor::PENDING_COOKIE, '', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); } /** * The pending-login token from the cookie, or '' when there is none usable. * * @return string */ private function pending_token() { if ( ! isset( $_COOKIE[ TwoFactor::PENDING_COOKIE ] ) || ! is_string( $_COOKIE[ TwoFactor::PENDING_COOKIE ] ) ) { return ''; } // Raw on purpose: PendingLogin accepts only 32 lowercase hex characters as a token. return (string) wp_unslash( $_COOKIE[ TwoFactor::PENDING_COOKIE ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } /** * Which action was posted, or '' for none. * * @return string */ private function posted_action() { // The nonce is checked by handle() as soon as this returns something; reading the marker // first is what tells a plain visit from a post that must carry one. if ( ! isset( $_POST[ self::FIELD ] ) || ! is_string( $_POST[ self::FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return ''; } return sanitize_key( wp_unslash( $_POST[ self::FIELD ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing } /** * The message for a pending login that is gone. * * @return string */ private function expired_message() { return __( 'Your login code has expired. Please enter your password again.', 'adminkeep' ); } /** * The sentence for a pending login with no code delivered, picked by why none went out. * * @param array $record The pending login. * @return string */ public function undelivered_message( array $record ) { return $this->login_reason( isset( $record['reason'] ) ? (string) $record['reason'] : '' ); } /** * The login screen's sentence for a reason no code went out: the shared wording plus the * fact that matters on this screen. * * @param string $reason "no_email", "limit" or "send_failed". * @return string */ private function login_reason( $reason ) { return self::reason_message( $reason ) . ' ' . __( 'You are not signed in.', 'adminkeep' ); } /** * Why no code went out, in words, without the sentence login_reason() adds for this screen. * * An unknown or empty reason reads as a failed send, the case every record before reasons * existed was in. * * @param string $reason "no_email", "limit" or "send_failed". * @return string */ public static function reason_message( $reason ) { if ( 'no_email' === $reason ) { return __( 'The code could not be sent because this account has no valid email address. Ask an administrator to fix the email address on your profile.', 'adminkeep' ); } if ( 'limit' === $reason ) { return __( 'This account has reached the limit of login codes for now. Wait ten minutes, then try again.', 'adminkeep' ); } return __( 'The code could not be emailed. This site may not be able to send email; the SMTP feature in Adminkeep can fix that.', 'adminkeep' ); } /** * "ow***@example.com", so the screen confirms which address without printing it whole. * * @param string $email The address. * @return string */ public static function mask_email( $email ) { $at = strpos( $email, '@' ); if ( false === $at ) { return '***'; } $local = substr( $email, 0, $at ); $keep = min( 2, strlen( $local ) ); return substr( $local, 0, $keep ) . '***' . substr( $email, $at ); } }