*/
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( '
', 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(
'',
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 );