0; } private const META_KEY = '_acemedia_passkeys'; private const TRANSIENT_REGISTER = 'acemedia_passkey_register_'; private const TRANSIENT_LOGIN = 'acemedia_passkey_login_'; private const TRANSIENT_2FA = 'acemedia_passkey_2fa_'; private const TRANSIENT_PASSWORD_FACTOR = 'acemedia_passkey_pwd_'; /** * Passkeys need OpenSSL and the WebAuthn library. The main plugin file defines the shared * check; fall back to the same test if this class is ever loaded on its own. */ private static function runtime_available() { if (function_exists('acemedia_login_block_passkeys_available')) { return acemedia_login_block_passkeys_available(); } return extension_loaded('openssl') && class_exists(WebAuthn::class); } public function __construct() { // Without OpenSSL passkeys cannot be verified, so register no routes, forms or filters. if (!self::runtime_available()) { return; } add_action('rest_api_init', [$this, 'register_routes']); add_action('acemedia_passkey_claim_cleanup', [__CLASS__, 'cleanup_expired_claims']); add_action('login_form', [$this, 'render_login_ui'], 20); add_action('show_user_profile', [$this, 'render_profile_ui'], 20); add_action('edit_user_profile', [$this, 'render_profile_ui'], 20); add_filter('authenticate', [$this, 'authenticate_with_passkey_token'], 15, 3); } public function register_routes() { register_rest_route('acemedia/v1', '/passkeys/register-options', [ 'methods' => 'POST', 'callback' => [$this, 'begin_registration'], 'permission_callback' => [$this, 'require_logged_in'], ]); register_rest_route('acemedia/v1', '/passkeys/register', [ 'methods' => 'POST', 'callback' => [$this, 'finish_registration'], 'permission_callback' => [$this, 'require_logged_in'], ]); register_rest_route('acemedia/v1', '/passkeys/login-options', [ 'methods' => 'POST', 'callback' => [$this, 'begin_login'], 'permission_callback' => '__return_true', ]); register_rest_route('acemedia/v1', '/passkeys/login', [ 'methods' => 'POST', 'callback' => [$this, 'finish_login'], 'permission_callback' => '__return_true', ]); register_rest_route('acemedia/v1', '/passkeys/remove', [ 'methods' => 'POST', 'callback' => [$this, 'remove_passkey'], 'permission_callback' => [$this, 'require_logged_in'], ]); } public function render_login_ui() { if (!$this->is_enabled()) { return; } ?>

is_enabled()) { return; } $passkeys = $this->get_passkeys($user->ID); ?>



