*/ protected function get_default_options() { return [ 'compulsory_roles' => [], 'email_otp_fallback' => 1, ]; } /** * Start the engine running * * @return void */ protected function init(): void { global $adminoptim_2fa_passed; $adminoptim_2fa_passed = false; $this->user_settings = new Two_Factor_Authentication_User_Settings(); $this->email_otp = new Two_Factor_Email_Otp(); $this->recovery = new Two_Factor_Recovery(); $this->settings = new Two_Factor_Authentication_Settings( $this->options ); add_action( 'wp_login', [ $this, 'maybe_add_2fa_login_page' ], 1, 2 ); add_action( 'login_form_validate_2fa', [ $this, 'validate_2fa_login_form' ] ); add_action( 'delete_user', [ self::class, 'delete_secret' ] ); add_action( 'admin_notices', [ $this, 'maybe_show_bypass_notice' ] ); add_action( 'admin_notices', [ $this, 'maybe_show_unreadable_secret_notice' ] ); add_filter( 'shake_error_codes', [ $this, 'add_shake_error_code' ] ); if ( ! empty( $this->options['add_user_2fa_column'] ) ) { add_filter( 'manage_users_columns', [ $this, 'add_user_2fa_column' ] ); add_action( 'manage_users_custom_column', [ $this, 'render_user_2fa_column' ], 10, 3 ); } if ( is_admin() ) { add_filter( Security_Monitor::SITE_HEALTH_ITEMS_FILTER, [ $this, 'add_site_health_item' ] ); } } /** * Most administrators the Site Health row will inspect. */ const HEALTH_ADMIN_LIMIT = 200; /** * Report this module's current state to Security Monitor's Site Health * tab: how many administrators have two-factor authentication set up. * Reads only this module's own user meta, through one bounded query on * administrators (never a full user scan). * * @param array $items Items collected so far. * * @return array */ public function add_site_health_item( $items ) { $items = is_array( $items ) ? $items : []; $ids = get_users( [ 'role' => 'administrator', 'fields' => 'ID', 'number' => self::HEALTH_ADMIN_LIMIT + 1, 'orderby' => 'ID', 'count_total' => false, ] ); $ids = is_array( $ids ) ? array_map( 'intval', $ids ) : []; $capped = count( $ids ) > self::HEALTH_ADMIN_LIMIT; $ids = array_slice( $ids, 0, self::HEALTH_ADMIN_LIMIT ); // One query primes the meta cache for every administrator at once. update_meta_cache( 'user', $ids ); $enrolled = 0; foreach ( $ids as $id ) { if ( self::is_user_enrolled( get_user_meta( $id, self::OPTION_NAME, true ) ) ) { ++$enrolled; } } $items[] = self::build_site_health_item( $enrolled, count( $ids ), $capped, self::get_module_admin_url() ); $bypass = ( $this->recovery ?? new Two_Factor_Recovery() )->bypass_status(); if ( 'off' !== $bypass['state'] ) { $items[] = [ 'id' => 'two-factor-authentication-bypass', 'label' => __( 'Two-Factor Authentication bypass', 'admin-optimizer' ), 'value' => implode( ', ', $bypass['users'] ), 'url' => self::get_module_admin_url(), 'link_label' => __( 'Open module', 'admin-optimizer' ), 'priority' => 19, 'status' => 'active' === $bypass['state'] ? 'critical' : 'warning', 'message' => 'active' === $bypass['state'] ? __( 'Two-factor authentication is switched off for these users by ADMINOPTIM_2FA_BYPASS in wp-config.php. Remove it once they are back in.', 'admin-optimizer' ) : __( 'ADMINOPTIM_2FA_BYPASS is still in wp-config.php but is expired or invalid. Remove it.', 'admin-optimizer' ), ]; } return $items; } /** * Whether new and upgraded TOTP secrets are saved to `AO_Secrets` (a reference in the user * meta) instead of as plain base32. * * Off by default for one release: an older plugin version would try to verify against the * reference text and fail every login. Reading references is always on. Also off on * multisite, where user meta is network-wide but secrets are per site. * * @return bool */ public static function use_secrets_store(): bool { return ! is_multisite() && (bool) apply_filters( 'adminoptim_2fa_store_secrets_as_secrets', false ); } /** * Name of the secret holding a user's TOTP secret. * * @param int $user_id User ID. * * @return string */ public static function secret_name( int $user_id ): string { return AO_Secrets::NAMESPACE_PREFIX . '/2fa-user-' . $user_id; } /** * The value to save as a user's `secret`: a reference when secrets storage is on and the * secret reads back identical, otherwise the plain secret unchanged (so enrolment never * fails because of the secret store). * * @param int $user_id User ID. * @param string $plain Base32 TOTP secret. * * @return string */ public static function store_secret( int $user_id, string $plain ): string { if ( ! self::use_secrets_store() || '' === $plain ) { return $plain; } $name = self::secret_name( $user_id ); if ( true !== AO_Secrets::set( $name, $plain ) ) { return $plain; } if ( AO_Secrets::get( $name ) !== $plain ) { AO_Secrets::delete( $name ); return $plain; } return AO_Secrets::reference( $name ); } /** * Delete a user's stored TOTP secret. Safe to call when there is none, and hooked to * `delete_user`; also called wherever the user's 2FA enrolment is reset. * * @param int $user_id User ID. * * @return void */ public static function delete_secret( $user_id ): void { $user_id = (int) $user_id; if ( $user_id > 0 ) { AO_Secrets::delete( self::secret_name( $user_id ) ); } } /** * Warn administrators, on every admin page, while the wp-config.php bypass is set (or was left * behind after it expired), so it cannot be forgotten. * * @return void */ public function maybe_show_bypass_notice(): void { $status = $this->recovery->bypass_status(); if ( 'off' === $status['state'] || ! current_user_can( 'manage_options' ) ) { return; } $users = implode( ', ', $status['users'] ); if ( 'active' === $status['state'] ) { $message = null === $status['until'] /* translators: %s: comma-separated users. */ ? sprintf( __( 'Two-factor authentication is switched off for %s by ADMINOPTIM_2FA_BYPASS in wp-config.php, with no end time. Remove it once they are back in.', 'admin-optimizer' ), $users ) /* translators: 1: comma-separated users, 2: date and time the bypass ends. */ : sprintf( __( 'Two-factor authentication is switched off for %1$s by ADMINOPTIM_2FA_BYPASS in wp-config.php until %2$s. Remove it once they are back in.', 'admin-optimizer' ), $users, wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $status['until'] ) ); } elseif ( 'expired' === $status['state'] ) { /* translators: %s: comma-separated users. */ $message = sprintf( __( 'ADMINOPTIM_2FA_BYPASS (for %s) has expired and no longer does anything. Remove it and ADMINOPTIM_2FA_BYPASS_UNTIL from wp-config.php.', 'admin-optimizer' ), $users ); } else { /* translators: %s: comma-separated users. */ $message = sprintf( __( 'ADMINOPTIM_2FA_BYPASS (for %s) is ignored because ADMINOPTIM_2FA_BYPASS_UNTIL is not a valid time. Fix or remove both constants in wp-config.php.', 'admin-optimizer' ), $users ); } printf( '

%s %s

', esc_html__( 'Two-factor bypass:', 'admin-optimizer' ), esc_html( $message ) ); } /** * Whether a user is enrolled but their saved authenticator secret can no longer be read (the * secret store's key changed), so the authenticator app no longer works and 2FA must be set up * again. * * @param int $user_id User ID. * * @return bool */ public static function is_secret_unreadable( int $user_id ): bool { $user_2fa = get_user_meta( $user_id, self::OPTION_NAME, true ); if ( ! self::is_user_enrolled( $user_2fa ) ) { return false; } return is_wp_error( self::get_totp_secret( $user_2fa ) ); } /** * Tell a signed-in user, on every admin page, that their authenticator no longer works and * needs setting up again (typically after the site's keys and salts were changed and they got * in with an emailed code). * * @return void */ public function maybe_show_unreadable_secret_notice(): void { $user_id = get_current_user_id(); if ( $user_id < 1 || ! self::is_secret_unreadable( $user_id ) ) { return; } printf( '

%1$s %2$s %4$s

', esc_html__( 'Two-factor authentication needs to be set up again.', 'admin-optimizer' ), esc_html__( 'Your saved authenticator can no longer be read, usually because the site\'s security keys were changed, so your authenticator app will not work at your next sign-in.', 'admin-optimizer' ), esc_url( get_edit_profile_url( $user_id ) . '#adminoptim-qrcode-wrap' ), esc_html__( 'Reset and set it up again on your profile.', 'admin-optimizer' ) ); } /** * The user's TOTP secret, ready for verification. * * A secret saved as a reference to `AO_Secrets` can become unreadable (the secret store's key * changed); that is returned as a WP_Error so the caller never verifies against an empty or * placeholder secret. A plain stored secret is returned as is. * * @param array $user_2fa The user's `adminoptim_2fa` meta. * * @return string|\WP_Error */ public static function get_totp_secret( array $user_2fa ) { $stored = $user_2fa['secret'] ?? ''; $name = AO_Secrets::name_from_reference( $stored ); if ( null === $name ) { return (string) $stored; } $value = AO_Secrets::get( $name ); if ( is_string( $value ) && '' !== $value ) { return $value; } return is_wp_error( $value ) ? $value : new \WP_Error( 'adminoptim_2fa_secret_missing', __( 'The authenticator secret is missing.', 'admin-optimizer' ) ); } /** * A user is enrolled once both `enabled` and `secret` are set. * * @param mixed $meta The user's `adminoptim_2fa` meta value. * * @return bool */ public static function is_user_enrolled( $meta ): bool { return is_array( $meta ) && ! empty( $meta['enabled'] ) && ! empty( $meta['secret'] ); } /** * Build the Site Health row. Pure, so the wording and status rules can be * tested without a database. Evaluated, not just reported: * * - every administrator enrolled: Good; * - any not enrolled: Recommended; * - more administrators than the query cap: Couldn't verify; * - no administrator found: Info. * * The wording says "have 2FA enabled", never "protected" or "secure": an * enrolled user is asked for a code on the login page, but this row does * not claim anything beyond that. * * @param int $enrolled Administrators enrolled. * @param int $total Administrators inspected. * @param bool $capped Whether more administrators exist than were inspected. * @param string $url Link to the module's own screen. * * @return array */ public static function build_site_health_item( int $enrolled, int $total, bool $capped, string $url ): array { $row = [ 'id' => 'two-factor-authentication', 'label' => __( 'Two-Factor Authentication', 'admin-optimizer' ), /* translators: 1: administrators enrolled, 2: administrators inspected. */ 'value' => sprintf( __( '%1$d of %2$d enrolled', 'admin-optimizer' ), $enrolled, $total ), 'url' => $url, 'link_label' => __( 'Open module', 'admin-optimizer' ), 'priority' => 20, ]; if ( $capped ) { $row['status'] = 'unverified'; /* translators: %d: administrator limit. */ $row['message'] = sprintf( __( 'More than %d administrators; not all were checked.', 'admin-optimizer' ), self::HEALTH_ADMIN_LIMIT ); $row['value'] = ''; } elseif ( 0 === $total ) { $row['status'] = 'info'; $row['message'] = __( 'No administrator accounts were found.', 'admin-optimizer' ); $row['value'] = ''; } elseif ( $enrolled >= $total ) { $row['status'] = 'ok'; /* translators: %d: number of administrators. */ $row['message'] = sprintf( _n( 'All %d administrator has 2FA enabled.', 'All %d administrators have 2FA enabled.', $total, 'admin-optimizer' ), $total ); } else { $missing = $total - $enrolled; $row['status'] = 'warning'; $row['message'] = 1 === $total ? __( "The administrator doesn't have 2FA enabled.", 'admin-optimizer' ) /* translators: 1: administrators without 2FA, 2: administrators. */ : sprintf( __( "%1\$d of %2\$d administrators don't have 2FA enabled.", 'admin-optimizer' ), $missing, $total ); } return $row; } /** * Add 2FA login page * * @param string $user_login User login name. * @param \WP_user $user User Object. * * @return void */ public function maybe_add_2fa_login_page( $user_login, $user ) { if ( isset( $user->adminoptim_twofa_validated ) && $user->adminoptim_twofa_validated ) { return; } if ( ! $this->is_login_page() ) { return; } // Emergency bypass set in wp-config.php for this user: sign in with the password alone, but tell the owner. if ( $this->recovery->bypass_applies_to( $user ) ) { $this->recovery->notify_bypass_used( $user ); do_action( 'adminoptim_security_monitor_log_event', [ 'source' => self::MODULE_SLUG, 'event_type' => '2fa_bypass_used', 'object' => (string) $user->user_login, 'user_id' => (int) $user->ID, 'severity' => 'flagged', 'detail' => [ 'ip' => AO_Request_Context::ip() ], ] ); return; } $user_2fa = get_user_meta( $user->ID, self::OPTION_NAME, true ); if ( empty( $user_2fa ) ) { $user_2fa = []; } if ( ! isset( $user_2fa['enabled'] ) ) { // Pro hooks in here to enforce compulsory-role login blocking. do_action( 'adminoptim_2fa_user_not_enrolled', $user, $user_2fa, $this->options ); return; } // Let Pro clear any stale compulsory-login block now that the user is enrolled. do_action( 'adminoptim_2fa_user_enrolled', $user, $user_2fa ); if ( $this->is_device_trusted( $user ) ) { return; } // ensure user cannot log in without a valid 2fa code. wp_clear_auth_cookie(); $mfa_token = $this->create_2fa_pending_token( $user->ID ); $redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? sanitize_url( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $rememberme = intval( ! empty( $_REQUEST['rememberme'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $can_add_recovery_checkbox = $this->user_can_use_recovery_code( $user ); $can_trust_device = $this->user_can_add_trust_device( $user ); // --- The authenticator secret cannot be read (the encryption key changed): offer an emailed code --- $email_otp_mode = false; if ( is_wp_error( self::get_totp_secret( $user_2fa ) ) ) { if ( Two_Factor_Email_Otp::is_enabled( $this->options ) ) { $email_otp_mode = true; $sent = $this->email_otp->send( $user ); if ( 'failed' === $sent ) { $email_otp_mode = false; $error = new \WP_Error( 'adminoptim_2fa_email_otp_failed', __( 'ERROR: We could not email you a code. Use a recovery code or ask an administrator to reset your two-factor authentication.', 'admin-optimizer' ) ); } else { $error = new \WP_Error( 'adminoptim_2fa_email_otp_sent', 'sent' === $sent ? __( 'Your authenticator could not be verified. Check your inbox for the one-time code.', 'admin-optimizer' ) : __( 'A one-time code was sent recently. Check your inbox, or try again in a few minutes.', 'admin-optimizer' ), 'message' ); } } else { $error = new \WP_Error( 'adminoptim_2fa_secret_unreadable', __( 'ERROR: Your two-factor authentication setup could not be verified. Use a recovery code or ask an administrator to reset it.', 'admin-optimizer' ) ); } } include 'includes/wp-2fa-login.php'; exit; } /** * Create a short-lived, single-use pending-2FA token bound to a user. * * @param int $user_id User ID that just passed the password check. * * @return string The opaque token to hand to the client. */ private function create_2fa_pending_token( $user_id ) { $token = bin2hex( random_bytes( 32 ) ); set_transient( self::PENDING_TOKEN_PREFIX . $token, $user_id, 5 * MINUTE_IN_SECONDS ); return $token; } /** * Resolve a pending-2FA token back to the user ID it was issued for. * * @param string $token Token supplied by the client. * * @return int User ID, or 0 if the token is missing/expired/invalid. */ private function get_user_id_from_pending_token( $token ) { if ( empty( $token ) || ! ctype_xdigit( $token ) ) { return 0; } $user_id = get_transient( self::PENDING_TOKEN_PREFIX . $token ); return $user_id ? absint( $user_id ) : 0; } /** * Invalidate a pending-2FA token so it cannot be replayed. * * @param string $token Token to invalidate. * * @return void */ private function delete_2fa_pending_token( $token ) { delete_transient( self::PENDING_TOKEN_PREFIX . $token ); } /** * Record a failed 2FA attempt for a pending token and check if the attempt cap was hit. * * Attempts are counted per pending token (not per user), so the counter always expires * together with the token it guards and never outlives or leaks across login attempts. * * @param string $token Pending-2FA token the attempt was made against. * * @return bool True if the token has now exceeded the allowed number of attempts. */ private function register_failed_2fa_attempt( $token ) { $key = self::ATTEMPTS_PREFIX . $token; $attempts = (int) get_transient( $key ); ++$attempts; set_transient( $key, $attempts, 5 * MINUTE_IN_SECONDS ); return $attempts >= self::MAX_ATTEMPTS; } /** * Clear the failed-attempt counter for a pending token. * * @param string $token Pending-2FA token to clear. * * @return void */ private function clear_2fa_attempts( $token ) { delete_transient( self::ATTEMPTS_PREFIX . $token ); } /** * Transient keys for the per-user and per-IP 2FA failure limiters. * * Unlike the per-token counter, these survive a restarted login (new pending token). * The IP comes from AO_Request_Context::ip(), which uses REMOTE_ADDR unless the site opts in * to trusting proxy headers (adminoptim_block_login_trust_proxy_headers filter). Behind a * reverse proxy that isn't marked as trusted, every visitor shares the proxy's address, so * the IP limiter then throttles them all together; the per-user limiter is unaffected. * * @param int $user_id User the 2FA attempt is for. * * @return string[] */ private function get_2fa_limit_keys( $user_id ) { $keys = [ self::LIMIT_PREFIX . 'user_' . $user_id ]; $ip = AO_Request_Context::ip(); if ( '' !== $ip ) { $keys[] = self::LIMIT_PREFIX . 'ip_' . md5( $ip ); } return $keys; } /** * Check whether the user or the client IP has used up its 2FA failure budget. * * @param int $user_id User the 2FA attempt is for. * * @return bool */ private function is_2fa_rate_limited( $user_id ) { foreach ( $this->get_2fa_limit_keys( $user_id ) as $key ) { $limit = get_transient( $key ); if ( is_array( $limit ) && $limit['expires'] > time() && $limit['count'] >= self::LIMIT_MAX_ATTEMPTS ) { return true; } } return false; } /** * Report a failed 2FA verification to Security Monitor's event log (Pro * Events tab). This module does the monitoring; Security Monitor only * stores what it is handed, and the action is a no-op when Security * Monitor Pro isn't active. * * @param \WP_User $user User whose code was wrong. * @param bool $locked_out True when this failure exhausted the login attempt's allowed tries. * * @return void */ private function report_failed_2fa( $user, bool $locked_out ): void { do_action( 'adminoptim_security_monitor_log_event', [ 'source' => self::MODULE_SLUG, 'event_type' => $locked_out ? '2fa_too_many_attempts' : '2fa_failed', 'object' => (string) $user->user_login, 'user_id' => (int) $user->ID, 'severity' => $locked_out ? 'flagged' : 'normal', 'detail' => [ 'ip' => AO_Request_Context::ip() ], ] ); } /** * Report a sign-in with an emailed code to Security Monitor's event log (a no-op when Security * Monitor Pro is not active). * * @param \WP_User $user User who signed in. * * @return void */ private function report_email_otp_used( $user ): void { do_action( 'adminoptim_security_monitor_log_event', [ 'source' => self::MODULE_SLUG, 'event_type' => '2fa_email_otp_used', 'object' => (string) $user->user_login, 'user_id' => (int) $user->ID, 'severity' => 'flagged', 'detail' => [ 'ip' => AO_Request_Context::ip() ], ] ); } /** * Count a failed 2FA attempt against the user and IP limiters (fixed LIMIT_WINDOW windows). * * @param int $user_id User the 2FA attempt was for. * * @return void */ private function register_failed_2fa_limits( $user_id ) { foreach ( $this->get_2fa_limit_keys( $user_id ) as $key ) { $limit = get_transient( $key ); if ( ! is_array( $limit ) || $limit['expires'] <= time() ) { $limit = [ 'count' => 0, 'expires' => time() + self::LIMIT_WINDOW, ]; } ++$limit['count']; set_transient( $key, $limit, max( 1, $limit['expires'] - time() ) ); } } /** * Replay-protection callbacks for ToTP::verify_code(), bound to a user. * * The last accepted TOTP time step is stored in user meta and any code whose step is not * newer is rejected, so each code works exactly once (across setup and login). * * @param int $user_id User the code is being verified for. * * @return callable[] [ $is_used_fn, $mark_used_fn ]. */ public static function get_totp_replay_callbacks( $user_id ) { return [ function ( $code, $step ) use ( $user_id ) { return $step <= (int) get_user_meta( $user_id, self::LAST_STEP_META, true ); }, function ( $code, $expires_at, $step ) use ( $user_id ) { update_user_meta( $user_id, self::LAST_STEP_META, $step ); }, ]; } /** * Validate the 2FA TOTP code * * @return void */ public function validate_2fa_login_form() { $mfa_token = ! empty( $_REQUEST['mfa_token'] ) ? sanitize_key( $_REQUEST['mfa_token'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? sanitize_url( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $use_recovery_code = ! empty( $_REQUEST['use_recovery_code'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $recovery_code = ( ! empty( $_REQUEST['recoverycode'] ) && self::is_recovery_code_valid( sanitize_text_field( wp_unslash( $_REQUEST['recoverycode'] ) ) ) ) ? strtoupper( trim( sanitize_text_field( wp_unslash( $_REQUEST['recoverycode'] ) ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $authcode = ( ! empty( $_REQUEST['authcode'] ) && self::is_code_valid( sanitize_text_field( wp_unslash( $_REQUEST['authcode'] ) ) ) ) ? trim( sanitize_text_field( wp_unslash( $_REQUEST['authcode'] ) ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $error = ''; $interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $mfa_token || ( ! $authcode && ! $recovery_code ) ) { return; } $user_id = $this->get_user_id_from_pending_token( $mfa_token ); $user = $user_id ? get_user_by( 'id', $user_id ) : false; if ( ! $user ) { // The pending-2FA token is missing, expired (5 minute TTL), or already used - e.g. the // login page was left open too long. Send the user back to wp-login.php to reauthenticate // rather than hard-failing with "Cheatin' uh?". wp_safe_redirect( wp_login_url() . '?reauth=1' ); exit; } if ( (int) get_transient( self::ATTEMPTS_PREFIX . $mfa_token ) >= self::MAX_ATTEMPTS ) { // Too many wrong codes against this login attempt - force a fresh login rather than // letting the token be used to keep brute-forcing the TOTP/recovery code. $this->delete_2fa_pending_token( $mfa_token ); $this->clear_2fa_attempts( $mfa_token ); wp_safe_redirect( wp_login_url() . '?reauth=1' ); exit; } $user_2fa = get_user_meta( $user->ID, self::OPTION_NAME, true ); if ( empty( $user_2fa ) ) { $user_2fa = []; } if ( ! isset( $user_2fa['enabled'] ) || ! isset( $user_2fa['secret'] ) ) { wp_die( esc_html__( 'Cheatin’ uh?', 'admin-optimizer' ) ); } $verified_login = false; $used_email_otp = false; $upgrade_secret = ''; if ( $this->is_2fa_rate_limited( $user->ID ) ) { // Too many recent failures for this user or IP: don't check the code at all and fall // through to the same generic error, without revealing which limit was hit. $verified_login = false; } elseif ( $use_recovery_code && ! empty( $recovery_code ) ) { // Recovery codes only count while the setting is on and the user has some. $user_recovery_codes = $this->user_can_use_recovery_code( $user ) ? $user_2fa['recovery_codes'] : []; if ( is_array( $user_recovery_codes ) && ! empty( $user_recovery_codes ) ) { foreach ( $user_recovery_codes as $key => $hashed_code ) { if ( wp_check_password( $recovery_code, $hashed_code, $user->ID ) ) { unset( $user_recovery_codes[ $key ] ); $user_2fa['recovery_codes'] = $user_recovery_codes; update_user_meta( $user->ID, self::OPTION_NAME, $user_2fa ); $verified_login = true; break; } } } } else { $secret = self::get_totp_secret( $user_2fa ); if ( is_wp_error( $secret ) ) { // The secret cannot be read: the only thing a typed code can match is the emailed one. if ( Two_Factor_Email_Otp::is_enabled( $this->options ) && $authcode && $this->email_otp->verify( $user, $authcode ) ) { $verified_login = true; $used_email_otp = true; } } else { $totp = new ToTP(); [ $is_used_fn, $mark_used_fn ] = self::get_totp_replay_callbacks( $user->ID ); if ( $totp->verify_code( $secret, $authcode, 30, 6, 1, 'sha1', $is_used_fn, $mark_used_fn ) ) { $verified_login = true; // A plain secret that just verified is known to work: move it into the secret store. if ( null === AO_Secrets::name_from_reference( $user_2fa['secret'] ) ) { $upgrade_secret = $secret; } } } } if ( $verified_login ) { if ( '' !== $upgrade_secret ) { $stored = self::store_secret( $user->ID, $upgrade_secret ); if ( $stored !== $upgrade_secret ) { $user_2fa['secret'] = $stored; update_user_meta( $user->ID, self::OPTION_NAME, $user_2fa ); } } if ( $used_email_otp ) { $this->email_otp->notify_used( $user ); $this->report_email_otp_used( $user ); } $this->delete_2fa_pending_token( $mfa_token ); $this->clear_2fa_attempts( $mfa_token ); // Only the user's limiter is reset: clearing the IP one would let anyone with a working // account at the same address wipe the counter for attempts on other accounts. delete_transient( self::LIMIT_PREFIX . 'user_' . $user->ID ); $rememberme = false; if ( isset( $_REQUEST['rememberme'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $rememberme = true; } wp_set_auth_cookie( $user->ID, $rememberme ); do_action( 'adminoptim_2fa_after_verification', $user ); $user->adminoptim_twofa_validated = true; do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound // Must be global because that's how login_header() uses it. global $interim_login; $interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited,WordPress.Security.NonceVerification.Recommended if ( $interim_login ) { $customize_login = isset( $_REQUEST['customize-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $customize_login ) { wp_enqueue_script( 'customize-base' ); } $message = '

' . __( 'You have logged in successfully.', 'admin-optimizer' ) . '

'; $interim_login = 'success'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited login_header( '', $message ); ?> setTimeout( function(){ new wp.customize.Messenger({ url: '', channel: 'login' }).send('login') }, 1000 ); register_failed_2fa_limits( $user->ID ); if ( $this->register_failed_2fa_attempt( $mfa_token ) ) { $this->delete_2fa_pending_token( $mfa_token ); $this->clear_2fa_attempts( $mfa_token ); do_action( 'wp_login_failed', $user->user_login, new \WP_Error( 'too_many_2fa_attempts', __( 'Too many failed attempts.', 'admin-optimizer' ) ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $this->report_failed_2fa( $user, true ); wp_safe_redirect( wp_login_url() . '?reauth=1' ); exit; } $error = new \WP_Error( 'invalid_2fa_authcode', __( 'ERROR: Invalid verification code.', 'admin-optimizer' ) ); do_action( 'wp_login_failed', $user->user_login, $error ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $this->report_failed_2fa( $user, false ); add_filter( 'login_errors', function () { return __( 'ERROR: Invalid verification code.', 'admin-optimizer' ); } ); $can_trust_device = $this->user_can_add_trust_device( $user ); $can_add_recovery_checkbox = $this->user_can_use_recovery_code( $user ); $email_otp_mode = is_wp_error( self::get_totp_secret( $user_2fa ) ) && Two_Factor_Email_Otp::is_enabled( $this->options ); include 'includes/wp-2fa-login.php'; } exit; } /** * Sanitize 2FA code * * @param int|string $code The code must be numeric and 6 digits. * * @return bool */ public static function is_code_valid( $code ): bool { if ( ! is_numeric( $code ) ) { return false; } elseif ( mb_strlen( $code ) !== 6 ) { return false; } else { return true; } } /** * Sanitize the recovery code * * @param string $code The recovery code must be alphanumeric and 10 characters (8 for codes issued before the length was raised). * * @return bool */ public static function is_recovery_code_valid( $code ): bool { if ( ! ctype_alnum( $code ) ) { return false; } elseif ( ! in_array( mb_strlen( $code ), [ 8, 10 ], true ) ) { return false; } else { return true; } } /** * Register the error code for the login form to "shake" * * @param array $shake_error_codes list of shake error codes. * * @return array */ public function add_shake_error_code( $shake_error_codes ) { if ( ! in_array( 'invalid_2fa_authcode', $shake_error_codes, true ) ) { $shake_error_codes[] = 'invalid_2fa_authcode'; } return $shake_error_codes; } /** * Check if the current user has set up recovery codes * * @param \WP_User $user WP User class. * * @return bool */ protected function user_can_use_recovery_code( $user ) { if ( isset( $this->options['enable_recovery_code'] ) ) { $user_2fa = get_user_meta( $user->ID, self::OPTION_NAME, true ); if ( empty( $user_2fa ) ) { $user_2fa = []; } if ( ! empty( $user_2fa['recovery_codes'] ) ) { return true; } } return false; } /** * Check whether user can add trust device option. Free has no trusted-device * concept of its own; Pro supplies the real role-based check. * * @param \WP_User $user User Object. * * @return bool */ private function user_can_add_trust_device( $user ) { return (bool) apply_filters( 'adminoptim_2fa_user_can_trust_device', false, $user, $this->options ); } /** * Check if the current device is trusted. Free has no trusted-device * concept of its own; Pro supplies the real cookie/user-meta check. * * @param \WP_User $user User Object. * * @return bool */ private function is_device_trusted( $user ) { return (bool) apply_filters( 'adminoptim_2fa_is_device_trusted', false, $user ); } /** * Add 2FA Enabled column to Users page. * * @param array $columns All columns. * * @return array */ public function add_user_2fa_column( $columns ) { $columns['adminoptim_2fa'] = __( '2FA Enabled', 'admin-optimizer' ); return $columns; } /** * Render 2FA column in User page * * @param string $value Custom column output. * @param string $column_name Column name. * @param int $user_id ID of the currently-listed user. * * @return false|string */ public function render_user_2fa_column( $value, $column_name, $user_id ) { if ( 'adminoptim_2fa' === $column_name ) { $is_enabled = false; $user_2fa = get_user_meta( $user_id, self::OPTION_NAME, true ); if ( ! empty( $user_2fa['enabled'] ) ) { $is_enabled = true; } if ( $is_enabled ) { // checkmark svg. return ''; } else { // red X svg. return ''; } } else { return $value; } } /** * Check if the current page is login page * * @return bool */ protected function is_login_page() { $request = isset( $_SERVER['REQUEST_URI'] ) ? wp_parse_url( rawurldecode( sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) ) : ''; if ( (bool) apply_filters( 'adminoptim_is_login_page', false ) ) { return true; } elseif ( ( strpos( rawurldecode( sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ), 'wp-login.php' ) !== false || ( isset( $request['path'] ) && untrailingslashit( $request['path'] ) === site_url( 'wp-login', 'relative' ) ) ) ) { return true; } elseif ( ( strpos( rawurldecode( sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ), 'wp-register.php' ) !== false || ( isset( $request['path'] ) && untrailingslashit( $request['path'] ) === site_url( 'wp-register', 'relative' ) ) ) ) { return true; } return false; } }