|null
*/
private $passkey_signon_context = null;
/**
* Number of passkey wp_signon bridge attempts in the current request.
*
* @var int
*/
private $passkey_signon_attempts = 0;
/**
* Singleton instance.
*
* @var self|null
*/
private static $instance = null;
const TABLE_CREDENTIALS = 'advapafo_credentials';
const TABLE_RATE_LIMITS = 'advapafo_rate_limits';
const DEFAULT_MAX_PASSKEYS = 0;
const LAST_USED_PILL_DEFAULT_FRESHNESS_DAYS = 90;
const USER_META_LAST_PASSKEY_LOGIN = 'advapafo_last_passkey_login_at';
// ──────────────────────────────────────────────────────────
// Boot
// ──────────────────────────────────────────────────────────
/**
* Check whether passkeys are globally enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
if ( defined( 'ADVAPAFO_ENABLED' ) ) {
return (bool) ADVAPAFO_ENABLED;
}
return (bool) advapafo_get_setting( 'enabled', true );
}
/**
* Public static wrapper so shortcodes and external code can check eligibility
* without instantiating the full class.
*
* @param WP_User $user User instance.
* @return bool
*/
public static function user_is_eligible( WP_User $user ): bool {
$allowed_roles = array_filter( array_map( 'sanitize_key', (array) advapafo_get_setting( 'eligible_roles', array( 'administrator' ) ) ) );
if ( empty( $allowed_roles ) ) {
$allowed_roles = array( 'administrator' );
}
$eligible = advapafo_get_setting( 'eligible_user', null, array( 'user' => $user ) );
if ( null !== $eligible ) {
return (bool) $eligible;
}
return ! empty( array_intersect( (array) $user->roles, $allowed_roles ) );
}
/**
* Constructor.
*/
public function __construct() {
if ( ! self::is_enabled() ) {
return;
}
if ( ! class_exists( 'lbuchs\\WebAuthn\\WebAuthn' ) ) {
add_action( 'admin_notices', array( $this, 'render_missing_dependency_notice' ) );
return;
}
self::$instance = $this;
// One-time schema patch: remove raw AAGUID storage from the shared credentials table.
if ( is_admin() && current_user_can( 'manage_options' ) && (int) get_option( 'advapafo_credentials_schema_v2', 0 ) !== 1 ) {
self::ensure_credentials_schema_v2();
update_option( 'advapafo_credentials_schema_v2', 1, false );
}
// Profile hooks.
add_action( 'show_user_profile', array( $this, 'render_profile_section' ) );
add_action( 'edit_user_profile', array( $this, 'render_profile_section' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_profile_assets' ) );
// Login hooks.
add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_assets' ) );
// Admin: Users list column.
add_filter( 'manage_users_columns', array( $this, 'users_column_header' ) );
add_filter( 'manage_users_custom_column', array( $this, 'users_column_content' ), 10, 3 );
// Admin: passkey setup nudge notice.
add_action( 'admin_notices', array( $this, 'render_setup_notice' ) );
add_action( 'wp_ajax_advapafo_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) );
// Cron: scheduled cleanup.
add_action( 'advapafo_scheduled_cleanup', array( $this, 'run_scheduled_cleanup' ) );
// AJAX — authenticated (registration + revocation).
add_action( 'wp_ajax_advapafo_begin_registration', array( $this, 'ajax_begin_registration' ) );
add_action( 'wp_ajax_advapafo_finish_registration', array( $this, 'ajax_finish_registration' ) );
add_action( 'wp_ajax_advapafo_revoke_credential', array( $this, 'ajax_revoke_credential' ) );
// AJAX — public + authenticated (login).
add_action( 'wp_ajax_nopriv_advapafo_begin_login', array( $this, 'ajax_begin_login' ) );
add_action( 'wp_ajax_advapafo_begin_login', array( $this, 'ajax_begin_login' ) );
add_action( 'wp_ajax_nopriv_advapafo_finish_login', array( $this, 'ajax_finish_login' ) );
add_action( 'wp_ajax_advapafo_finish_login', array( $this, 'ajax_finish_login' ) );
add_action( 'wp_ajax_nopriv_advapafo_get_last_used_hint', array( $this, 'ajax_get_last_used_hint' ) );
add_action( 'wp_ajax_advapafo_get_last_used_hint', array( $this, 'ajax_get_last_used_hint' ) );
// Persist a user-level timestamp after successful passkey login.
add_action( 'advapafo_passkey_login_success', array( $this, 'handle_passkey_login_success' ), 10, 3 );
}
/**
* Return singleton instance when initialized.
*
* @return self|null
*/
public static function get_instance(): ?self {
return self::$instance;
}
// ──────────────────────────────────────────────────────────
// Database
// ──────────────────────────────────────────────────────────
/**
* Create plugin database tables.
*/
public static function create_tables(): void {
global $wpdb;
$charset = $wpdb->get_charset_collate();
$cred = $wpdb->prefix . self::TABLE_CREDENTIALS;
$rate = $wpdb->prefix . self::TABLE_RATE_LIMITS;
$sql_credentials = "CREATE TABLE $cred (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT(20) UNSIGNED NOT NULL,
credential_id VARCHAR(512) NOT NULL,
credential_id_hash CHAR(64) NOT NULL,
credential_public_key LONGTEXT NOT NULL,
sign_count BIGINT(20) UNSIGNED DEFAULT 0,
transports VARCHAR(191) DEFAULT NULL,
credential_label VARCHAR(191) DEFAULT 'Passkey',
backed_up TINYINT(1) DEFAULT 0,
created_at DATETIME NOT NULL,
last_used_at DATETIME DEFAULT NULL,
revoked_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY credential_id_hash (credential_id_hash),
KEY user_id (user_id),
KEY revoked_at (revoked_at)
) $charset;";
$sql_rate = "CREATE TABLE $rate (
bucket_key VARCHAR(191) NOT NULL,
failure_count INT(10) UNSIGNED NOT NULL DEFAULT 0,
window_expires_at DATETIME DEFAULT NULL,
lock_expires_at DATETIME DEFAULT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (bucket_key),
KEY lock_expires_at (lock_expires_at),
KEY window_expires_at (window_expires_at)
) $charset;";
$logs = $wpdb->prefix . 'advapafo_logs';
$sql_logs = "CREATE TABLE $logs (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
event_type VARCHAR(64) NOT NULL,
log_timestamp DATETIME NOT NULL,
user_agent VARCHAR(512) DEFAULT NULL,
log_data LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY event_type (event_type),
KEY log_timestamp (log_timestamp)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql_credentials );
dbDelta( $sql_rate );
dbDelta( $sql_logs );
self::ensure_credentials_schema_v2();
update_option( 'advapafo_credentials_schema_v2', 1, false );
}
/**
* Ensure credential table schema upgrades are applied.
*/
private static function ensure_credentials_schema_v2(): void {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_CREDENTIALS;
$like = $wpdb->esc_like( $table_name );
$row = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if ( $table_name !== $row ) {
return;
}
$column = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$wpdb->prefix}advapafo_credentials LIKE %s", 'aaguid' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
if ( ! empty( $column ) ) {
$wpdb->query( "ALTER TABLE {$wpdb->prefix}advapafo_credentials DROP COLUMN aaguid" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
}
}
/**
* Drop plugin-owned database tables.
*/
public static function drop_tables(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}advapafo_credentials" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}advapafo_rate_limits" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}advapafo_logs" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
}
// ──────────────────────────────────────────────────────────
// Admin notices
// ──────────────────────────────────────────────────────────
/**
* Render admin notice when WebAuthn dependency is missing.
*/
public function render_missing_dependency_notice(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
echo '
' .
esc_html__( 'Advanced Passkeys for Secure Login: The WebAuthn library is missing. Run composer install in the advanced-passkey-login plugin directory.', 'advanced-passkey-login' ) .
'
';
}
// ──────────────────────────────────────────────────────────
// Admin: passkey setup nudge notice
// ──────────────────────────────────────────────────────────
/**
* Render setup notice for eligible admins without passkeys.
*/
public function render_setup_notice(): void {
if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) {
return;
}
if ( function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
if ( ! $screen || ! in_array( (string) $screen->base, array( 'profile', 'user-edit' ), true ) ) {
return;
}
}
if ( ! (int) get_option( 'advapafo_show_setup_notice', 1 ) ) {
return;
}
$user = wp_get_current_user();
if ( ! $this->is_eligible_user( $user ) ) {
return;
}
$dismissed_key = 'advapafo_notice_dismissed_' . (int) $user->ID;
if ( get_user_meta( (int) $user->ID, $dismissed_key, true ) ) {
return;
}
if ( $this->has_any_user_credentials( (int) $user->ID ) ) {
return;
}
$profile_url = admin_url( 'profile.php#advapafo-profile-section' );
$nonce = wp_create_nonce( 'advapafo_dismiss_notice' );
?>
Register a passkey now — sign in with Face ID, Touch ID, or a security key, no password needed.', 'advanced-passkey-login' ),
array( 'a' => array( 'href' => array() ) )
),
esc_url( $profile_url )
);
?>
is_post_request() ) {
wp_send_json_error( null, 405 );
}
if ( ! check_ajax_referer( 'advapafo_dismiss_notice', 'nonce', false ) ) {
wp_send_json_error( null, 403 );
}
if ( ! is_user_logged_in() ) {
wp_send_json_error( null, 403 );
}
$user_id = get_current_user_id();
if ( $user_id <= 0 ) {
wp_send_json_error( null, 403 );
}
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( null, 403 );
}
update_user_meta( $user_id, 'advapafo_notice_dismissed_' . $user_id, 1 );
wp_send_json_success();
}
// ──────────────────────────────────────────────────────────
// Admin: Users list passkey column
// ──────────────────────────────────────────────────────────
/**
* Register users table header for passkey counts.
*
* @param array $columns Users table columns.
* @return array
*/
public function users_column_header( array $columns ): array {
$columns['advapafo_passkeys'] = __( 'Passkeys', 'advanced-passkey-login' );
return $columns;
}
/**
* Render users list custom column contents.
*
* @param string $output Existing output.
* @param string $column_name Column key.
* @param int $user_id User ID.
* @return string
*/
public function users_column_content( string $output, string $column_name, int $user_id ): string {
if ( 'advapafo_passkeys' !== $column_name ) {
return $output;
}
$user = get_user_by( 'id', $user_id );
if ( ! $user || ! $this->is_eligible_user( $user ) ) {
return '— ';
}
$count = $this->count_user_credentials( $user_id );
if ( 0 === $count ) {
return '0 ';
}
$url = add_query_arg(
array(
'user_id' => $user_id,
'anchor' => 'advapafo-profile-section',
),
admin_url( 'user-edit.php' )
);
return sprintf(
'%d ',
esc_url( $url ),
esc_attr(
sprintf(
/* translators: %d passkey count, %s username */
__( '%1$d passkey(s) for %2$s — click to manage', 'advanced-passkey-login' ),
$count,
$user->user_login
)
),
$count
);
}
/**
* Register sortable keys for users list custom columns.
*
* @param array $columns Users table columns.
* @return array
*/
public function users_column_sortable( array $columns ): array {
$columns['advapafo_passkeys'] = 'advapafo_passkeys';
return $columns;
}
// ──────────────────────────────────────────────────────────
// Cron: scheduled cleanup
// ──────────────────────────────────────────────────────────
/**
* Schedule recurring cleanup cron hook.
*/
public static function schedule_cron(): void {
if ( ! wp_next_scheduled( 'advapafo_scheduled_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'advapafo_scheduled_cleanup' );
}
}
/**
* Unschedule plugin cleanup cron hook.
*/
public static function unschedule_cron(): void {
if ( function_exists( 'wp_clear_scheduled_hook' ) ) {
wp_clear_scheduled_hook( 'advapafo_scheduled_cleanup' );
return;
}
$timestamp = wp_next_scheduled( 'advapafo_scheduled_cleanup' );
while ( $timestamp ) {
wp_unschedule_event( $timestamp, 'advapafo_scheduled_cleanup' );
$timestamp = wp_next_scheduled( 'advapafo_scheduled_cleanup' );
}
}
/**
* Run recurring cleanup tasks.
*/
public function run_scheduled_cleanup(): void {
global $wpdb;
// Purge expired rate-limit rows.
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
"DELETE FROM {$wpdb->prefix}advapafo_rate_limits WHERE (lock_expires_at IS NULL OR lock_expires_at <= UTC_TIMESTAMP()) AND (window_expires_at IS NULL OR window_expires_at <= UTC_TIMESTAMP())" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
);
// Purge log rows older than the configured retention window.
$keep_days = max( 7, (int) get_option( 'advapafo_log_retention_days', 90 ) );
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"DELETE FROM {$wpdb->prefix}advapafo_logs WHERE log_timestamp < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$keep_days
)
);
}
// ──────────────────────────────────────────────────────────
// Asset enqueuing
// ──────────────────────────────────────────────────────────
/**
* Enqueue profile assets on eligible user profile screens.
*
* @param string $hook Current admin hook suffix.
*/
public function enqueue_profile_assets( string $hook ): void {
if ( 'profile.php' !== $hook && 'user-edit.php' !== $hook ) {
return;
}
$screen_uid = get_current_user_id();
if ( 'user-edit.php' === $hook ) {
$advapafo_requested_user_id = filter_input( INPUT_GET, 'user_id', FILTER_SANITIZE_NUMBER_INT );
if ( null !== $advapafo_requested_user_id && false !== $advapafo_requested_user_id ) {
$screen_uid = absint( $advapafo_requested_user_id );
}
}
if ( $screen_uid < 1 ) {
return;
}
$target_user = get_user_by( 'id', $screen_uid );
if ( ! $target_user || ! $this->is_eligible_user( $target_user ) ) {
return;
}
wp_enqueue_script(
'advapafo-profile',
ADVAPAFO_PLUGIN_URL . 'admin/js/advapafo-profile.js',
array(),
ADVAPAFO_VERSION,
true
);
wp_localize_script(
'advapafo-profile',
'ADVAPAFOProfile',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'advapafo_profile' ),
'messages' => array(
'labelPlaceholder' => __( 'e.g. iPhone 15, YubiKey 5', 'advanced-passkey-login' ),
'starting' => __( 'Starting passkey registration…', 'advanced-passkey-login' ),
'success' => __( 'Passkey registered successfully.', 'advanced-passkey-login' ),
'failed' => __( 'Passkey registration failed. Try again.', 'advanced-passkey-login' ),
'notSupported' => __( 'This browser does not support passkeys.', 'advanced-passkey-login' ),
'mobileHint' => __( 'Tip: open this page on your phone to save a passkey to iCloud Keychain or Google Password Manager.', 'advanced-passkey-login' ),
'confirmRevoke' => __( 'Revoke this passkey? You will need to re-register to use it again.', 'advanced-passkey-login' ),
'revokeFailed' => __( 'Failed to revoke passkey.', 'advanced-passkey-login' ),
'limitReached' => __( 'You have reached the maximum number of passkeys. Revoke an existing one to add a new one.', 'advanced-passkey-login' ),
),
)
);
wp_enqueue_style( 'advapafo-admin', ADVAPAFO_PLUGIN_URL . 'admin/css/advapafo-admin.css', array(), ADVAPAFO_VERSION );
}
/**
* Enqueue login-page assets.
*/
public function enqueue_login_assets(): void {
$conditional_ui_enabled = self::is_conditional_ui_enabled();
wp_enqueue_script(
'advapafo-login',
ADVAPAFO_PLUGIN_URL . 'admin/js/advapafo-login.js',
array(),
ADVAPAFO_VERSION,
true
);
wp_localize_script(
'advapafo-login',
'ADVAPAFOLogin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'advapafo_login' ),
'conditionalUi' => $conditional_ui_enabled,
'messages' => array(
'notSupported' => __( 'Passkeys are unavailable here. Use HTTPS (or localhost) in a passkey-capable browser, or sign in with your password.', 'advanced-passkey-login' ),
'genericError' => __( 'Passkey sign-in failed. Please try again or use your password.', 'advanced-passkey-login' ),
'signingIn' => __( 'Signing in…', 'advanced-passkey-login' ),
'usernamelessUnsupported' => __( 'Username-free passkey sign-in is not available with this browser or authenticator. Enter your username or email, then try passkey sign-in again.', 'advanced-passkey-login' ),
),
)
);
wp_enqueue_style( 'advapafo-login', ADVAPAFO_PLUGIN_URL . 'admin/css/advapafo-admin.css', array(), ADVAPAFO_VERSION );
}
/**
* Whether Conditional UI (passkey autofill) is enabled.
*
* Developers can force-enable/disable this through advapafo_local_configuration.
*
* @return bool
*/
public static function is_conditional_ui_enabled(): bool {
return (bool) advapafo_get_setting( 'conditional_ui_enabled', false );
}
// ──────────────────────────────────────────────────────────
// Profile section UI
// ──────────────────────────────────────────────────────────
/**
* Render passkey profile section for eligible users.
*
* @param WP_User $user User being edited.
*/
public function render_profile_section( WP_User $user ): void {
if ( ! $this->is_eligible_user( $user ) ) {
return;
}
if ( (int) get_current_user_id() !== (int) $user->ID && ! current_user_can( 'edit_users' ) ) {
return;
}
$credentials = $this->get_user_credentials_meta( (int) $user->ID );
$max_passkeys = $this->get_max_passkeys_per_user( $user );
$at_limit = count( $credentials ) >= $max_passkeys;
$max_display = $max_passkeys >= 999999 ? __( 'unlimited', 'advanced-passkey-login' ) : (string) $max_passkeys;
?>
credential_label ? (string) $cred->credential_label : __( 'Passkey', 'advanced-passkey-login' ) ); ?>
format_utc_datetime_for_display( (string) $cred->created_at ) ); ?>
last_used_at ? esc_html( $this->format_utc_datetime_for_display( (string) $cred->last_used_at ) ) : esc_html__( 'Never', 'advanced-passkey-login' ); ?>
is_post_request() ) {
wp_send_json_error( array( 'message' => 'Invalid request method.' ), 405 );
}
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
$ip = $this->get_client_ip();
if ( $this->is_locked_out( 'reg_begin_ip', $ip ) ) {
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! $this->is_secure_context() ) {
$this->record_failure( 'reg_begin_ip', $ip );
wp_send_json_error( array( 'message' => 'Passkeys require HTTPS.' ), 400 );
}
if ( ! check_ajax_referer( 'advapafo_profile', 'nonce', false ) ) {
$this->record_failure( 'reg_begin_ip', $ip );
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
$user = wp_get_current_user();
if ( $this->is_locked_out( 'reg_begin_user', (int) $user->ID ) ) {
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! $this->is_eligible_user( $user ) ) {
$this->record_failure( 'reg_begin_ip', $ip );
wp_send_json_error( array( 'message' => 'Your account is not eligible for passkeys.' ), 403 );
}
if ( ! current_user_can( 'edit_user', (int) $user->ID ) ) {
$this->record_failure( 'reg_begin_ip', $ip );
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
// Enforce per-user passkey cap.
$max = $this->get_max_passkeys_per_user( $user );
$count = $this->count_user_credentials( (int) $user->ID );
if ( $count >= $max ) {
$this->record_failure( 'reg_begin_ip', $ip );
wp_send_json_error( array( 'message' => 'You have reached the maximum number of passkeys for your account.' ), 400 );
}
try {
$web_authn = $this->new_webauthn();
$exclude_ids = $this->get_credential_ids_binary( (int) $user->ID );
$create_args = $web_authn->getCreateArgs(
'wp-user-' . (string) $user->ID,
(string) $user->user_login,
(string) $user->display_name,
$this->get_registration_challenge_ttl(),
true,
$this->get_user_verification(),
null,
$exclude_ids
);
$token = wp_generate_password( 32, false, false );
set_transient(
'advapafo_reg_' . $token,
array(
'user_id' => (int) $user->ID,
'challenge' => base64_encode( $web_authn->getChallenge()->getBinaryString() ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- WebAuthn challenge binary is stored in transient-safe format.
),
$this->get_registration_challenge_ttl()
);
$this->clear_failures( 'reg_begin_ip', $ip );
$this->clear_failures( 'reg_begin_user', (int) $user->ID );
wp_send_json_success(
array(
'options' => $create_args,
'token' => $token,
)
);
} catch ( \Throwable $e ) {
$this->record_failure( 'reg_begin_ip', $ip );
$this->record_failure( 'reg_begin_user', (int) $user->ID );
$this->log_event( 'registration_begin_failed', array( 'message' => $e->getMessage() ) );
wp_send_json_error( array( 'message' => 'Could not start passkey registration.' ), 500 );
}
}
// ──────────────────────────────────────────────────────────
// AJAX: Finish Registration
// ──────────────────────────────────────────────────────────
/**
* Complete passkey registration ceremony.
*
* @throws \RuntimeException When WebAuthn registration payload validation fails.
*/
public function ajax_finish_registration(): void {
if ( ! $this->is_post_request() ) {
wp_send_json_error( array( 'message' => 'Invalid request method.' ), 405 );
}
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
$ip = $this->get_client_ip();
if ( $this->is_locked_out( 'reg_finish_ip', $ip ) ) {
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! $this->is_secure_context() ) {
$this->record_failure( 'reg_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Passkeys require HTTPS.' ), 400 );
}
if ( ! check_ajax_referer( 'advapafo_profile', 'nonce', false ) ) {
$this->record_failure( 'reg_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
$user = wp_get_current_user();
if ( $this->is_locked_out( 'reg_finish_user', (int) $user->ID ) ) {
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! current_user_can( 'edit_user', (int) $user->ID ) ) {
$this->record_failure( 'reg_finish_ip', $ip );
$this->record_failure( 'reg_finish_user', (int) $user->ID );
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
if ( ! $this->is_eligible_user( $user ) ) {
$this->record_failure( 'reg_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Your account is not eligible for passkeys.' ), 403 );
}
$token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
if ( ! $this->is_valid_token( $token ) ) {
$this->record_failure( 'reg_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Registration session expired.' ), 400 );
}
$state = get_transient( 'advapafo_reg_' . $token );
if ( ! $state || empty( $state['challenge'] ) || (int) $user->ID !== (int) $state['user_id'] ) {
$this->record_failure( 'reg_finish_ip', $ip );
$this->record_failure( 'reg_finish_user', (int) $user->ID );
wp_send_json_error( array( 'message' => 'Registration session expired.' ), 400 );
}
delete_transient( 'advapafo_reg_' . $token );
$client_data = isset( $_POST['clientDataJSON'] ) ? sanitize_text_field( wp_unslash( $_POST['clientDataJSON'] ) ) : '';
$attestation = isset( $_POST['attestationObject'] ) ? sanitize_text_field( wp_unslash( $_POST['attestationObject'] ) ) : '';
$label_raw = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
$label = '' !== $label_raw ? substr( $label_raw, 0, 100 ) : 'Passkey';
// Validate transports: decode JSON, allowlist known values, re-encode.
$transports = '';
$transports_input = '';
$transports_raw = isset( $_POST['transports'] ) ? sanitize_text_field( wp_unslash( $_POST['transports'] ) ) : '';
if ( is_string( $transports_raw ) ) {
$transports_input = wp_check_invalid_utf8( $transports_raw );
}
if ( '' !== $transports_input ) {
$raw_transports = json_decode( $transports_input, true );
if ( is_array( $raw_transports ) ) {
$allowed_transports = array( 'usb', 'nfc', 'ble', 'internal', 'hybrid', 'cable', 'smart-card' );
$clean_transports = array_values( array_intersect( array_map( 'sanitize_key', $raw_transports ), $allowed_transports ) );
$transports = (string) wp_json_encode( $clean_transports );
}
}
if ( ! $this->is_valid_b64url_payload( $client_data ) || ! $this->is_valid_b64url_payload( $attestation ) ) {
$this->record_failure( 'reg_finish_ip', $ip );
$this->record_failure( 'reg_finish_user', (int) $user->ID );
wp_send_json_error( array( 'message' => 'Invalid passkey response.' ), 400 );
}
try {
$web_authn = $this->new_webauthn();
$challenge = base64_decode( $state['challenge'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decodes previously stored WebAuthn challenge bytes.
if ( false === $challenge ) {
throw new \RuntimeException( 'Challenge decode failed' );
}
$result = $web_authn->processCreate(
$this->decode_b64url( $client_data ),
$this->decode_b64url( $attestation ),
$challenge,
'required' === $this->get_user_verification(),
true,
false
);
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$cred_id = $this->encode_b64url( $result->credentialId );
$cred_hash = hash( 'sha256', $cred_id );
$aaguid = $this->format_aaguid_for_logging( isset( $result->AAGUID ) && is_string( $result->AAGUID ) ? $result->AAGUID : '' );
global $wpdb;
$table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- custom credentials table write during registration.
$table,
array(
'user_id' => (int) $user->ID,
'credential_id' => $cred_id,
'credential_id_hash' => $cred_hash,
'credential_public_key' => (string) $result->credentialPublicKey,
'sign_count' => null !== $result->signatureCounter ? (int) $result->signatureCounter : 0,
'transports' => $transports,
'credential_label' => $label,
'backed_up' => ! empty( $result->isBackedUp ) ? 1 : 0,
'created_at' => gmdate( 'Y-m-d H:i:s' ),
'last_used_at' => null,
'revoked_at' => null,
),
array( '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s' )
);
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( $wpdb->last_error ) {
throw new \RuntimeException( $wpdb->last_error );
}
$this->log_event(
'registered',
array(
'user_id' => (int) $user->ID,
'credential_hash' => $cred_hash,
'aaguid' => $aaguid,
)
);
/**
* Fires after a passkey is successfully registered.
*
* @param int $user_id WordPress user ID.
* @param string $credential_hash SHA-256 hash of the credential ID.
*/
do_action( 'advapafo_passkey_registered', (int) $user->ID, $cred_hash );
$this->clear_failures( 'reg_finish_ip', $ip );
$this->clear_failures( 'reg_finish_user', (int) $user->ID );
wp_send_json_success( array( 'message' => 'Passkey registered.' ) );
} catch ( \Throwable $e ) {
$this->record_failure( 'reg_finish_ip', $ip );
$this->record_failure( 'reg_finish_user', (int) $user->ID );
$this->log_event(
'registration_failed',
array(
'user_id' => (int) $user->ID,
'message' => $e->getMessage(),
)
);
wp_send_json_error( array( 'message' => 'Passkey registration failed.' ), 400 );
}
}
// ──────────────────────────────────────────────────────────
// AJAX: Revoke Credential
// ──────────────────────────────────────────────────────────
/**
* Revoke an existing passkey credential.
*/
public function ajax_revoke_credential(): void {
if ( ! $this->is_post_request() ) {
wp_send_json_error( array( 'message' => 'Invalid request method.' ), 405 );
}
if ( ! is_user_logged_in() ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
$ip = $this->get_client_ip();
if ( $this->is_locked_out( 'revoke_ip', $ip ) ) {
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! $this->is_secure_context() ) {
$this->record_failure( 'revoke_ip', $ip );
wp_send_json_error( array( 'message' => 'Passkeys require HTTPS.' ), 400 );
}
if ( ! check_ajax_referer( 'advapafo_profile', 'nonce', false ) ) {
$this->record_failure( 'revoke_ip', $ip );
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
$user = wp_get_current_user();
$cred_row_id_raw = filter_input( INPUT_POST, 'credentialId', FILTER_SANITIZE_NUMBER_INT );
$cred_row_id = is_string( $cred_row_id_raw ) ? absint( $cred_row_id_raw ) : 0;
if ( $cred_row_id < 1 ) {
wp_send_json_error( array( 'message' => 'Invalid credential.' ), 400 );
}
global $wpdb;
$resolved = null;
foreach ( $this->get_credentials_table_names_ordered() as $candidate_table ) {
$candidate = $this->get_active_credential_by_row_id_from_table( $candidate_table, $cred_row_id );
if ( $candidate ) {
$resolved = array(
'table' => $candidate_table,
'row' => $candidate,
);
break;
}
}
$cred = is_array( $resolved ) && isset( $resolved['row'] ) && is_object( $resolved['row'] ) ? $resolved['row'] : null;
if ( ! $cred ) {
wp_send_json_error( array( 'message' => 'Credential not found.' ), 404 );
}
if ( ! current_user_can( 'edit_user', (int) $cred->user_id ) ) {
wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
}
$resolved_table = isset( $resolved['table'] ) && is_string( $resolved['table'] ) ? $resolved['table'] : '';
if ( '' === $resolved_table || ! $this->revoke_credential_by_row_id_from_table( $resolved_table, $cred_row_id ) ) {
wp_send_json_error( array( 'message' => 'Failed to revoke passkey.' ), 500 );
}
$cred_hash = isset( $cred->credential_id_hash ) ? strtolower( trim( (string) $cred->credential_id_hash ) ) : '';
if ( '' !== $cred_hash ) {
foreach ( $this->get_credentials_table_names_ordered() as $candidate_table ) {
if ( $candidate_table === $resolved_table ) {
continue;
}
$this->revoke_credential_by_hash_from_table( $candidate_table, $cred_hash, (int) $cred->user_id );
}
}
$this->log_event(
'revoked',
array(
'user_id' => (int) $cred->user_id,
'credential_id' => $cred_row_id,
)
);
/**
* Fires after a passkey is revoked.
*
* @param int $user_id WordPress user ID.
* @param int $credential_id DB row ID of the revoked credential.
*/
do_action( 'advapafo_passkey_revoked', (int) $cred->user_id, $cred_row_id );
wp_send_json_success( array( 'message' => 'Passkey revoked.' ) );
}
/**
* Fetch a single active credential row by numeric row ID from a supported table.
*
* @param string $table Table name.
* @param int $row_id Credential row ID.
* @return object|null
*/
private function get_active_credential_by_row_id_from_table( string $table, int $row_id ): ?object {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( $row_id < 1 || ( $table !== $lite_table && $table !== $shared_table ) ) {
return null;
}
if ( $table === $lite_table ) {
return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- credential revoke requires immediate row lookup.
$wpdb->prepare(
"SELECT id, user_id, credential_id_hash FROM {$wpdb->prefix}advapafo_credentials WHERE id = %d AND revoked_at IS NULL LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$row_id
)
);
}
return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- credential revoke requires immediate row lookup.
$wpdb->prepare(
"SELECT id, user_id, credential_id_hash FROM {$wpdb->prefix}wpk_credentials WHERE id = %d AND revoked_at IS NULL LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$row_id
)
);
}
/**
* Revoke a credential row by row ID in a supported table.
*
* @param string $table Table name.
* @param int $row_id Credential row ID.
* @return bool
*/
private function revoke_credential_by_row_id_from_table( string $table, int $row_id ): bool {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( $row_id < 1 || ( $table !== $lite_table && $table !== $shared_table ) ) {
return false;
}
$updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- explicit credential revoke update.
$table,
array( 'revoked_at' => current_time( 'mysql' ) ),
array(
'id' => $row_id,
'revoked_at' => null,
),
array( '%s' ),
array( '%d', '%s' )
);
return is_int( $updated ) && $updated > 0;
}
/**
* Revoke any duplicate active credentials matching hash+user in a supported table.
*
* @param string $table Table name.
* @param string $credential_hash Credential SHA-256 hash.
* @param int $user_id User ID.
* @return void
*/
private function revoke_credential_by_hash_from_table( string $table, string $credential_hash, int $user_id ): void {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( '' === $credential_hash || $user_id < 1 || ( $table !== $lite_table && $table !== $shared_table ) ) {
return;
}
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- cleanup revoke for duplicated credential entries across compatibility tables.
$table,
array( 'revoked_at' => current_time( 'mysql' ) ),
array(
'credential_id_hash' => $credential_hash,
'user_id' => $user_id,
'revoked_at' => null,
),
array( '%s' ),
array( '%s', '%d', '%s' )
);
}
// ──────────────────────────────────────────────────────────
// AJAX: Begin Login
// ──────────────────────────────────────────────────────────
/**
* Start passkey login ceremony.
*
* @throws \RuntimeException When challenge generation returns an empty value.
* @throws \Throwable When upstream WebAuthn option generation fails in non-usernameless mode.
*/
public function ajax_begin_login(): void {
$ip = $this->get_client_ip();
$login = isset( $_POST['login'] ) ? sanitize_text_field( wp_unslash( $_POST['login'] ) ) : '';
$is_usernameless_request = ( '' === $login );
$ip_bucket_prefix = $is_usernameless_request ? 'login_begin_ip_usernameless' : 'login_begin_ip';
if ( ! $this->is_post_request() ) {
$this->record_failure( $ip_bucket_prefix, $ip );
wp_send_json_error( array( 'message' => 'Invalid request method.' ), 405 );
}
if ( $this->is_locked_out( $ip_bucket_prefix, $ip ) ) {
$this->log_event( 'login_rate_limited', array( 'ip' => $ip ) );
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! $this->is_secure_context() ) {
$this->record_failure( $ip_bucket_prefix, $ip );
wp_send_json_error( array( 'message' => 'Passkeys require HTTPS.' ), 400 );
}
if ( ! check_ajax_referer( 'advapafo_login', 'nonce', false ) ) {
$this->record_failure( $ip_bucket_prefix, $ip );
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
$generic_err = 'Passkey sign-in could not be started. Please try again.';
if ( '' !== $login && ! $this->is_valid_login_identifier( $login ) ) {
$this->record_failure( $ip_bucket_prefix, $ip );
wp_send_json_error( array( 'message' => $generic_err ), 400 );
}
$login_key = '';
$state_uid = 0;
$cred_rows = array();
// Optional: username-based flow (non-discoverable / legacy credentials).
if ( '' !== $login ) {
$login_key = strtolower( $login );
if ( $this->is_locked_out( 'login_begin_acct', $login_key ) ) {
$this->log_event( 'login_rate_limited', array( 'ip' => $ip ) );
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
$user = $this->resolve_user( $login );
if ( ! $user || ! $this->is_eligible_user( $user ) ) {
$this->record_failure( $ip_bucket_prefix, $ip );
$this->record_failure( 'login_begin_acct', $login_key );
wp_send_json_error( array( 'message' => $generic_err ), 400 );
}
$cred_rows = $this->get_user_credentials( (int) $user->ID );
if ( empty( $cred_rows ) ) {
$this->record_failure( $ip_bucket_prefix, $ip );
$this->record_failure( 'login_begin_acct', $login_key );
wp_send_json_error( array( 'message' => $generic_err ), 400 );
}
$state_uid = (int) $user->ID;
}
try {
$web_authn = $this->new_webauthn();
$cred_ids = array_map( fn( $r ) => $this->decode_b64url( $r->credential_id ), $cred_rows );
$ttl = $this->get_login_challenge_ttl();
$challenge_binary = '';
try {
$get_args = $web_authn->getGetArgs(
$cred_ids,
$ttl,
true,
true,
true,
true,
true,
$this->get_user_verification()
);
$challenge_binary = $web_authn->getChallenge()->getBinaryString();
} catch ( \Throwable $options_error ) {
if ( ! $is_usernameless_request ) {
throw $options_error;
}
// Fallback for usernameless/Conditional UI when upstream option builders are unavailable.
$challenge_binary = random_bytes( 32 );
$get_args = array(
'publicKey' => array(
'timeout' => $ttl * 1000,
'challenge' => $this->encode_b64url( $challenge_binary ),
'userVerification' => $this->get_user_verification(),
'rpId' => $this->get_rp_id(),
'allowCredentials' => array(),
),
);
}
if ( $is_usernameless_request ) {
if ( is_object( $get_args ) ) {
$get_args_array = (array) $get_args;
if ( isset( $get_args_array['publicKey'] ) && is_object( $get_args_array['publicKey'] ) ) {
$public_key_array = (array) $get_args_array['publicKey'];
$public_key_array['allowCredentials'] = array();
$get_args_array['publicKey'] = (object) $public_key_array;
$get_args = (object) $get_args_array;
}
} elseif ( is_array( $get_args ) && isset( $get_args['publicKey'] ) && is_array( $get_args['publicKey'] ) ) {
$get_args['publicKey']['allowCredentials'] = array();
}
}
if ( ! is_string( $challenge_binary ) || '' === $challenge_binary ) {
throw new \RuntimeException( 'Empty WebAuthn challenge.' );
}
$token = wp_generate_password( 32, false, false );
set_transient(
'advapafo_login_' . $token,
array(
'user_id' => $state_uid,
'challenge' => base64_encode( $challenge_binary ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- WebAuthn challenge binary is stored in transient-safe format.
),
$ttl
);
$this->clear_failures( $ip_bucket_prefix, $ip );
if ( '' !== $login_key ) {
$this->clear_failures( 'login_begin_acct', $login_key );
}
wp_send_json_success(
array(
'options' => $get_args,
'token' => $token,
)
);
} catch ( \Throwable $e ) {
$this->record_failure( $ip_bucket_prefix, $ip );
if ( '' !== $login_key ) {
$this->record_failure( 'login_begin_acct', $login_key );
}
$this->log_event( 'login_begin_failed', array( 'message' => $e->getMessage() ) );
wp_send_json_error( array( 'message' => 'Could not start passkey sign-in.' ), 500 );
}
}
// ──────────────────────────────────────────────────────────
// AJAX: Finish Login
// ──────────────────────────────────────────────────────────
/**
* Complete passkey login ceremony.
*
* @throws \RuntimeException When credential assertions fail verification.
*/
public function ajax_finish_login(): void {
$ip = $this->get_client_ip();
if ( ! $this->is_post_request() ) {
$this->record_failure( 'login_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Invalid request method.' ), 405 );
}
if ( $this->is_locked_out( 'login_finish_ip', $ip ) ) {
$this->log_event( 'login_rate_limited', array( 'ip' => $ip ) );
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
if ( ! $this->is_secure_context() ) {
$this->record_failure( 'login_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Passkeys require HTTPS.' ), 400 );
}
if ( ! check_ajax_referer( 'advapafo_login', 'nonce', false ) ) {
$this->record_failure( 'login_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
$token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
if ( ! $this->is_valid_token( $token ) ) {
$this->record_failure( 'login_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Login session expired.' ), 400 );
}
$state = get_transient( 'advapafo_login_' . $token );
if ( ! $state || empty( $state['challenge'] ) || ! isset( $state['user_id'] ) ) {
$this->record_failure( 'login_finish_ip', $ip );
wp_send_json_error( array( 'message' => 'Login session expired.' ), 400 );
}
$state_uid = (int) $state['user_id'];
if ( $state_uid > 0 && $this->is_locked_out( 'login_finish_user', $state_uid ) ) {
$this->log_event( 'login_rate_limited', array( 'ip' => $ip ) );
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
delete_transient( 'advapafo_login_' . $token );
$cred_id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
$client_data = isset( $_POST['clientDataJSON'] ) ? sanitize_text_field( wp_unslash( $_POST['clientDataJSON'] ) ) : '';
$auth_data = isset( $_POST['authenticatorData'] ) ? sanitize_text_field( wp_unslash( $_POST['authenticatorData'] ) ) : '';
$signature = isset( $_POST['signature'] ) ? sanitize_text_field( wp_unslash( $_POST['signature'] ) ) : '';
$user_handle = isset( $_POST['userHandle'] ) ? sanitize_text_field( wp_unslash( $_POST['userHandle'] ) ) : '';
if (
! $this->is_valid_b64url_payload( $cred_id )
|| ! $this->is_valid_b64url_payload( $client_data )
|| ! $this->is_valid_b64url_payload( $auth_data )
|| ! $this->is_valid_b64url_payload( $signature )
|| ( '' !== $user_handle && ! $this->is_valid_b64url_payload( $user_handle ) )
) {
$this->record_failure( 'login_finish_ip', $ip );
if ( $state_uid > 0 ) {
$this->record_failure( 'login_finish_user', $state_uid );
}
wp_send_json_error( array( 'message' => 'Invalid passkey response.' ), 400 );
}
global $wpdb;
$tables = $this->get_credentials_table_names_ordered();
$cred_hash = hash( 'sha256', $cred_id );
if ( 0 === $state_uid && $this->is_locked_out( 'login_finish_cred', $cred_hash ) ) {
$this->log_event( 'login_rate_limited', array( 'ip' => $ip ) );
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
// Fetch stored credential from Lite table first, then shared downgrade-compat table.
$cred = null;
$cred_table = '';
foreach ( $tables as $candidate_table ) {
$candidate = $this->get_credential_by_hash_from_table( $candidate_table, $cred_hash, $state_uid );
if ( $candidate ) {
$cred = $candidate;
$cred_table = $candidate_table;
break;
}
}
if ( ! $cred ) {
$this->record_failure( 'login_finish_ip', $ip );
if ( $state_uid > 0 ) {
$this->record_failure( 'login_finish_user', $state_uid );
} else {
$this->record_failure( 'login_finish_cred', $cred_hash );
}
$this->log_event( 'login_credential_mismatch', array( 'hash' => $cred_hash ) );
wp_send_json_error( array( 'message' => 'Passkey sign-in failed.' ), 400 );
}
if ( $this->is_locked_out( 'login_finish_user', (int) $cred->user_id ) ) {
$this->log_event( 'login_rate_limited', array( 'ip' => $ip ) );
wp_send_json_error( array( 'message' => 'Too many attempts. Please wait and try again.' ), 429 );
}
try {
$web_authn = $this->new_webauthn();
$challenge = base64_decode( $state['challenge'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decodes previously stored WebAuthn challenge bytes.
if ( false === $challenge ) {
throw new \RuntimeException( 'Challenge decode failed' );
}
$web_authn->processGet(
$this->decode_b64url( $client_data ),
$this->decode_b64url( $auth_data ),
$this->decode_b64url( $signature ),
$cred->credential_public_key,
$challenge,
is_null( $cred->sign_count ) ? null : (int) $cred->sign_count,
'required' === $this->get_user_verification(),
true
);
// Verify user handle (discoverable credential).
if ( '' !== $user_handle ) {
$decoded_handle = $this->decode_b64url( $user_handle );
$expected_handle = 'wp-user-' . (string) ( (int) $cred->user_id );
if ( ! hash_equals( $expected_handle, (string) $decoded_handle ) ) {
throw new \RuntimeException( 'User handle mismatch' );
}
}
$user = get_user_by( 'id', (int) $cred->user_id );
if ( ! $user ) {
throw new \RuntimeException( 'User not found' );
}
if ( ! $this->is_eligible_user( $user ) ) {
throw new \RuntimeException( 'User is not eligible for passkey login' );
}
$allow_session = (bool) advapafo_get_setting(
'passkey_allow_session_establishment',
true,
array(
'user' => $user,
'credential_hash' => $cred_hash,
)
);
if ( ! $allow_session ) {
throw new \RuntimeException( 'Passkey session was denied by policy.' );
}
$remember = (bool) advapafo_get_setting( 'passkey_remember_me', false, array( 'user' => $user ) );
$this->establish_authenticated_session( $user, $remember, $cred_hash );
// Update sign count and last-used timestamp.
$next_count = $web_authn->getSignatureCounter();
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- persist signature counter/last-used metadata in custom table.
$cred_table,
array(
'sign_count' => null === $next_count ? (int) $cred->sign_count : (int) $next_count,
'last_used_at' => gmdate( 'Y-m-d H:i:s' ),
),
array( 'id' => (int) $cred->id ),
array( '%d', '%s' ),
array( '%d' )
);
$this->log_event(
'login_success',
array(
'user_id' => (int) $user->ID,
'credential_hash' => $cred_hash,
)
);
do_action( 'advapafo_passkey_session_established', (int) $user->ID, $remember );
/**
* Fires after a successful passkey login.
*
* @param int $user_id WordPress user ID.
* @param string $credential_hash SHA-256 hash of the credential ID.
* @param string $user_agent Raw User-Agent string.
*/
do_action( 'advapafo_passkey_login_success', (int) $user->ID, $cred_hash, sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ) );
$this->clear_failures( 'login_finish_ip', $ip );
$this->clear_failures( 'login_finish_user', (int) $user->ID );
$this->clear_failures( 'login_finish_cred', $cred_hash );
// Determine redirect target.
$request_redirect = '';
$request_redirect_raw = filter_input( INPUT_POST, 'redirect_to', FILTER_SANITIZE_URL );
if ( is_string( $request_redirect_raw ) && '' !== $request_redirect_raw ) {
$request_redirect = esc_url_raw( wp_unslash( $request_redirect_raw ) );
}
$redirect = '' !== $request_redirect ? $this->safe_redirect( $request_redirect ) : '';
if ( '' === $redirect && isset( $_COOKIE['advapafo_redirect_to'] ) ) {
$cookie_redirect_raw = wp_unslash( $_COOKIE['advapafo_redirect_to'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via esc_url_raw/safe_redirect.
if ( is_string( $cookie_redirect_raw ) ) {
$redirect = $this->safe_redirect( esc_url_raw( $cookie_redirect_raw ) );
}
}
$this->clear_redirect_cookie();
// Fall back to the settings-page redirect URL.
if ( '' === $redirect ) {
$settings_redirect = (string) advapafo_get_setting( 'login_redirect', '', array( 'apply_legacy' => false ) );
if ( '' !== $settings_redirect ) {
$redirect = $this->safe_redirect( $settings_redirect );
}
}
if ( '' === $redirect ) {
$default = apply_filters( 'login_redirect', admin_url(), '', $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WordPress hook.
$redirect = $this->safe_redirect( $default, admin_url() );
}
// Re-validate after filter to prevent open-redirect via third-party hooks.
$redirect = $this->safe_redirect(
(string) advapafo_get_setting(
'login_redirect',
$redirect,
array(
'user' => $user,
'skip_database' => true,
)
),
admin_url()
);
wp_send_json_success( array( 'redirect' => $redirect ) );
} catch ( \Throwable $e ) {
$failed_user_id = (int) $cred->user_id;
$failed_user = get_user_by( 'id', $failed_user_id );
$reason = (string) $e->getMessage();
do_action( 'advapafo_passkey_login_failed', $failed_user_id, $cred_hash, $reason );
if ( $failed_user instanceof WP_User ) {
$emit_wp_login_failed = (bool) advapafo_get_setting(
'passkey_emit_wp_login_failed',
false,
array(
'user' => $failed_user,
'credential_hash' => $cred_hash,
'reason' => $reason,
)
);
if ( $emit_wp_login_failed ) {
do_action( 'wp_login_failed', $failed_user->user_login ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- optional core hook for lockout ecosystem compatibility.
}
}
$this->record_failure( 'login_finish_ip', $ip );
$this->record_failure( 'login_finish_user', (int) $cred->user_id );
$this->record_failure( 'login_finish_cred', $cred_hash );
$this->log_event(
'login_failed',
array(
'user_id' => $failed_user_id,
'message' => $reason,
)
);
wp_send_json_error( array( 'message' => 'Passkey sign-in failed. Please use another login method.' ), 400 );
}
}
/**
* Establish an authenticated session after successful passkey verification.
*
* This is technically required to complete WordPress login for verified users.
* Uses wp_signon() with a one-time transient-backed token so login flows
* through WordPress' native authentication pipeline and security hooks.
*
* @param WP_User $user Authenticated user.
* @param bool $remember Whether to issue a persistent login cookie.
* @param string $credential_hash SHA-256 credential hash for diagnostics.
* @throws \RuntimeException When sign-in cannot be completed.
*/
private function establish_authenticated_session( WP_User $user, bool $remember = false, string $credential_hash = '' ): void {
$user_id = (int) $user->ID;
$max_attempts = defined( 'ADVAPAFO_PASSKEY_SIGNON_MAX_ATTEMPTS' ) ? max( 1, (int) ADVAPAFO_PASSKEY_SIGNON_MAX_ATTEMPTS ) : 1;
if ( $this->passkey_signon_attempts >= $max_attempts ) {
throw new \RuntimeException( 'Passkey sign-in recursion guard triggered.' );
}
++$this->passkey_signon_attempts;
$attempt_number = $this->passkey_signon_attempts;
$token = wp_generate_password( 64, false, false );
$token_hash = hash_hmac( 'sha256', $token, wp_salt( 'auth' ) );
$key_seed = $user_id . '|' . strtolower( (string) $user->user_login ) . '|' . $token;
$key_suffix = substr( hash( 'sha256', $key_seed ), 0, 40 );
$key = 'advapafo_signin_' . $key_suffix;
set_transient(
$key,
array(
'user_id' => $user_id,
'user_login' => strtolower( (string) $user->user_login ),
'token_hash' => $token_hash,
),
MINUTE_IN_SECONDS
);
$this->passkey_signon_context = array(
'transient_key' => $key,
'user_id' => $user_id,
'user_login' => strtolower( (string) $user->user_login ),
);
add_filter( 'authenticate', array( $this, 'authenticate_passkey_signon' ), 5, 3 );
try {
$signed_in = wp_signon(
array(
'user_login' => (string) $user->user_login,
'user_password' => $token,
'remember' => $remember,
),
is_ssl()
);
} finally {
remove_filter( 'authenticate', array( $this, 'authenticate_passkey_signon' ), 5 );
delete_transient( $key );
$this->passkey_signon_context = null;
$this->passkey_signon_attempts = max( 0, $this->passkey_signon_attempts - 1 );
}
if ( is_wp_error( $signed_in ) ) {
$error_codes = array_values( array_filter( array_map( 'sanitize_key', (array) $signed_in->get_error_codes() ) ) );
$primary = isset( $error_codes[0] ) ? $error_codes[0] : 'unknown';
$this->log_event(
'login_signon_failed',
array(
'user_id' => $user_id,
'credential_hash' => $credential_hash,
'wp_error_primary' => $primary,
'wp_error_codes' => $error_codes,
'wp_signon_attempt' => $attempt_number,
)
);
throw new \RuntimeException( 'WordPress sign-in pipeline rejected authentication.' );
}
if ( ! $signed_in instanceof WP_User || (int) $signed_in->ID !== $user_id ) {
throw new \RuntimeException( 'WordPress sign-in returned an unexpected user.' );
}
}
/**
* Authenticate the one-time passkey sign-in token during wp_signon().
*
* @param WP_User|WP_Error|null $user Existing authentication state.
* @param string $username Submitted username.
* @param string $password Submitted password/token.
* @return WP_User|WP_Error|null
*/
public function authenticate_passkey_signon( $user, $username, $password ) {
if ( $user instanceof WP_User || $user instanceof WP_Error ) {
return $user;
}
if ( ! is_array( $this->passkey_signon_context ) ) {
return $user;
}
if ( ! is_string( $username ) || ! is_string( $password ) || '' === $password ) {
return $user;
}
$context_login = isset( $this->passkey_signon_context['user_login'] ) ? (string) $this->passkey_signon_context['user_login'] : '';
if ( '' === $context_login || ! hash_equals( $context_login, strtolower( $username ) ) ) {
return $user;
}
$context_key = isset( $this->passkey_signon_context['transient_key'] ) ? (string) $this->passkey_signon_context['transient_key'] : '';
$state = '' !== $context_key ? get_transient( $context_key ) : false;
if ( ! is_array( $state ) ) {
return $user;
}
$state_login = isset( $state['user_login'] ) ? (string) $state['user_login'] : '';
if ( '' === $state_login || ! hash_equals( $state_login, strtolower( $username ) ) ) {
return $user;
}
$expected_hash = isset( $state['token_hash'] ) ? (string) $state['token_hash'] : '';
$given_hash = hash_hmac( 'sha256', $password, wp_salt( 'auth' ) );
if ( '' === $expected_hash || ! hash_equals( $expected_hash, $given_hash ) ) {
return $user;
}
$user_id = isset( $state['user_id'] ) ? (int) $state['user_id'] : 0;
if ( $user_id < 1 ) {
return $user;
}
delete_transient( $context_key );
$resolved_user = get_user_by( 'id', $user_id );
if ( ! $resolved_user instanceof WP_User ) {
return $user;
}
return $resolved_user;
}
// ──────────────────────────────────────────────────────────
// Private helpers — WebAuthn
// ──────────────────────────────────────────────────────────
/**
* Create WebAuthn service configured for this relying party.
*
* @return WebAuthn
*/
private function new_webauthn(): WebAuthn {
return new WebAuthn(
$this->get_rp_name(),
$this->get_rp_id(),
array( 'none', 'packed', 'apple', 'fido-u2f', 'tpm', 'android-key', 'android-safetynet' ),
true
);
}
/**
* Resolve relying-party ID.
*
* @return string
*/
private function get_rp_id(): string {
if ( defined( 'ADVAPAFO_RP_ID' ) && ADVAPAFO_RP_ID ) {
return (string) ADVAPAFO_RP_ID;
}
return (string) wp_parse_url( home_url(), PHP_URL_HOST );
}
/**
* Resolve relying-party display name.
*
* @return string
*/
private function get_rp_name(): string {
if ( defined( 'ADVAPAFO_RP_NAME' ) && ADVAPAFO_RP_NAME ) {
return (string) ADVAPAFO_RP_NAME;
}
$name = get_option( 'advapafo_rp_name', '' );
return '' !== $name ? (string) $name : (string) get_bloginfo( 'name' );
}
/**
* Get default challenge TTL in seconds.
*
* @return int
*/
private function get_challenge_ttl(): int {
if ( defined( 'ADVAPAFO_CHALLENGE_TTL' ) && (int) ADVAPAFO_CHALLENGE_TTL > 30 ) {
return (int) ADVAPAFO_CHALLENGE_TTL;
}
$opt = (int) advapafo_get_setting( 'challenge_ttl', 0 );
if ( $opt >= 30 ) {
return min( 600, $opt );
}
return 300;
}
/**
* Get login challenge TTL in seconds.
*
* @return int
*/
private function get_login_challenge_ttl(): int {
$opt = (int) advapafo_get_setting( 'login_challenge_ttl', 0 );
if ( $opt >= 30 ) {
return min( 1200, $opt );
}
return $this->get_challenge_ttl();
}
/**
* Get registration challenge TTL in seconds.
*
* @return int
*/
private function get_registration_challenge_ttl(): int {
$opt = (int) advapafo_get_setting( 'registration_challenge_ttl', 0 );
if ( $opt >= 30 ) {
return min( 1200, $opt );
}
return $this->get_challenge_ttl();
}
/**
* Resolve WebAuthn user-verification preference.
*
* @return string
*/
private function get_user_verification(): string {
$opt = strtolower( (string) advapafo_get_setting( 'user_verification', '' ) );
if ( in_array( $opt, array( 'required', 'preferred', 'discouraged' ), true ) ) {
return $opt;
}
if ( defined( 'ADVAPAFO_USER_VERIFICATION' ) ) {
$v = strtolower( (string) ADVAPAFO_USER_VERIFICATION );
if ( in_array( $v, array( 'required', 'preferred', 'discouraged' ), true ) ) {
return $v;
}
}
return 'required';
}
/**
* Decode base64url input to binary.
*
* @param string $value Base64url value.
* @return string
*/
private function decode_b64url( string $value ): string {
return ByteBuffer::fromBase64Url( $value )->getBinaryString();
}
/**
* Encode binary input as base64url.
*
* @param string $value Binary value.
* @return string
*/
private function encode_b64url( string $value ): string {
return rtrim( strtr( base64_encode( $value ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- canonical base64url encoding for WebAuthn payloads.
}
/**
* Convert raw authenticator AAGUID bytes into canonical UUID format.
*
* @param string $aaguid_raw Raw binary/string AAGUID.
* @return string
*/
private function format_aaguid_for_logging( string $aaguid_raw ): string {
$aaguid_raw = trim( $aaguid_raw );
if ( '' === $aaguid_raw ) {
return '';
}
$hex = '';
if ( strlen( $aaguid_raw ) === 16 ) {
$hex = strtolower( bin2hex( $aaguid_raw ) );
} elseif ( preg_match( '/^[a-fA-F0-9-]{32,36}$/', $aaguid_raw ) ) {
$hex = strtolower( str_replace( '-', '', $aaguid_raw ) );
}
if ( ! preg_match( '/^[a-f0-9]{32}$/', $hex ) ) {
return '';
}
if ( str_repeat( '0', 32 ) === $hex ) {
return '';
}
return substr( $hex, 0, 8 ) . '-' . substr( $hex, 8, 4 ) . '-' . substr( $hex, 12, 4 ) . '-' . substr( $hex, 16, 4 ) . '-' . substr( $hex, 20, 12 );
}
// ──────────────────────────────────────────────────────────
// Private helpers — User
// ──────────────────────────────────────────────────────────
/**
* Determine whether a user can register and use passkeys.
*
* @param WP_User $user User instance.
* @return bool
*/
private function is_eligible_user( WP_User $user ): bool {
/**
* Filters whether a user may register/use passkeys.
*
* @param bool $eligible Whether the user is eligible.
* @param WP_User $user The user in question.
*/
$eligible = advapafo_get_setting( 'eligible_user', null, array( 'user' => $user ) );
if ( null !== $eligible ) {
return (bool) $eligible;
}
$allowed_roles = array_filter( array_map( 'sanitize_key', (array) advapafo_get_setting( 'eligible_roles', array( 'administrator' ) ) ) );
if ( empty( $allowed_roles ) ) {
$allowed_roles = array( 'administrator' );
}
return ! empty( array_intersect( (array) $user->roles, $allowed_roles ) );
}
/**
* Get max credentials allowed for the given user.
*
* @param WP_User $user User instance.
* @return int
*/
private function get_max_passkeys_per_user( WP_User $user ): int {
/**
* Filters the max number of passkeys a user may hold.
* Return 0 or less to remove the cap.
*
* @param int $max Current cap.
* @param WP_User $user The user.
*/
$setting = (int) advapafo_get_setting( 'max_passkeys_per_user', self::DEFAULT_MAX_PASSKEYS, array( 'user' => $user ) );
return $setting <= 0 ? PHP_INT_MAX : max( 1, $setting );
}
/**
* Resolve a user from email or login identifier.
*
* @param string $login Login or email.
* @return WP_User|null
*/
private function resolve_user( string $login ): ?WP_User {
$user = is_email( $login ) ? get_user_by( 'email', $login ) : false;
if ( ! $user ) {
$user = get_user_by( 'login', $login );
}
return $user instanceof WP_User ? $user : null;
}
/**
* Record the latest successful passkey login timestamp for a user.
*
* @param int $user_id WordPress user ID.
* @param string $credential_hash SHA-256 hash of the credential ID.
* @param string $user_agent Raw user agent string.
* @return void
*/
public function handle_passkey_login_success( int $user_id, string $credential_hash = '', string $user_agent = '' ): void {
unset( $credential_hash, $user_agent );
if ( $user_id < 1 ) {
return;
}
update_user_meta( $user_id, self::USER_META_LAST_PASSKEY_LOGIN, (string) time() );
}
/**
* Resolve the login-pill label with site-level override support.
*
* @param WP_User|null $user Resolved user (if available).
* @return string
*/
private function get_last_used_pill_label( ?WP_User $user ): string {
$default_label = __( 'Last used', 'advanced-passkey-login' );
$filtered = advapafo_get_setting( 'last_used_pill_label', $default_label, array( 'user' => $user ) );
$label = is_string( $filtered ) ? sanitize_text_field( $filtered ) : '';
return '' !== $label ? $label : $default_label;
}
/**
* Resolve freshness window days for the login-pill recency check.
*
* @param WP_User|null $user Resolved user (if available).
* @return int
*/
private function get_last_used_pill_freshness_days( ?WP_User $user ): int {
$days = (int) advapafo_get_setting( 'last_used_pill_freshness_days', self::LAST_USED_PILL_DEFAULT_FRESHNESS_DAYS, array( 'user' => $user ) );
if ( $days < 0 ) {
$days = 0;
}
return min( $days, 3650 );
}
/**
* Evaluate visibility for the login-pill hint and expose a final override filter.
*
* @param int $timestamp Last passkey-login timestamp.
* @param WP_User|null $user Resolved user (if available).
* @return bool
*/
private function should_show_last_used_pill( int $timestamp, ?WP_User $user ): bool {
$freshness_days = $this->get_last_used_pill_freshness_days( $user );
$cutoff = time() - ( $freshness_days * DAY_IN_SECONDS );
$base_visible = $timestamp > 0 && $timestamp >= $cutoff;
return (bool) advapafo_get_setting(
'last_used_pill_visible',
$base_visible,
array(
'user' => $user,
'timestamp' => $timestamp,
'freshness_days' => $freshness_days,
)
);
}
/**
* Return whether the entered account should show the "Last used" login hint.
*
* @return void
*/
public function ajax_get_last_used_hint(): void {
$label = $this->get_last_used_pill_label( null );
if ( ! $this->is_post_request() ) {
wp_send_json_error(
array(
'message' => __( 'Invalid request method.', 'advanced-passkey-login' ),
'showPill' => false,
'label' => $label,
),
405
);
}
if ( ! check_ajax_referer( 'advapafo_login', 'nonce', false ) ) {
wp_send_json_error(
array(
'message' => __( 'Security check failed. Please refresh and try again.', 'advanced-passkey-login' ),
'showPill' => false,
'label' => $label,
),
403
);
}
$login = isset( $_POST['login'] ) ? sanitize_text_field( wp_unslash( $_POST['login'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce is verified above.
$login = trim( $login );
if ( '' === $login || strlen( $login ) > 191 ) {
wp_send_json_success(
array(
'showPill' => false,
'label' => $label,
)
);
}
$user = $this->resolve_user( $login );
if ( ! $user instanceof WP_User ) {
wp_send_json_success(
array(
'showPill' => false,
'label' => $label,
)
);
}
$last_login = get_user_meta( (int) $user->ID, self::USER_META_LAST_PASSKEY_LOGIN, true );
$timestamp = is_numeric( $last_login ) ? (int) $last_login : 0;
$label = $this->get_last_used_pill_label( $user );
wp_send_json_success(
array(
'showPill' => $this->should_show_last_used_pill( $timestamp, $user ),
'label' => $label,
)
);
}
/**
* Get active credentials for a user.
*
* @param int $user_id User ID.
* @return array
*/
private function get_user_credentials( int $user_id ): array {
$results = array();
$seen = array();
foreach ( $this->get_credentials_table_names_ordered() as $table ) {
$rows = $this->get_active_credentials_rows_from_table( $table, $user_id );
foreach ( $rows as $row ) {
$hash = isset( $row->credential_id_hash ) ? (string) $row->credential_id_hash : '';
if ( '' === $hash ) {
$hash = md5( (string) ( $row->credential_id ?? '' ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_md5 -- stable key for dedupe fallback only.
}
if ( isset( $seen[ $hash ] ) ) {
continue;
}
$seen[ $hash ] = true;
$results[] = $row;
}
}
usort(
$results,
static function ( $a, $b ) {
return strcmp( (string) ( $b->created_at ?? '' ), (string) ( $a->created_at ?? '' ) );
}
);
return $results;
}
/**
* Fetch display-only metadata for a user's passkeys.
* Does NOT load credential_public_key — use get_user_credentials() for authentication.
*
* @param int $user_id User ID.
* @return array
*/
private function get_user_credentials_meta( int $user_id ): array {
$results = array();
$seen = array();
foreach ( $this->get_credentials_table_names_ordered() as $table ) {
$rows = $this->get_active_credentials_meta_rows_from_table( $table, $user_id );
foreach ( $rows as $row ) {
$hash = isset( $row->credential_id_hash ) ? (string) $row->credential_id_hash : '';
if ( '' === $hash ) {
$hash = md5( (string) ( $row->credential_id ?? '' ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_md5 -- fallback key for dedupe only.
}
if ( '' !== $hash && isset( $seen[ $hash ] ) ) {
continue;
}
if ( '' !== $hash ) {
$seen[ $hash ] = true;
}
$results[] = $row;
}
}
usort(
$results,
static function ( $a, $b ) {
return strcmp( (string) ( $b->created_at ?? '' ), (string) ( $a->created_at ?? '' ) );
}
);
return $results;
}
/**
* Return the count of active (non-revoked) passkeys for a user.
* Avoids loading full credential rows just for a count check.
*
* @param int $user_id User ID.
* @return int
*/
private function count_user_credentials( int $user_id ): int {
$hashes = array();
foreach ( $this->get_credentials_table_names_ordered() as $table ) {
$rows = $this->get_active_credential_hashes_from_table( $table, $user_id );
foreach ( $rows as $hash ) {
$normalized = (string) $hash;
if ( '' === $normalized ) {
continue;
}
$hashes[ $normalized ] = true;
}
}
return count( $hashes );
}
/**
* Fetch a single active credential row by hash from a supported table.
*
* @param string $table Table name.
* @param string $credential_hash Credential hash.
* @param int $user_id Optional user context. Zero means discoverable lookup.
* @return object|null
*/
private function get_credential_by_hash_from_table( string $table, string $credential_hash, int $user_id ): ?object {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( $table !== $lite_table && $table !== $shared_table ) {
return null;
}
if ( $user_id > 0 ) {
if ( $table === $lite_table ) {
return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- authentication flow requires immediate credential lookup.
$wpdb->prepare(
"SELECT id, user_id, credential_id, credential_id_hash, credential_public_key, sign_count, transports, credential_label, backed_up, created_at, last_used_at, revoked_at FROM {$wpdb->prefix}advapafo_credentials WHERE credential_id_hash = %s AND user_id = %d AND revoked_at IS NULL LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$credential_hash,
$user_id
)
);
}
if ( $table === $shared_table ) {
return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- authentication flow requires immediate credential lookup.
$wpdb->prepare(
"SELECT id, user_id, credential_id, credential_id_hash, credential_public_key, sign_count, transports, credential_label, backed_up, created_at, last_used_at, revoked_at FROM {$wpdb->prefix}wpk_credentials WHERE credential_id_hash = %s AND user_id = %d AND revoked_at IS NULL LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$credential_hash,
$user_id
)
);
}
return null;
}
if ( $table === $lite_table ) {
return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- authentication flow requires immediate credential lookup.
$wpdb->prepare(
"SELECT id, user_id, credential_id, credential_id_hash, credential_public_key, sign_count, transports, credential_label, backed_up, created_at, last_used_at, revoked_at FROM {$wpdb->prefix}advapafo_credentials WHERE credential_id_hash = %s AND revoked_at IS NULL LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$credential_hash
)
);
}
if ( $table === $shared_table ) {
return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- authentication flow requires immediate credential lookup.
$wpdb->prepare(
"SELECT id, user_id, credential_id, credential_id_hash, credential_public_key, sign_count, transports, credential_label, backed_up, created_at, last_used_at, revoked_at FROM {$wpdb->prefix}wpk_credentials WHERE credential_id_hash = %s AND revoked_at IS NULL LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$credential_hash
)
);
}
return null;
}
/**
* Fetch active credential rows for a user from a supported table.
*
* @param string $table Table name.
* @param int $user_id User ID.
* @return array
*/
private function get_active_credentials_rows_from_table( string $table, int $user_id ): array {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( $table === $lite_table ) {
return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- authentication requires live reads from plugin credential table.
$wpdb->prepare(
"SELECT id, user_id, credential_id, credential_id_hash, credential_public_key, sign_count, transports, credential_label, backed_up, created_at, last_used_at, revoked_at FROM {$wpdb->prefix}advapafo_credentials WHERE user_id = %d AND revoked_at IS NULL ORDER BY created_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$user_id
)
);
}
if ( $table === $shared_table ) {
return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- authentication requires live reads from plugin credential table.
$wpdb->prepare(
"SELECT id, user_id, credential_id, credential_id_hash, credential_public_key, sign_count, transports, credential_label, backed_up, created_at, last_used_at, revoked_at FROM {$wpdb->prefix}wpk_credentials WHERE user_id = %d AND revoked_at IS NULL ORDER BY created_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$user_id
)
);
}
return array();
}
/**
* Fetch active credential metadata rows for a user from a supported table.
*
* @param string $table Table name.
* @param int $user_id User ID.
* @return array
*/
private function get_active_credentials_meta_rows_from_table( string $table, int $user_id ): array {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( $table === $lite_table ) {
return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- admin credential metadata view reads plugin-owned table.
$wpdb->prepare(
"SELECT id, credential_id, credential_id_hash, credential_label, created_at, last_used_at FROM {$wpdb->prefix}advapafo_credentials WHERE user_id = %d AND revoked_at IS NULL ORDER BY created_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$user_id
)
);
}
if ( $table === $shared_table ) {
return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- admin credential metadata view reads plugin-owned table.
$wpdb->prepare(
"SELECT id, credential_id, credential_id_hash, credential_label, created_at, last_used_at FROM {$wpdb->prefix}wpk_credentials WHERE user_id = %d AND revoked_at IS NULL ORDER BY created_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$user_id
)
);
}
return array();
}
/**
* Fetch active credential hashes for a user from a supported table.
*
* @param string $table Table name.
* @param int $user_id User ID.
* @return array
*/
private function get_active_credential_hashes_from_table( string $table, int $user_id ): array {
global $wpdb;
$lite_table = $wpdb->prefix . self::TABLE_CREDENTIALS;
$shared_table = $wpdb->prefix . 'wpk_credentials';
if ( $table === $lite_table ) {
return array_map(
'strval',
(array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- realtime credential count used for limit enforcement.
$wpdb->prepare(
"SELECT credential_id_hash FROM {$wpdb->prefix}advapafo_credentials WHERE user_id = %d AND revoked_at IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$user_id
)
)
);
}
if ( $table === $shared_table ) {
return array_map(
'strval',
(array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- realtime credential count used for limit enforcement.
$wpdb->prepare(
"SELECT credential_id_hash FROM {$wpdb->prefix}wpk_credentials WHERE user_id = %d AND revoked_at IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$user_id
)
)
);
}
return array();
}
/**
* Determine credential tables available for read compatibility.
* Lite table remains primary; shared table supports Pro-to-Lite downgrades.
*
* @return array
*/
private function get_credentials_table_names_ordered(): array {
global $wpdb;
$candidates = array(
$wpdb->prefix . self::TABLE_CREDENTIALS,
$wpdb->prefix . 'wpk_credentials',
);
$available = array();
foreach ( $candidates as $candidate ) {
$like = $wpdb->esc_like( $candidate );
$row = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if ( $row === $candidate ) {
$available[] = $candidate;
}
}
if ( empty( $available ) ) {
$available[] = $wpdb->prefix . self::TABLE_CREDENTIALS;
}
return array_values( array_unique( $available ) );
}
/**
* Suppress setup nudge when credentials already exist.
*
* @param int $user_id User ID.
* @return bool
*/
private function has_any_user_credentials( int $user_id ): bool {
return $this->count_user_credentials( $user_id ) > 0;
}
/**
* Get credential IDs in binary form for WebAuthn allowCredentials.
*
* @param int $user_id User ID.
* @return array
*/
private function get_credential_ids_binary( int $user_id ): array {
return array_map(
fn( $r ) => $this->decode_b64url( $r->credential_id ),
$this->get_user_credentials( $user_id )
);
}
// ──────────────────────────────────────────────────────────
// Private helpers — Redirect / Cookie
// ──────────────────────────────────────────────────────────
/**
* Validate and normalize a redirect URL.
*
* @param string $target Candidate target URL.
* @param string $fallback Fallback URL.
* @return string
*/
private function safe_redirect( string $target, string $fallback = '' ): string {
return '' !== $target ? (string) wp_validate_redirect( $target, $fallback ) : $fallback;
}
/**
* Clear the login redirect helper cookie.
*/
private function clear_redirect_cookie(): void {
setcookie(
'advapafo_redirect_to',
'',
array(
'expires' => time() - 3600,
'path' => defined( 'COOKIEPATH' ) ? (string) COOKIEPATH : '/',
'domain' => defined( 'COOKIE_DOMAIN' ) ? (string) COOKIE_DOMAIN : '',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
)
);
}
/**
* Validate login session token format.
*
* @param string $token Candidate token.
* @return bool
*/
private function is_valid_token( string $token ): bool {
return '' !== $token && 1 === preg_match( '/\A[a-zA-Z0-9]{20,64}\z/', $token );
}
/**
* Validate incoming base64url payload from WebAuthn responses.
*
* @param string $payload Candidate payload string.
* @return bool
*/
private function is_valid_b64url_payload( string $payload ): bool {
$len = strlen( $payload );
if ( $len < 8 || $len > 8192 ) {
return false;
}
return 1 === preg_match( '/\A[A-Za-z0-9_-]+\z/', $payload );
}
/**
* Validate a submitted login identifier before lookup.
*
* @param string $login Candidate login identifier.
* @return bool
*/
private function is_valid_login_identifier( string $login ): bool {
$len = strlen( $login );
if ( $len < 1 || $len > 254 ) {
return false;
}
return 1 === preg_match( '/\A[^\r\n\t\0]+\z/', $login );
}
/**
* Check whether the current request method is POST.
*
* @return bool
*/
private function is_post_request(): bool {
$request_method = isset( $_SERVER['REQUEST_METHOD'] )
? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- request method is sanitized immediately for strict comparison.
: '';
return 'POST' === strtoupper( $request_method );
}
// ──────────────────────────────────────────────────────────
// Private helpers — Rate limiting
// ──────────────────────────────────────────────────────────
/**
* Get rate-limit window length in seconds.
*
* @return int
*/
private function get_rate_window(): int {
$opt = (int) advapafo_get_setting( 'rate_limit_window', 0 );
if ( $opt >= 60 ) {
return min( 3600, $opt );
}
// Backward compatibility with pre-1.1 option keys.
$opt = (int) advapafo_get_setting( 'rate_window', 0 );
if ( $opt >= 60 ) {
return min( 3600, $opt );
}
if ( defined( 'ADVAPAFO_RATE_WINDOW' ) && (int) ADVAPAFO_RATE_WINDOW >= 60 ) {
return (int) ADVAPAFO_RATE_WINDOW;
}
return 300;
}
/**
* Get max failed attempts before lockout.
*
* @return int
*/
private function get_rate_max_attempts(): int {
$opt = (int) advapafo_get_setting( 'rate_limit_max_failures', 0 );
if ( $opt >= 1 ) {
return min( 50, $opt );
}
// Backward compatibility with pre-1.1 option keys.
$opt = (int) advapafo_get_setting( 'rate_max_attempts', 0 );
if ( $opt >= 1 ) {
return min( 50, $opt );
}
if ( defined( 'ADVAPAFO_RATE_MAX_ATTEMPTS' ) && (int) ADVAPAFO_RATE_MAX_ATTEMPTS >= 1 ) {
return (int) ADVAPAFO_RATE_MAX_ATTEMPTS;
}
return 5;
}
/**
* Get lockout duration in seconds.
*
* @return int
*/
private function get_rate_lockout(): int {
$opt = (int) advapafo_get_setting( 'rate_limit_lockout', 0 );
if ( $opt >= 60 ) {
return min( 86400, $opt );
}
// Backward compatibility with pre-1.1 option keys.
$opt = (int) advapafo_get_setting( 'rate_lockout', 0 );
if ( $opt >= 60 ) {
return min( 86400, $opt );
}
if ( defined( 'ADVAPAFO_RATE_LOCKOUT' ) && (int) ADVAPAFO_RATE_LOCKOUT >= 60 ) {
return (int) ADVAPAFO_RATE_LOCKOUT;
}
return 900;
}
/**
* Build deterministic, privacy-safe bucket key.
*
* @param string $prefix Bucket namespace prefix.
* @param string $identifier User/IP identifier.
* @return string
*/
private function bucket_key( string $prefix, string $identifier ): string {
return 'advapafo_' . hash_hmac( 'sha256', $prefix . '|' . $identifier, wp_salt( 'auth' ) );
}
/**
* Check whether a bucket is currently locked.
*
* @param string $prefix Bucket namespace prefix.
* @param int|string $identifier User/IP identifier.
* @return bool
*/
private function is_locked_out( string $prefix, $identifier ): bool {
global $wpdb;
$key = $this->bucket_key( $prefix, (string) $identifier );
$until = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- rate-limit lock checks require live table reads.
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"SELECT lock_expires_at FROM {$wpdb->prefix}advapafo_rate_limits WHERE bucket_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$key
)
);
return $until && strtotime( (string) $until ) > time();
}
/**
* Record a failed operation in a rate-limit bucket.
*
* @param string $prefix Bucket namespace prefix.
* @param int|string $identifier User/IP identifier.
*/
private function record_failure( string $prefix, $identifier ): void {
global $wpdb;
$key = $this->bucket_key( $prefix, (string) $identifier );
$window = $this->get_rate_window();
$max = $this->get_rate_max_attempts();
$lockout = $this->get_rate_lockout();
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- rate-limit bucket initialization write.
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"INSERT IGNORE INTO {$wpdb->prefix}advapafo_rate_limits (bucket_key, failure_count, window_expires_at, lock_expires_at, updated_at) VALUES (%s, 0, NULL, NULL, UTC_TIMESTAMP())", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$key
)
);
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- rate-limit counters and expirations are updated atomically.
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- table identifier is strict-validated by quote_table_name().
"UPDATE {$wpdb->prefix}advapafo_rate_limits SET
failure_count = IF(window_expires_at IS NULL OR window_expires_at <= UTC_TIMESTAMP(), 1, failure_count + 1),
window_expires_at = IF(window_expires_at IS NULL OR window_expires_at <= UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL %d SECOND), window_expires_at),
lock_expires_at = IF(
IF(window_expires_at IS NULL OR window_expires_at <= UTC_TIMESTAMP(), 1, failure_count + 1) >= %d,
DATE_ADD(UTC_TIMESTAMP(), INTERVAL %d SECOND),
IF(lock_expires_at IS NOT NULL AND lock_expires_at <= UTC_TIMESTAMP(), NULL, lock_expires_at)
),
updated_at = UTC_TIMESTAMP()
WHERE bucket_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$window,
$max,
$lockout,
$key
)
);
if ( random_int( 1, 100 ) === 1 ) {
$this->cleanup_rate_table();
}
}
/**
* Clear failures for a specific bucket.
*
* @param string $prefix Bucket namespace prefix.
* @param int|string $identifier User/IP identifier.
*/
private function clear_failures( string $prefix, $identifier ): void {
global $wpdb;
$key = $this->bucket_key( $prefix, (string) $identifier );
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- explicit reset of a rate-limit bucket.
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"DELETE FROM {$wpdb->prefix}advapafo_rate_limits WHERE bucket_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
$key
)
);
}
/**
* Purge expired rows from rate-limit table.
*/
private function cleanup_rate_table(): void {
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- periodic cleanup for expired rate-limit buckets.
"DELETE FROM {$wpdb->prefix}advapafo_rate_limits WHERE (lock_expires_at IS NULL OR lock_expires_at <= UTC_TIMESTAMP()) AND (window_expires_at IS NULL OR window_expires_at <= UTC_TIMESTAMP())" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is plugin-owned and prefix-scoped.
);
}
/**
* Quote and validate a table name for SQL usage.
*
* @param string $table_name Table name.
* @return string
*/
private static function quote_table_name( string $table_name ): string {
$table_name = trim( $table_name );
if ( '' === $table_name ) {
return '';
}
if ( 1 !== preg_match( '/^[A-Za-z0-9_]+$/', $table_name ) ) {
return '';
}
return '`' . $table_name . '`';
}
// ──────────────────────────────────────────────────────────
// Private helpers — Misc
// ──────────────────────────────────────────────────────────
/**
* Convert UTC datetime string to timestamp.
*
* @param string $raw_datetime Datetime string.
* @return int
*/
private function utc_datetime_to_timestamp( string $raw_datetime ): int {
$raw_datetime = trim( $raw_datetime );
if ( '' === $raw_datetime ) {
return 0;
}
$date_utc = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $raw_datetime, new DateTimeZone( 'UTC' ) );
if ( $date_utc instanceof DateTimeImmutable ) {
return $date_utc->getTimestamp();
}
$fallback_ts = strtotime( $raw_datetime . ' UTC' );
return $fallback_ts ? (int) $fallback_ts : 0;
}
/**
* Format UTC datetime for local display.
*
* @param string $raw_datetime Datetime string.
* @return string
*/
private function format_utc_datetime_for_display( string $raw_datetime ): string {
$timestamp = $this->utc_datetime_to_timestamp( $raw_datetime );
if ( $timestamp <= 0 ) {
return $raw_datetime;
}
return wp_date(
get_option( 'date_format' ) . ' ' . get_option( 'time_format' ),
$timestamp,
wp_timezone()
);
}
/**
* Determine whether passkeys may run in this request context.
*
* @return bool
*/
private function is_secure_context(): bool {
if ( is_ssl() ) {
return true;
}
$enforce_https = (bool) advapafo_get_setting( 'enforce_https', true );
// Allow HTTP only for local/dev-style environments when explicitly enabled.
if ( ( ! $enforce_https || ( defined( 'ADVAPAFO_ALLOW_HTTP' ) && ADVAPAFO_ALLOW_HTTP ) ) ) {
if ( function_exists( 'wp_get_environment_type' ) && 'production' === wp_get_environment_type() ) {
return false;
}
return true;
}
return false;
}
/**
* Get client IP address with strict sanitization.
*
* @return string
*/
private function get_client_ip(): string {
$ip = '';
if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
$ip_raw = wp_unslash( $_SERVER['REMOTE_ADDR'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below by sanitize_text_field/filter_var.
if ( is_string( $ip_raw ) ) {
$ip = sanitize_text_field( $ip_raw );
}
}
if ( '' === $ip || false === filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return '0.0.0.0';
}
return $ip;
}
/**
* Persist a sanitized activity log event.
*
* @param string $event Event key.
* @param array $data Event payload.
*/
private function log_event( string $event, array $data = array() ): void {
if ( defined( 'ADVAPAFO_ENABLE_LOGGING' ) && ! ADVAPAFO_ENABLE_LOGGING ) {
return;
}
if ( ! (bool) advapafo_get_setting( 'activity_logging_enabled', true ) ) {
return;
}
// Privacy-first logging: strip or pseudonymize fields that can contain PII.
if ( isset( $data['user_id'] ) ) {
$data['user_ref'] = hash_hmac( 'sha256', (string) absint( $data['user_id'] ), wp_salt( 'auth' ) );
unset( $data['user_id'] );
}
$ip_candidate = isset( $data['ip'] ) ? (string) $data['ip'] : $this->get_client_ip();
$ip_metadata = $this->build_ip_log_metadata( $ip_candidate );
if ( ! empty( $ip_metadata ) ) {
$data = array_merge( $data, $ip_metadata );
}
foreach ( array( 'message', 'email', 'ip', 'ip_address', 'client_ip', 'remote_addr', 'user_agent', 'login', 'username', 'display_name', 'redirect' ) as $sensitive_key ) {
if ( isset( $data[ $sensitive_key ] ) ) {
unset( $data[ $sensitive_key ] );
}
}
$event = sanitize_key( $event );
if ( '' === $event ) {
return;
}
$encoded_data = wp_json_encode( $data );
if ( ! is_string( $encoded_data ) ) {
$encoded_data = '{}';
}
global $wpdb;
$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- append-only logging table write.
$wpdb->prefix . 'advapafo_logs',
array(
'event_type' => $event,
'log_timestamp' => gmdate( 'Y-m-d H:i:s' ),
// Keep user_agent empty to avoid storing browser fingerprinting data.
'user_agent' => '',
'log_data' => $encoded_data,
),
array( '%s', '%s', '%s', '%s' )
);
}
/**
* Build a privacy-safe, masked IP payload for audit logging.
*
* @param string $ip_raw Raw client IP address.
* @return array
*/
private function build_ip_log_metadata( string $ip_raw ): array {
$ip_raw = sanitize_text_field( $ip_raw );
if ( in_array( $ip_raw, array( '0.0.0.0', '::', '::0' ), true ) ) {
return array();
}
if ( '' === $ip_raw || ! filter_var( $ip_raw, FILTER_VALIDATE_IP ) ) {
return array();
}
$masked = $this->mask_ip_for_logging( $ip_raw );
if ( '' === $masked ) {
return array();
}
return array(
'ip_masked' => $masked,
'ip_hash' => hash_hmac( 'sha256', $ip_raw, wp_salt( 'auth' ) ),
);
}
/**
* Zero out the host portion of an IP address for privacy-safe display.
*
* @param string $ip IP address.
* @return string
*/
private function mask_ip_for_logging( string $ip ): string {
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
$parts = explode( '.', $ip );
if ( count( $parts ) === 4 ) {
$parts[3] = '0';
return implode( '.', $parts );
}
return '';
}
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
$binary = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- inet_pton() emits a warning on malformed input we've already loosely validated.
if ( false === $binary || strlen( $binary ) !== 16 ) {
return '';
}
$masked_binary = substr( $binary, 0, 8 ) . str_repeat( "\x00", 8 );
$masked_ip = @inet_ntop( $masked_binary ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- inet_ntop() emits a warning on malformed input we've already loosely validated.
return is_string( $masked_ip ) ? $masked_ip : '';
}
return '';
}
}