is_enabled()) { return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]); } $user = wp_get_current_user(); if (!$user || !$user->ID) { return new \WP_Error('not_logged_in', __('Not logged in.', 'acemedia-login-block'), ['status' => 401]); } $passkeys = $this->get_passkeys($user->ID); $exclude = []; foreach ($passkeys as $passkey) { if (!empty($passkey['id'])) { $exclude[] = ByteBuffer::fromBase64Url($passkey['id']); } } $webauthn = $this->get_webauthn(); $createArgs = $webauthn->getCreateArgs( (string) $user->ID, $user->user_login, $user->display_name ?: $user->user_login, 60, 'required', 'preferred', null, $exclude ); $state = $this->generate_state(); $this->store_state(self::TRANSIENT_REGISTER, $state, [ 'user_id' => $user->ID, 'challenge' => $this->buffer_to_base64url($webauthn->getChallenge()), ], 10 * MINUTE_IN_SECONDS); return rest_ensure_response([ 'options' => $createArgs, 'state' => $state, ]); } public function finish_registration($request) { if (!$this->is_enabled()) { return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]); } $user = wp_get_current_user(); if (!$user || !$user->ID) { return new \WP_Error('not_logged_in', __('Not logged in.', 'acemedia-login-block'), ['status' => 401]); } $state = sanitize_text_field($request->get_param('state')); $state_data = $this->get_state(self::TRANSIENT_REGISTER, $state); if (!$state_data || (int) $state_data['user_id'] !== (int) $user->ID) { return new \WP_Error('invalid_state', __('Registration session expired.', 'acemedia-login-block'), ['status' => 400]); } $credential = $request->get_param('credential'); if (!is_array($credential) || empty($credential['response'])) { return new \WP_Error('invalid_credential', __('Invalid credential payload.', 'acemedia-login-block'), ['status' => 400]); } $clientDataJSON = $this->base64url_decode($credential['response']['clientDataJSON'] ?? ''); $attestationObject = $this->base64url_decode($credential['response']['attestationObject'] ?? ''); $challenge = $this->base64url_decode($state_data['challenge']); if (!$clientDataJSON || !$attestationObject) { return new \WP_Error('invalid_payload', __('Invalid registration payload.', 'acemedia-login-block'), ['status' => 400]); } try { $webauthn = $this->get_webauthn(); $createData = $webauthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true); } catch (\Exception $e) { return new \WP_Error('registration_failed', $e->getMessage(), ['status' => 400]); } $credential_id = $this->extract_credential_id($createData); $public_key = $this->extract_public_key($createData); $counter = $this->extract_counter($createData, $webauthn); if (!$credential_id || !$public_key) { return new \WP_Error('registration_failed', __('Could not read passkey data.', 'acemedia-login-block'), ['status' => 400]); } $name = sanitize_text_field($request->get_param('name')) ?: __('Passkey', 'acemedia-login-block'); $transports = $credential['transports'] ?? []; $this->save_passkey($user->ID, [ 'id' => $credential_id, 'publicKey' => $public_key, 'counter' => $counter, 'name' => $name, 'created' => time(), 'last_used' => 0, 'transports' => is_array($transports) ? $transports : [], ]); Logging::log_event($user->ID, 'passkey_registered', [ 'name' => $name, 'transports' => is_array($transports) ? $transports : [], ], true); $this->delete_state(self::TRANSIENT_REGISTER, $state); return rest_ensure_response(['success' => true]); } public function begin_login($request) { if (!$this->is_enabled()) { return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]); } $username = sanitize_text_field($request->get_param('username')); $context = sanitize_text_field($request->get_param('context')) ?: 'passwordless'; $discoverable = (bool) $request->get_param('discoverable'); if (!in_array($context, ['passwordless', 'password_factor', 'second_factor'], true)) { return new \WP_Error('invalid_context', __('Invalid passkey login context.', 'acemedia-login-block'), ['status' => 400]); } $user = null; $allow_list = []; if (!$username) { if (!$discoverable) { return new \WP_Error('missing_username', __('Username is required.', 'acemedia-login-block'), ['status' => 400]); } } else { $user = $this->get_user_from_login($username); if (!$user) { return new \WP_Error('invalid_username', __('Invalid username.', 'acemedia-login-block'), ['status' => 404]); } if ($this->is_locked_out($user->ID)) { return new \WP_Error('locked_out', __('Account is temporarily locked.', 'acemedia-login-block'), ['status' => 403]); } $requires_2fa = $this->user_requires_2fa($user); if ($context === 'passwordless' && $requires_2fa && !$this->user_allows_passwordless($user)) { return new \WP_Error('passkey_requires_password', __('This account requires password login before passkey verification.', 'acemedia-login-block'), ['status' => 403]); } $passkeys = $this->get_passkeys($user->ID); if (empty($passkeys)) { return new \WP_Error('no_passkeys', __('No passkeys registered for this account.', 'acemedia-login-block'), ['status' => 404]); } foreach ($passkeys as $passkey) { if (!empty($passkey['id'])) { $allow_list[] = ByteBuffer::fromBase64Url($passkey['id']); } } } $webauthn = $this->get_webauthn(); $allow_credentials = empty($allow_list) ? null : $allow_list; $getArgs = $webauthn->getGetArgs($allow_credentials, 60, true, true, true, true, true, $context === 'second_factor' ? 'preferred' : 'required'); $state = $this->generate_state(); $this->store_state(self::TRANSIENT_LOGIN, $state, [ 'user_id' => $user ? $user->ID : 0, 'challenge' => $this->buffer_to_base64url($webauthn->getChallenge()), 'context' => $context, 'discoverable' => $discoverable, ], 10 * MINUTE_IN_SECONDS); return rest_ensure_response([ 'options' => $getArgs, 'state' => $state, ]); } public function finish_login($request) { if (!$this->is_enabled()) { return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]); } $state = sanitize_text_field($request->get_param('state')); $state_data = self::take_once(self::TRANSIENT_LOGIN, $state); if (!$state_data) { return new \WP_Error('invalid_state', __('Login session expired.', 'acemedia-login-block'), ['status' => 400]); } $credential = $request->get_param('credential'); if (!is_array($credential) || empty($credential['response'])) { return new \WP_Error('invalid_credential', __('Invalid credential payload.', 'acemedia-login-block'), ['status' => 400]); } $user_id = (int) ($state_data['user_id'] ?? 0); $user = $user_id ? get_user_by('id', $user_id) : null; $credential_id = sanitize_text_field($credential['id'] ?? ''); if (!$credential_id) { return new \WP_Error('invalid_credential', __('Invalid credential payload.', 'acemedia-login-block'), ['status' => 400]); } $passkey = null; if (!$user) { $match = $this->find_user_by_credential_id($credential_id); if ($match) { $user = $match['user']; $user_id = (int) $user->ID; $passkey = $match['passkey']; } } else { $passkey = $this->find_passkey($user_id, $credential_id); } if (!$user || !$passkey) { do_action('wp_login_failed', $user ? $user->user_login : ''); return new \WP_Error('unknown_credential', __('Passkey not recognized.', 'acemedia-login-block'), ['status' => 403]); } if ($this->is_locked_out($user_id)) { return new \WP_Error('locked_out', __('Account is temporarily locked.', 'acemedia-login-block'), ['status' => 403]); } $clientDataJSON = $this->base64url_decode($credential['response']['clientDataJSON'] ?? ''); $authenticatorData = $this->base64url_decode($credential['response']['authenticatorData'] ?? ''); $signature = $this->base64url_decode($credential['response']['signature'] ?? ''); $challenge = $this->base64url_decode($state_data['challenge']); if (!$clientDataJSON || !$authenticatorData || !$signature) { do_action('wp_login_failed', $user->user_login); return new \WP_Error('invalid_payload', __('Invalid login payload.', 'acemedia-login-block'), ['status' => 400]); } try { $webauthn = $this->get_webauthn(); $webauthn->processGet( $clientDataJSON, $authenticatorData, $signature, $passkey['publicKey'], $challenge, isset($passkey['counter']) ? (int) $passkey['counter'] : 0, ($state_data['context'] ?? '') !== 'second_factor', true ); } catch (\Exception $e) { do_action('wp_login_failed', $user->user_login); return new \WP_Error('login_failed', $e->getMessage(), ['status' => 403]); } $passkey['counter'] = $this->extract_counter(null, $webauthn); $passkey['last_used'] = time(); $this->update_passkey($user_id, $passkey); $this->delete_state(self::TRANSIENT_LOGIN, $state); if (($state_data['context'] ?? '') === 'second_factor') { $token = $this->generate_state(24); $this->store_state(self::TRANSIENT_2FA, $token, [ 'user_id' => $user_id, ], 5 * MINUTE_IN_SECONDS); return rest_ensure_response([ 'success' => true, 'token' => $token, ]); } if (($state_data['context'] ?? '') === 'password_factor') { $token = $this->generate_state(24); $this->store_state(self::TRANSIENT_PASSWORD_FACTOR, $token, [ 'user_id' => $user_id, ], 5 * MINUTE_IN_SECONDS); return rest_ensure_response([ 'success' => true, 'token' => $token, 'login' => $user->user_login, ]); } if ($this->user_requires_2fa($user) && !$this->user_allows_passwordless($user)) { return new \WP_Error('passkey_requires_password', __('This account requires password login before passkey verification.', 'acemedia-login-block'), ['status' => 403]); } // Only an asserted, UV-verified passkey reaches this internal bridge. // Keep the normal authenticate chain (including multisite spam checks), // session creation and wp_login observers available to integrations. $provide_verified_user = static function ($candidate) use ($user) { return is_wp_error($candidate) ? $candidate : apply_filters('wp_authenticate_user', $user, ''); }; add_filter('authenticate', $provide_verified_user, 15, 1); self::$verified_passwordless_user = (int) $user_id; try { $signed_in = wp_signon(['user_login' => $user->user_login, 'user_password' => '', 'remember' => false], is_ssl()); } finally { self::$verified_passwordless_user = 0; remove_filter('authenticate', $provide_verified_user, 15); } if (is_wp_error($signed_in)) { $code = $signed_in->get_error_code(); $data = $signed_in->get_error_data($code); if (!is_array($data) || !isset($data['status'])) { $signed_in->add_data(array_merge(is_array($data) ? $data : [], ['status' => 403]), $code); } return $signed_in; } wp_set_current_user($signed_in->ID); return rest_ensure_response([ 'success' => true, 'redirect' => $this->get_redirect_url($user), ]); } public function remove_passkey($request) { if (!$this->is_enabled()) { return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]); } $user = wp_get_current_user(); if (!$user || !$user->ID) { return new \WP_Error('not_logged_in', __('Not logged in.', 'acemedia-login-block'), ['status' => 401]); } $id = sanitize_text_field($request->get_param('id')); if (!$id) { return new \WP_Error('missing_id', __('Missing passkey id.', 'acemedia-login-block'), ['status' => 400]); } $passkeys = $this->get_passkeys($user->ID); $updated = []; $removed = false; foreach ($passkeys as $passkey) { if (!isset($passkey['id']) || $passkey['id'] !== $id) { $updated[] = $passkey; } else { $removed = true; } } if (!$removed) { return new \WP_Error('not_found', __('Passkey not found.', 'acemedia-login-block'), ['status' => 404]); } update_user_meta($user->ID, self::META_KEY, $updated); Logging::log_event($user->ID, 'passkey_removed', [ 'id' => $id, ], true); return rest_ensure_response(['success' => true]); } public static function verify_2fa_token($user_id, $token) { $token = sanitize_text_field($token); if (!$token) { return false; } $data = self::take_once(self::TRANSIENT_2FA, $token); if (!$data || (int) ($data['user_id'] ?? 0) !== (int) $user_id) { return false; } delete_transient(self::TRANSIENT_2FA . $token); return true; } /** Read-only first-factor inspection for the factor-selection request. */ public static function inspect_password_token($token, $username) { $data = get_transient(self::TRANSIENT_PASSWORD_FACTOR . sanitize_text_field($token)); $user = !empty($data['user_id']) ? get_user_by('id', (int) $data['user_id']) : false; if (!$user || (string) $username === '') { return false; } if (!hash_equals((string) $user->user_login, (string) $username) && !hash_equals((string) $user->user_email, (string) $username)) { return false; } return $user; } public function authenticate_with_passkey_token($user, $username, $password) { if (is_wp_error($user)) { return $user; } $token = isset($_POST['acemedia_passkey_password_token']) ? sanitize_text_field(wp_unslash($_POST['acemedia_passkey_password_token'])) : ''; if (!$token) { return $user; } $token_data = self::take_once(self::TRANSIENT_PASSWORD_FACTOR, $token); if (!$token_data || empty($token_data['user_id'])) { return new \WP_Error('passkey_password_token_invalid', __('Passkey login session expired. Please try again.', 'acemedia-login-block')); } $target_user = get_user_by('id', (int) $token_data['user_id']); if (!$target_user) { return new \WP_Error('passkey_password_user_missing', __('Passkey login failed for this account. Please try again.', 'acemedia-login-block')); } if (!empty($username)) { $normalized_username = sanitize_text_field($username); $matches_login = hash_equals((string) $target_user->user_login, (string) $normalized_username); $matches_email = is_email($normalized_username) && hash_equals((string) $target_user->user_email, (string) $normalized_username); if (!$matches_login && !$matches_email) { return new \WP_Error('passkey_password_username_mismatch', __('Passkey matched a different account than the provided username.', 'acemedia-login-block')); } } delete_transient(self::TRANSIENT_PASSWORD_FACTOR . $token); return apply_filters('wp_authenticate_user', $target_user, $password); } private function is_enabled() { if (!self::runtime_available()) { return false; } return (bool) get_option('acemedia_passkeys_enabled', true); } public function require_logged_in() { return is_user_logged_in(); } private function get_webauthn() { return new WebAuthn( get_bloginfo('name'), $this->get_rp_id(), null, true ); } private function get_rp_id() { $override = get_option('acemedia_passkeys_rp_id', ''); if (!empty($override)) { return apply_filters('acemedia_passkeys_rp_id', $override); } $host = wp_parse_url(home_url(), PHP_URL_HOST); if (defined('COOKIE_DOMAIN') && COOKIE_DOMAIN) { $host = ltrim(COOKIE_DOMAIN, '.'); } elseif (!$host && isset($_SERVER['HTTP_HOST'])) { $host = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])); } $host = $host ?: 'localhost'; return apply_filters('acemedia_passkeys_rp_id', $host); } private function get_attestation_preference() { $value = sanitize_text_field(get_option('acemedia_passkeys_attestation', 'preferred')); $allowed = ['preferred', 'none', 'indirect', 'direct', 'enterprise']; if (!in_array($value, $allowed, true)) { return 'preferred'; } return $value; } private function get_passkeys($user_id) { $passkeys = get_user_meta($user_id, self::META_KEY, true); return is_array($passkeys) ? $passkeys : []; } private function save_passkey($user_id, array $data) { $passkeys = $this->get_passkeys($user_id); $existing = false; foreach ($passkeys as $index => $passkey) { if (!empty($passkey['id']) && $passkey['id'] === $data['id']) { $passkeys[$index] = array_merge($passkey, $data); $existing = true; break; } } if (!$existing) { $passkeys[] = $data; } update_user_meta($user_id, self::META_KEY, $passkeys); } private function update_passkey($user_id, array $data) { $this->save_passkey($user_id, $data); } private function find_passkey($user_id, $credential_id) { $passkeys = $this->get_passkeys($user_id); foreach ($passkeys as $passkey) { if (!empty($passkey['id']) && hash_equals($passkey['id'], $credential_id)) { return $passkey; } } return null; } private function find_user_by_credential_id($credential_id) { $users = get_users([ 'meta_key' => self::META_KEY, 'fields' => ['ID'], ]); foreach ($users as $user_obj) { $passkeys = $this->get_passkeys($user_obj->ID); foreach ($passkeys as $passkey) { if (!empty($passkey['id']) && hash_equals($passkey['id'], $credential_id)) { $user = get_user_by('id', $user_obj->ID); if ($user) { return [ 'user' => $user, 'passkey' => $passkey, ]; } } } } return null; } private function user_requires_2fa($user) { if (get_user_meta($user->ID, '_acemedia_2fa_enabled', true)) { return true; } foreach ($user->roles as $role) { if ( get_option("acemedia_2fa_enabled_{$role}", false) || get_option("acemedia_passkey_2fa_required_{$role}", false) ) { return true; } } return false; } private function user_allows_passwordless($user) { foreach ($user->roles as $role) { if ( get_option("acemedia_passkey_passwordless_{$role}", false) || get_option("acemedia_passkey_2fa_required_{$role}", false) ) { return true; } } return false; } private function get_redirect_url($user) { $redirect_url = admin_url(); foreach ($user->roles as $role) { $role_redirect_key = "acemedia_login_block_redirect_{$role}"; $role_redirect_url = get_option($role_redirect_key); if (!empty($role_redirect_url)) { $redirect_url = esc_url($role_redirect_url); break; } } return wp_validate_redirect(apply_filters('login_redirect', $redirect_url, '', $user), admin_url()); } private function get_user_from_login($login) { $login = sanitize_text_field($login); $user = get_user_by('login', $login); if (!$user && is_email($login)) { $user = get_user_by('email', $login); } return $user ?: null; } private function generate_state($length = 16) { return bin2hex(random_bytes($length)); } /** Claim a transient once across workers, including persistent object caches. */ private static function take_once($prefix, $token) { if (!is_string($token) || $token === '') { return false; } $key = 'acemedia_passkey_claim_' . hash('sha256', $prefix . $token); $previous = get_option($key, false); // All issued states live at most ten minutes. Never reclaim a lock while // its original state can still be valid, even if a worker was suspended. if (is_string($previous) && (int) $previous < time() - 11 * MINUTE_IN_SECONDS) { self::release_claim($key, $previous); } $owner = time() . ':' . bin2hex(random_bytes(16)); global $wpdb; // add_option() can upsert on a concurrent cache miss. A plain unique-key // INSERT IGNORE is required for exclusive ownership across workers. $claimed = $wpdb->query($wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", $key, $owner )); wp_cache_delete($key, 'options'); if ($claimed !== 1) { return false; } if (!wp_next_scheduled('acemedia_passkey_claim_cleanup')) { wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'acemedia_passkey_claim_cleanup'); } try { $data = get_transient($prefix . $token); delete_transient($prefix . $token); return $data; } finally { self::release_claim($key, $owner); } } private static function release_claim($key, $owner) { global $wpdb; // Conditional deletion prevents an old worker releasing another claim. $wpdb->delete($wpdb->options, ['option_name' => $key, 'option_value' => $owner], ['%s', '%s']); wp_cache_delete($key, 'options'); } public static function cleanup_expired_claims() { global $wpdb; $like = $wpdb->esc_like('acemedia_passkey_claim_') . '%'; $rows = $wpdb->get_results($wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $like )); $pending = false; foreach ($rows as $row) { if ((int) $row->option_value < time() - 11 * MINUTE_IN_SECONDS) { self::release_claim($row->option_name, $row->option_value); } else { $pending = true; } } if ($pending && !wp_next_scheduled('acemedia_passkey_claim_cleanup')) { wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'acemedia_passkey_claim_cleanup'); } } private function store_state($prefix, $state, array $data, $ttl) { set_transient($prefix . $state, $data, $ttl); } private function get_state($prefix, $state) { return get_transient($prefix . $state); } private function delete_state($prefix, $state) { delete_transient($prefix . $state); } private function base64url_encode($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } private function base64url_decode($data) { $remainder = strlen($data) % 4; if ($remainder) { $data .= str_repeat('=', 4 - $remainder); } $decoded = base64_decode(strtr($data, '-_', '+/')); return $decoded === false ? '' : $decoded; } private function buffer_to_base64url($buffer) { if ($buffer instanceof ByteBuffer) { if (method_exists($buffer, 'getBase64Url')) { return $buffer->getBase64Url(); } if (method_exists($buffer, 'getBinaryString')) { return $this->base64url_encode($buffer->getBinaryString()); } } if (is_string($buffer)) { return $this->base64url_encode($buffer); } return ''; } private function extract_credential_id($createData) { $candidates = ['credentialId', 'credentialID', 'credId', 'id']; foreach ($candidates as $candidate) { if (isset($createData->$candidate)) { return $this->buffer_to_base64url($createData->$candidate); } } foreach (get_object_vars($createData) as $value) { if ($value instanceof ByteBuffer) { return $this->buffer_to_base64url($value); } } return ''; } private function extract_public_key($createData) { $candidates = ['credentialPublicKey', 'publicKey', 'publicKeyPem', 'credentialPublicKeyPem']; foreach ($candidates as $candidate) { if (isset($createData->$candidate) && is_string($createData->$candidate)) { return $createData->$candidate; } } foreach (get_object_vars($createData) as $value) { if (is_string($value) && strpos($value, 'BEGIN PUBLIC KEY') !== false) { return $value; } } return ''; } private function extract_counter($createData, $webauthn) { if ($createData) { $candidates = ['signatureCounter', 'signCount', 'counter']; foreach ($candidates as $candidate) { if (isset($createData->$candidate)) { return (int) $createData->$candidate; } } } $counter = $webauthn ? $webauthn->getSignatureCounter() : null; return $counter === null ? 0 : (int) $counter; } private function is_locked_out($user_id) { $locked_until = (int) get_user_meta($user_id, '_acemedia_login_locked_until', true); return $locked_until && time() < $locked_until; } } new Passkeys();