ID ) ) { return true; } return self::is_required( $user ) && self::grace_expired( $user ); } public static function grace_anchor( $user ) { $user = is_numeric( $user ) ? get_userdata( (int) $user ) : $user; if ( ! $user instanceof WP_User ) { return 0; } $override = (int) get_user_meta( $user->ID, AIEO_TOTP::META_GRACE_FROM, true ); if ( $override > 0 ) { return $override; } $conf = AIEO_Login_Security::feature( '2fa' ); $since = isset( $conf['enabled_at'] ) ? (int) $conf['enabled_at'] : 0; if ( $since <= 0 ) { return 0; } $made = strtotime( (string) $user->user_registered . ' UTC' ); $made = ( false === $made || $made <= 0 ) ? 0 : $made; return max( $since, $made ); } public static function grace_expired( $user ) { $conf = AIEO_Login_Security::feature( '2fa' ); $days = max( 0, (int) $conf['grace_days'] ); if ( 0 === $days ) { return true; } $anchor = self::grace_anchor( $user ); if ( $anchor <= 0 ) { return false; } return ( time() - $anchor ) > ( $days * DAY_IN_SECONDS ); } public static function grace_days_left( $user ) { $conf = AIEO_Login_Security::feature( '2fa' ); $days = max( 0, (int) $conf['grace_days'] ); if ( 0 === $days ) { return 0; } $anchor = self::grace_anchor( $user ); if ( $anchor <= 0 ) { return $days; } $left = ( $anchor + ( $days * DAY_IN_SECONDS ) ) - time(); return max( 0, (int) ceil( $left / DAY_IN_SECONDS ) ); } public static function intercept( $user_login, $user = null ) { if ( ! $user instanceof WP_User ) { $user = get_user_by( 'login', $user_login ); } if ( ! $user instanceof WP_User ) { return; } if ( ! self::should_challenge( $user ) ) { return; } if ( self::device_is_trusted( $user->ID ) ) { return; } wp_clear_auth_cookie(); self::end_opened_sessions( $user->ID ); $nonce = self::issue_login_nonce( $user->ID ); $redirect = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(); $remember = ! empty( $_REQUEST['rememberme'] ); self::render_second_step( $user, $nonce, $redirect, $remember, '', false ); exit; } public static function note_opened_session( $cookie, $expire, $expiration, $user_id, $scheme = 'logged_in', $token = '' ) { if ( '' !== (string) $token ) { self::$opened[ (int) $user_id ][] = (string) $token; } } private static function end_opened_sessions( $user_id ) { $user_id = (int) $user_id; if ( empty( self::$opened[ $user_id ] ) ) { return; } $sessions = WP_Session_Tokens::get_instance( $user_id ); foreach ( self::$opened[ $user_id ] as $token ) { $sessions->destroy( $token ); } unset( self::$opened[ $user_id ] ); } public static function handle_second_step() { if ( 'POST' !== strtoupper( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) { wp_safe_redirect( wp_login_url() ); exit; } $user_id = isset( $_POST['aieo_user'] ) ? (int) $_POST['aieo_user'] : 0; $ticket = isset( $_POST['aieo_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['aieo_nonce'] ) ) : ''; $user = $user_id ? get_userdata( $user_id ) : null; if ( ! $user instanceof WP_User || ! self::consume_login_nonce( $user_id, $ticket ) ) { wp_safe_redirect( wp_login_url() ); exit; } $redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : admin_url(); $remember = ! empty( $_POST['rememberme'] ); $code = isset( $_POST['aieo_code'] ) ? sanitize_text_field( wp_unslash( $_POST['aieo_code'] ) ) : ''; $pending_secret = isset( $_POST['aieo_enrolling'] ) ? '1' === sanitize_text_field( wp_unslash( $_POST['aieo_enrolling'] ) ) : false; $trust = self::trust_requested(); $ok = false; $error = ''; if ( $pending_secret ) { $result = AIEO_TOTP::confirm_enrolment( $user->ID, $code ); $ok = ! empty( $result['ok'] ); $error = $ok ? '' : (string) $result['message']; if ( $ok ) { self::notify_account( $user, 'enrolled' ); set_transient( 'aieo_2fa_codes_' . $user->ID, $result['codes'], 10 * MINUTE_IN_SECONDS ); } } else { $ok = AIEO_TOTP::verify( $user->ID, $code ); if ( ! $ok && AIEO_TOTP::verify_recovery_code( $user->ID, $code ) ) { $ok = true; self::notify_account( $user, 'recovery_used' ); } if ( ! $ok ) { $error = __( 'That code is not right. Codes change every 30 seconds — wait for the next one and try again.', 'ai-eshop-optimizer' ); do_action( 'wp_login_failed', $user->user_login, new WP_Error( 'aieo_2fa_bad_code', $error ) ); } } if ( ! $ok ) { $nonce = self::issue_login_nonce( $user->ID ); self::render_second_step( $user, $nonce, $redirect, $remember, $error, $trust ); exit; } if ( $trust ) { self::remember_device( $user->ID ); } wp_set_auth_cookie( $user->ID, $remember ); do_action( 'aieo_2fa_passed', $user ); $redirect = wp_validate_redirect( $redirect, admin_url() ); wp_safe_redirect( $redirect ); exit; } private static function issue_login_nonce( $user_id ) { $plain = bin2hex( random_bytes( 32 ) ); update_user_meta( $user_id, self::META_NONCE, array( 'hash' => wp_hash( $plain ), 'expires' => time() + self::NONCE_TTL, ) ); return $plain; } private static function consume_login_nonce( $user_id, $plain ) { $stored = get_user_meta( $user_id, self::META_NONCE, true ); delete_user_meta( $user_id, self::META_NONCE ); if ( ! is_array( $stored ) || empty( $stored['hash'] ) || empty( $stored['expires'] ) ) { return false; } if ( time() > (int) $stored['expires'] ) { return false; } return hash_equals( (string) $stored['hash'], wp_hash( (string) $plain ) ); } public static function remember_days() { $conf = AIEO_Login_Security::feature( '2fa' ); return max( 0, (int) ( isset( $conf['remember_days'] ) ? $conf['remember_days'] : 0 ) ); } private static function trust_requested() { return self::remember_days() > 0 && isset( $_POST['aieo_trust'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['aieo_trust'] ) ); } private static function trust_cookie() { return 'aieo_2fa_trust_' . COOKIEHASH; } private static function device_is_trusted( $user_id ) { if ( 0 === self::remember_days() ) { return false; } $name = self::trust_cookie(); $raw = isset( $_COOKIE[ $name ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) ) : ''; if ( '' === $raw ) { return false; } $tokens = get_user_meta( $user_id, 'aieo_2fa_trusted', true ); if ( ! is_array( $tokens ) ) { return false; } $hash = wp_hash( $raw ); foreach ( $tokens as $t ) { if ( self::token_counts( $t ) && hash_equals( (string) $t['hash'], $hash ) ) { return true; } } return false; } private static function token_counts( $t ) { return is_array( $t ) && isset( $t['hash'], $t['expires'] ) && ! empty( $t['ticked'] ) && time() < (int) $t['expires']; } private static function remember_device( $user_id ) { $days = self::remember_days(); if ( 0 === $days ) { return; } $plain = bin2hex( random_bytes( 32 ) ); $expires = time() + ( $days * DAY_IN_SECONDS ); $tokens = get_user_meta( $user_id, 'aieo_2fa_trusted', true ); $tokens = is_array( $tokens ) ? $tokens : array(); $tokens = array_values( array_filter( $tokens, array( __CLASS__, 'token_counts' ) ) ); $tokens[] = array( 'hash' => wp_hash( $plain ), 'expires' => $expires, 'ticked' => 1, ); update_user_meta( $user_id, 'aieo_2fa_trusted', $tokens ); setcookie( self::trust_cookie(), $plain, array( 'expires' => $expires, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } private static function render_second_step( $user, $nonce, $redirect, $remember, $error, $trust ) { $enrolling = ! AIEO_TOTP::is_active( $user->ID ); $secret = ''; $uri = ''; if ( $enrolling ) { $start = AIEO_TOTP::begin_enrolment( $user->ID ); $secret = $start['secret']; $uri = $start['uri']; } $title = $enrolling ? __( 'Set up your authenticator', 'ai-eshop-optimizer' ) : __( 'One more step', 'ai-eshop-optimizer' ); $chrome = function_exists( 'login_header' ) && function_exists( 'login_footer' ); if ( $chrome ) { login_header( $title, $error ? '
' . '
' . '' . esc_html__( 'No code will be asked for on this browser until then. Leave it unticked on a shared or public computer.', 'ai-eshop-optimizer' ) . '
'; } private static function minimal_header( $title, $error ) { nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); echo ''; echo ''; echo ''; echo '| ' . esc_html__( 'Status', 'ai-eshop-optimizer' ) . ' | ';
if ( $active ) {
echo ' ' . esc_html__( 'On.', 'ai-eshop-optimizer' ) . ' ' . esc_html( sprintf( _n( '%d recovery code left.', '%d recovery codes left.', $left, 'ai-eshop-optimizer' ), $left ) ) . ' '; if ( $left <= 2 ) { echo '' . esc_html__( 'Running low — issue a new set before you are locked out of your own shop.', 'ai-eshop-optimizer' ) . ' '; } } elseif ( $required ) { $days = self::grace_days_left( $user ); echo '' . esc_html__( 'Off, and required for this account.', 'ai-eshop-optimizer' ) . ' '; echo esc_html( $days > 0 ? sprintf( _n( 'You have %d day left to set it up before it is asked for at sign-in.', 'You have %d days left to set it up before it is asked for at sign-in.', $days, 'ai-eshop-optimizer' ), $days ) : __( 'It will be asked for at your next sign-in.', 'ai-eshop-optimizer' ) ); echo ' '; } else { echo '' . esc_html__( 'Off.', 'ai-eshop-optimizer' ) . ' '; } if ( ! $is_self ) { if ( $active && current_user_can( 'edit_user', $user->ID ) ) { echo ''; echo ' ' . esc_html__( 'Use when somebody has lost their phone and their recovery codes. They will be asked to set it up again.', 'ai-eshop-optimizer' ) . ' '; } echo ' |
|---|
'; if ( ! $active ) { echo ''; } else { echo ' '; if ( ! $required ) { echo ''; } } echo '
'; echo ''; self::print_profile_js( $user->ID ); } private static function print_profile_js( $user_id ) { $nonce = wp_create_nonce( 'aieo_2fa_profile' ); ?> 'permission_denied' ), 403 ); } return $target; } public static function ajax_begin() { $user_id = self::verify(); if ( $user_id !== get_current_user_id() ) { wp_send_json_error( array( 'message' => 'self_only' ), 403 ); } $start = AIEO_TOTP::begin_enrolment( $user_id ); $svg = AIEO_QR::svg( $start['uri'], array( 'size' => 190, 'alt' => __( 'Scan with your authenticator app', 'ai-eshop-optimizer' ) ) ); $html = '' . esc_html__( 'Scan this with your authenticator app, then type the code it shows to prove it worked.', 'ai-eshop-optimizer' ) . '
'; $html .= '' . esc_html__( 'No camera? Type this key in instead:', 'ai-eshop-optimizer' ) . '
'; $html .= '' . esc_html( AIEO_TOTP::format_secret( $start['secret'] ) ) . '
'; $html .= ''; $html .= '
'; $html .= ''; $html .= '' . esc_html__( 'Each one signs you in once if your phone is lost or wiped. This is the only time they are shown — print them, or put them in your password manager. They cannot be shown again, only replaced.', 'ai-eshop-optimizer' ) . '
'; $html .= '';
foreach ( $codes as $c ) {
$html .= esc_html( $c ) . '
';
}
$html .= '
' . esc_html__( 'Your account still has no second factor.', 'ai-eshop-optimizer' ) . ' ' . esc_html( $days > 0 ? sprintf( _n( 'This account manages the shop, so a code from your phone will be required in %d day.', 'This account manages the shop, so a code from your phone will be required in %d days.', $days, 'ai-eshop-optimizer' ), $days ) : __( 'This account manages the shop, so a code from your phone will be required at your next sign-in.', 'ai-eshop-optimizer' ) ) . ' ' . esc_html__( 'Set it up now', 'ai-eshop-optimizer' ) . '
' . esc_html__( 'Save these recovery codes now — this is the only time they are shown.', 'ai-eshop-optimizer' ) . '
' . esc_html__( 'Each one signs you in once if your phone is lost or wiped. Print them, or put them in your password manager. They cannot be shown again, only replaced from your profile.', 'ai-eshop-optimizer' ) . '
';
foreach ( $codes as $c ) {
echo esc_html( (string) $c ) . '
';
}
echo '