constant_reader = $constant_reader ?? static function ( string $name ) { return defined( $name ) ? constant( $name ) : null; }; } /** * Current state of the bypass constant. * * @return array{defined:bool,users:string[],until:int|null,state:string} `state` is `off` (not * defined or names nobody), `active`, `expired` (past UNTIL), or `invalid` (UNTIL cannot be read). */ public function bypass_status(): array { $raw = ( $this->constant_reader )( self::BYPASS_CONSTANT ); $users = is_string( $raw ) ? array_values( array_unique( array_filter( array_map( static fn( $entry ) => strtolower( trim( $entry ) ), explode( ',', $raw ) ) ) ) ) : []; $until = ( $this->constant_reader )( self::UNTIL_CONSTANT ); if ( null === $until ) { $until_time = null; } elseif ( is_int( $until ) || ( is_string( $until ) && ctype_digit( $until ) ) ) { $until_time = (int) $until; } elseif ( is_string( $until ) && preg_match( '/^\d{4}-\d{2}-\d{2}/', trim( $until ) ) && false !== strtotime( $until ) ) { // An absolute date only. A relative one ("+2 hours", "tomorrow") would move forward on every request and never end. $until_time = (int) strtotime( $until ); } else { $until_time = 0; } if ( empty( $users ) ) { $state = 'off'; } elseif ( 0 === $until_time ) { $state = 'invalid'; } elseif ( null !== $until_time && $until_time <= time() ) { $state = 'expired'; } else { $state = 'active'; } return [ 'defined' => null !== $raw, 'users' => $users, 'until' => $until_time, 'state' => $state, ]; } /** * Whether the bypass is in force for this user. * * @param \WP_User $user User who just passed the password check. * * @return bool */ public function bypass_applies_to( \WP_User $user ): bool { $status = $this->bypass_status(); if ( 'active' !== $status['state'] ) { return false; } return in_array( (string) $user->ID, $status['users'], true ) || in_array( strtolower( (string) $user->user_login ), $status['users'], true ) || in_array( strtolower( (string) $user->user_email ), $status['users'], true ); } /** * Email the account owner and log that the bypass let their account in. * * @param \WP_User $user User who signed in without 2FA. * * @return void */ public function notify_bypass_used( \WP_User $user ): void { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); wp_mail( $user->user_email, /* translators: %s: site name. */ sprintf( __( '[%s] Your account was signed in without two-factor authentication', 'admin-optimizer' ), $site ), sprintf( /* translators: 1: user display name, 2: IP address, 3: site URL. */ __( "Hello %1\$s,\n\nYour account was just signed in with a password only, because the site's administrator has temporarily switched off two-factor authentication for it (an emergency bypass in wp-config.php). The request came from IP address %2\$s.\n\nIf this was not you, change your password immediately and tell the site administrator. Once you are in, set up two-factor authentication again from your profile page on %3\$s.", 'admin-optimizer' ), $user->display_name, AO_Request_Context::ip(), home_url( '/' ) ) ); $this->warning( 'Account signed in through the 2FA emergency bypass.', [ 'user_id' => (int) $user->ID ], 'bypass_used' ); } /** * Find a user by ID, login, or email address. * * @param string $identifier ID, login, or email. * * @return \WP_User|null */ public static function find_user( string $identifier ) { $identifier = trim( $identifier ); foreach ( [ 'id', 'login', 'email' ] as $field ) { if ( 'id' === $field && ! ctype_digit( $identifier ) ) { continue; } $user = get_user_by( $field, $identifier ); if ( $user ) { return $user; } } return null; } /** * Remove a user's 2FA enrolment: the secret (and its stored copy), recovery codes, trusted * devices, the replay marker, and any pending emailed code. They sign in with a password * and set 2FA up again; for a compulsory role the grace period starts over. * * @param int $user_id User ID. * * @return bool Whether the user had anything to remove. */ public function reset_user( int $user_id ): bool { $had = ! empty( get_user_meta( $user_id, Two_Factor_Authentication::OPTION_NAME, true ) ); Two_Factor_Authentication::delete_secret( $user_id ); delete_user_meta( $user_id, Two_Factor_Authentication::OPTION_NAME ); delete_user_meta( $user_id, Two_Factor_Authentication::TRUSTED_TOKEN_NAME ); delete_user_meta( $user_id, Two_Factor_Authentication::LAST_STEP_META ); delete_transient( Two_Factor_Email_Otp::CODE_PREFIX . $user_id ); $this->warning( 'Two-factor authentication was reset from the command line.', [ 'user_id' => $user_id ], 'cli_reset' ); return $had; } /** * Tell a user an administrator reset their 2FA. * * @param \WP_User $user User whose 2FA was reset. * * @return void */ public function notify_reset( \WP_User $user ): void { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); wp_mail( $user->user_email, /* translators: %s: site name. */ sprintf( __( '[%s] Your two-factor authentication was reset', 'admin-optimizer' ), $site ), sprintf( /* translators: 1: user display name, 2: site URL. */ __( "Hello %1\$s,\n\nA site administrator reset your two-factor authentication from the server. You can sign in with your password, then set it up again from your profile page on %2\$s.\n\nIf you did not expect this, contact your site administrator.", 'admin-optimizer' ), $user->display_name, home_url( '/' ) ) ); } /** * IDs of users with any 2FA data, in ID order, after a given ID. * * @param int $after Return IDs greater than this. * @param int $limit Most IDs to return. * * @return int[] */ public static function enrolled_user_ids( int $after = 0, int $limit = 200 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded, command-line only. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND user_id > %d ORDER BY user_id ASC LIMIT %d", Two_Factor_Authentication::OPTION_NAME, $after, $limit ) ); return array_map( 'intval', (array) $ids ); } }