Files
peptide-starter-theme/inc/auth-handlers.php
peptiderepo cb8b73f4ab v1.5.1: frontend auth hardening — rate limiting, email verification, honeypots, CSV safety
Security-only release per ADR-0001. Closes all six ship-blockers from the
post-v1.5.0 audit: PSEC-001 (no rate limiting), PSEC-002 (account
enumeration via separate error messages), PSEC-003 (registration auto-login
with no email verification), PSEC-004 (ungated "My Data" templates),
PSEC-005 (CSV formula injection on subscriber export), PSEC-006 (no
honeypot/CAPTCHA on public forms).

Adds inc/config.php (single source of truth for all security thresholds,
filterable via peptide_starter_security_config), inc/rate-limiter.php
(Peptide_Starter_Rate_Limiter + Cloudflare-aware client IP resolver,
transient-backed, never stores raw IPs), and inc/email-verification.php
(43-char token, 24h TTL, /verify rewrite, rate-limited resend endpoint).

Auth handlers rewritten to: unify all failure messages, rate-limit before
any expensive check, drop registrations with a filled honeypot silently,
stop auto-login on register, send a verification email instead. The
require_login() helper lives in inc/helpers.php and is applied at the top
of page-tracker.php, page-subject-log.php, and page-protocol-builder.php;
page-calculator.php stays open (no PII, no writes).

Newsletter signup gains: consent checkbox, privacy link, honeypot,
autoload=false on the wp_options blob, unsubscribe token stored per row,
/newsletter-unsubscribe route, merged success+duplicate into a single "ok"
state. Admin export runs every cell through peptide_starter_csv_safe()
which prefixes leading =/+/-/@/tab/CR with a quote.

Contact handler hardens against email-header injection (rejects name
values with \r \n , < >), rate-limits, honors honeypot, tags mail
failures with a request ID for correlation without logging message
content.

Adds Tools → Mail Test (inc/mail-diagnostic.php): a permanent wp_mail
probe so we can check Hostinger outbound deliverability any time it looks
flaky. Adds a one-time admin_init migration that enrols pre-v1.5.1
subscribers into the verification flow (currently a no-op — zero users —
but guarded for the future).

Other cleanup from the audit: fallback primary menu moved out of
header.php into inc/helpers.php (redeclaration risk), placeholder footer
language links replaced with "Translations coming soon", footer copyright
now reads through the customizer getter, settings-panel gains a proper
Tab/Shift+Tab focus trap and saves/restores body overflow, documentation
heading IDs slugified from heading text with collision counters, JS files
standardized on const/let.

Six new PHPUnit suites cover rate limiter lifecycle, auth handler error
unification and rate limits, email verification token generation and
expiry, contact handler header injection + rate limit + honeypot,
newsletter signup consent + CSV safety, and require_login gate behavior.

ARCHITECTURE.md and CONVENTIONS.md updated in the same commit — new
module layout, data flows, security patterns for rate-limiting,
honeypotting, gating, and CSV-safe export.

Version bumped to 1.5.1 in style.css and functions.php.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 13:01:22 +07:00

221 lines
8.4 KiB
PHP

