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 ? '
' . wp_kses_post( $error ) . '
' : '' ); } else { self::minimal_header( $title, $error ); } echo '
'; if ( $enrolling ) { echo '

' . esc_html__( 'This account manages the shop, so it needs a code from your phone as well as a password.', 'ai-eshop-optimizer' ) . '

'; echo '

' . esc_html__( 'Open your authenticator app — Google Authenticator, Microsoft Authenticator, 1Password, Authy — and scan this:', 'ai-eshop-optimizer' ) . '

'; $svg = AIEO_QR::svg( $uri, array( 'size' => 200, 'alt' => __( 'Scan with your authenticator app', 'ai-eshop-optimizer' ) ) ); if ( '' !== $svg ) { echo '
' . $svg . '
'; } echo '

' . esc_html__( 'No camera? Type this key in instead:', 'ai-eshop-optimizer' ) . '

'; echo '

' . esc_html( AIEO_TOTP::format_secret( $secret ) ) . '

'; echo ''; echo ''; } else { echo ''; } echo ''; if ( ! $enrolling ) { echo '

' . esc_html__( 'Lost your phone? Type one of the recovery codes you saved instead.', 'ai-eshop-optimizer' ) . '

'; } echo self::trust_box( $trust ); echo ''; echo ''; echo ''; if ( $remember ) { echo ''; } echo '

'; echo ''; echo '

'; echo '
'; if ( $chrome ) { login_footer( 'aieo_code' ); } else { self::minimal_footer(); } } public static function trust_box( $trust ) { $days = self::remember_days(); if ( 0 === $days ) { return ''; } return '

' . '

' . '

' . 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( $title ) . ''; echo '
'; echo '

' . esc_html( $title ) . '

'; if ( $error ) { echo '
' . wp_kses_post( $error ) . '
'; } } private static function minimal_footer() { echo '
'; } private static function step_url() { return add_query_arg( 'action', self::ACTION, wp_login_url() ); } private static function action_attr( $action_id, $destructive = false ) { if ( class_exists( 'AIEO_DMM_UI' ) && is_callable( array( 'AIEO_DMM_UI', 'action_attr' ) ) ) { return AIEO_DMM_UI::action_attr( 'login_security', $action_id, $destructive ); } return 'data-aieo-action="' . esc_attr( 'login_security.' . $action_id ) . '"' . ( $destructive ? ' data-aieo-action-destructive="1"' : '' ); } public static function profile_section( $user ) { if ( ! AIEO_Login_Security::feature( '2fa' )['enabled'] && ! AIEO_TOTP::is_active( $user->ID ) ) { return; } $is_self = ( get_current_user_id() === (int) $user->ID ); $active = AIEO_TOTP::is_active( $user->ID ); $required = self::is_required( $user ); $left = AIEO_TOTP::recovery_codes_left( $user->ID ); echo '

' . esc_html__( 'Two-factor authentication', 'ai-eshop-optimizer' ) . '

'; echo ''; echo ''; self::print_profile_js( $user->ID ); return; } echo '
'; 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 = '
'; $html .= '

' . esc_html__( 'Scan this with your authenticator app, then type the code it shows to prove it worked.', 'ai-eshop-optimizer' ) . '

'; $html .= '
' . $svg . '
'; $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 .= '
'; wp_send_json_success( array( 'html' => $html ) ); } public static function ajax_confirm() { $user_id = self::verify(); if ( $user_id !== get_current_user_id() ) { wp_send_json_error( array( 'message' => 'self_only' ), 403 ); } $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; $result = AIEO_TOTP::confirm_enrolment( $user_id, $code ); if ( empty( $result['ok'] ) ) { wp_send_json_error( array( 'message' => $result['message'] ) ); } self::notify_account( get_userdata( $user_id ), 'enrolled' ); wp_send_json_success( array( 'html' => self::recovery_codes_html( $result['codes'] ) ) ); } public static function ajax_disable() { $target = isset( $_POST['target'] ) ? (int) $_POST['target'] : 0; $user_id = self::verify( $target ); AIEO_TOTP::disable( $user_id ); update_user_meta( $user_id, AIEO_TOTP::META_GRACE_FROM, (string) time() ); self::notify_account( get_userdata( $user_id ), 'disabled' ); wp_send_json_success(); } public static function ajax_new_recovery_codes() { $user_id = self::verify(); if ( $user_id !== get_current_user_id() ) { wp_send_json_error( array( 'message' => 'self_only' ), 403 ); } if ( ! AIEO_TOTP::is_active( $user_id ) ) { wp_send_json_error( array( 'message' => 'not_enrolled' ), 400 ); } $codes = AIEO_TOTP::issue_recovery_codes( $user_id ); self::notify_account( get_userdata( $user_id ), 'recovery_reissued' ); wp_send_json_success( array( 'html' => self::recovery_codes_html( $codes ) ) ); } private static function recovery_codes_html( array $codes ) { $html = '
'; $html .= '

' . esc_html__( 'Save these recovery codes now', 'ai-eshop-optimizer' ) . '

'; $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 .= '

'; return $html; } public static function enrolment_notice() { if ( ! AIEO_Login_Security::feature_active( '2fa' ) ) { return; } $user = wp_get_current_user(); if ( ! $user || ! self::is_required( $user ) || AIEO_TOTP::is_active( $user->ID ) ) { return; } $days = self::grace_days_left( $user ); $url = admin_url( 'profile.php#aieo-2fa' ); echo '

' . 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' ) . '

'; } public static function handoff_notice() { $key = 'aieo_2fa_codes_' . get_current_user_id(); $codes = get_transient( $key ); if ( ! is_array( $codes ) || empty( $codes ) ) { return; } delete_transient( $key ); echo '

' . 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 '

'; } private static function notify_account( $user, $event ) { if ( ! $user instanceof WP_User || ! is_email( $user->user_email ) ) { return; } $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); $lines = array( 'enrolled' => __( 'Two-factor authentication was switched ON for your account.', 'ai-eshop-optimizer' ), 'disabled' => __( 'Two-factor authentication was switched OFF for your account.', 'ai-eshop-optimizer' ), 'recovery_used' => __( 'One of your recovery codes was used to sign in.', 'ai-eshop-optimizer' ), 'recovery_reissued' => __( 'A new set of recovery codes was issued for your account. Any you had saved no longer work.', 'ai-eshop-optimizer' ), ); if ( ! isset( $lines[ $event ] ) ) { return; } $body = $lines[ $event ] . "\n\n" . sprintf( __( 'Account: %1$s on %2$s', 'ai-eshop-optimizer' ), $user->user_login, $site ) . "\n" . sprintf( __( 'When: %s', 'ai-eshop-optimizer' ), wp_date( 'j F Y, H:i' ) ) . "\n\n" . __( 'If this was you, nothing needs doing. If it was not, change your password immediately and tell whoever runs the shop.', 'ai-eshop-optimizer' ); wp_mail( $user->user_email, sprintf( __( '[%s] Your sign-in security changed', 'ai-eshop-optimizer' ), $site ), $body ); } }