<?php
/**
* Authentication AJAX Handlers
*
* Login + registration endpoints for the branded /auth page. Both are
* nonce-verified, rate-limited, honeypot-checked, and emit only unified
* error messages — no enumeration surface.
*
* Registration does NOT auto-login — users must click the verification
* link emailed to them (see inc/email-verification.php).
*
* @see page-auth.php — renders the forms
* @see assets/js/auth.js — AJAX submitter
* @see inc/rate-limiter.php — Peptide_Starter_Rate_Limiter
* @see inc/email-verification.php — sends verification email on register
* @see inc/config.php — thresholds for rate limits, username/password rules
* @see functions.php — includes this file
*
* What: AJAX login / register with unified errors and abuse controls.
* Who calls it: admin-ajax.php via auth.js POST.
* Dependencies: wp_signon, wp_create_user, rate limiter, email verification.
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Unified login failure message.
*
* Kept in a helper so every failure path returns byte-identical output —
* critical for defeating account enumeration via error-message oracle.
*
* @return string Translated failure message.
*/
function peptide_starter_login_failure_message() {
return __( 'Invalid email or password.', 'peptide-starter' );
}
/**
* Unified registration failure message.
*
* @return string Translated failure message.
*/
function peptide_starter_register_failure_message() {
return __( 'Unable to create account. Please check your entries and try again.', 'peptide-starter' );
}
/**
* Handle AJAX login.
*
* Order of operations:
* 1. Nonce check (returns security error — separate from auth failure).
* 2. Honeypot check (silent fake success).
* 3. Rate-limit check keyed on IP + sha256(lowercased email).
* 4. Email lookup + wp_signon.
* 5. On success: reset the limiter; redirect via wp_validate_redirect.
*
* Side effects: may set auth cookie, writes rate-limit transient.
*
* @return void Emits JSON and exits.
*/
function peptide_starter_ajax_login() {
if ( ! isset( $_POST['ps_login_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_login_nonce'] ) ), 'ps_auth_login' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
// Honeypot — any value means bot. Return fake success, don't log PII.
if ( peptide_starter_honeypot_triggered( 'ps_hp_login' ) ) {
wp_send_json_success( array( 'redirect' => home_url( '/' ) ) );
}
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be modified.
$identifier = peptide_starter_hash_identifier( strtolower( $email ) );
if ( ! Peptide_Starter_Rate_Limiter::check( 'login', $identifier ) ) {
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
if ( '' === $email || '' === $password ) {
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
$user = get_user_by( 'email', $email );
if ( ! $user ) {
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
$creds = array(
'user_login' => $user->user_login,
'user_password' => $password,
'remember' => true,
);
$signed_in = wp_signon( $creds, is_ssl() );
if ( is_wp_error( $signed_in ) ) {
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
Peptide_Starter_Rate_Limiter::reset( 'login', $identifier );
$redirect_raw = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : home_url( '/' );
$redirect = wp_validate_redirect( $redirect_raw, home_url( '/' ) );
// Unverified users land on profile with the verify banner.
if ( ! peptide_starter_user_is_verified( $signed_in->ID ) ) {
$redirect = home_url( '/profile?verify_required=1' );
}
wp_send_json_success( array( 'redirect' => $redirect ) );
}
add_action( 'wp_ajax_nopriv_ps_auth_login', 'peptide_starter_ajax_login' );
/**
* Handle AJAX registration.
*
* Never auto-logs-in. Sends a verification email and responds with a
* "check your inbox" message. All validation-failure paths collapse to
* one message to avoid username/email enumeration.
*
* Side effects: creates a user, writes verify meta, sends email.
*
* @return void Emits JSON and exits.
*/
function peptide_starter_ajax_register() {
if ( ! isset( $_POST['ps_register_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_register_nonce'] ) ), 'ps_auth_register' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
if ( peptide_starter_honeypot_triggered( 'ps_hp_register' ) ) {
wp_send_json_success( array( 'message' => __( 'Check your email for a verification link.', 'peptide-starter' ) ) );
}
$config = peptide_starter_security_config();
if ( ! $config['registration_enabled'] || ! get_option( 'users_can_register' ) ) {
wp_send_json_error( array( 'message' => __( 'Registration is currently disabled.', 'peptide-starter' ) ) );
}
// Rate-limit registration by IP only (identifier empty → IP-only bucket).
if ( ! Peptide_Starter_Rate_Limiter::check( 'register', 'ip' ) ) {
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
}
Peptide_Starter_Rate_Limiter::record( 'register', 'ip' );
$username = isset( $_POST['username'] ) ? sanitize_user( wp_unslash( $_POST['username'] ) ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be modified.
$pattern = sprintf( '/^[a-zA-Z0-9]{%d,%d}$/', (int) $config['username_min'], (int) $config['username_max'] );
if ( ! preg_match( $pattern, $username ) ||
! is_email( $email ) ||
strlen( $password ) < (int) $config['password_min'] ||
username_exists( $username ) ||
email_exists( $email ) ) {
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
}
$user_id = wp_create_user( $username, $password, $email );
if ( is_wp_error( $user_id ) ) {
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
}
// Send verification email. Intentionally ignore the wp_mail boolean —
// we never tell the client whether delivery succeeded (attacker signal).
peptide_starter_send_verification_email( $user_id );
/**
* Fires after a new user registers via the frontend auth form.
*
* Listeners may attach additional profile-setup logic. Fired BEFORE
* email verification is complete — do not grant privileged access from
* this hook without checking peptide_starter_user_is_verified().
*
* @param int $user_id The newly created user ID.
*/
do_action( 'peptide_starter_user_registered', $user_id );
wp_send_json_success(
array(
'message' => __( 'Account created. Check your inbox for a verification link — it expires in 24 hours.', 'peptide-starter' ),
)
);
}
add_action( 'wp_ajax_nopriv_ps_auth_register', 'peptide_starter_ajax_register' );
/**
* Detect a tripped honeypot field.
*
* Hidden fields should never receive input from a human using the real
* form. Any non-empty value → bot, silently drop the request.
*
* Side effects: on trip, writes a single low-signal log line with only
* a hash of the client IP — zero PII.
*
* @param string $field_name POST key for the honeypot input.
* @return bool True if the trap was triggered.
*/
function peptide_starter_honeypot_triggered( $field_name ) {
if ( empty( $_POST[ $field_name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller already verifies nonce.
return false;
}
$hash = substr( hash( 'sha256', peptide_starter_get_client_ip() ), 0, 12 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'peptide_starter_honeypot field=%s ip_hash=%s', $field_name, $hash ) );
return true;
}