{token,user,expires_at}, sets hatch_jwt cookie * POST /auth/register {username,email,password[,hp_website]} * POST /auth/logout -> {ok:true}, clears cookie * GET /auth/me -> user object (or 401) * POST /auth/refresh -> fresh token if within 6h of expiry * * Also gates POST /wp/v2/comments via rest_pre_dispatch so a valid JWT * populates the current user, letting the Astro-side commenter post as * themselves. * * CSRF model: a token sent in the Authorization header is not attached by the * browser, so it needs no nonce. A token read from the hatch_jwt cookie is * attached automatically, so a state-changing request authenticated by it must * carry a valid X-WP-Nonce header (action wp_rest), the same rule WordPress * core applies to its own login cookie. /auth/login and /auth/me return a * `nonce` field for that purpose. * * @package Hatch * @since 0.7.6 */ defined( 'ABSPATH' ) || exit; class Hatch_Auth { const COOKIE_NAME = 'hatch_jwt'; const OPTION_SECRET = 'hatch_jwt_secret'; const TOKEN_TTL = 86400; // 24 hours. const VERSION_META = 'hatch_jwt_ver'; const REFRESH_WINDOW = 21600; // Refresh within 6h of expiry. const RL_MAX_ATTEMPTS = 5; const RL_WINDOW = 300; // 5 minutes. /** * Capabilities that mark a staff account. Frontend accounts are for * visitors and customers; staff sign in at wp-admin, so these accounts get * no frontend session and their tokens are never honoured. */ const STAFF_CAPS = array( 'edit_posts', 'manage_options' ); private static $instance = null; /** * User id authenticated from the hatch_jwt COOKIE on this request, or 0. * Header (Bearer) authentication never sets it. * * @var int */ private static $cookie_auth_uid = 0; /** * Whether frontend accounts are turned on (Content tab, "Frontend accounts"). * * Off by default. While off, Hatch registers no sign-in, sign-up or session * route and adds no authentication filter, so nothing here can log a user in. * Sign-up additionally needs core's "Anyone can register" setting. * * @return bool */ public static function accounts_enabled(): bool { $flags = (array) get_option( 'hatch_content_flags', array() ); return ! empty( $flags['accounts_enabled'] ); } public static function instance(): Hatch_Auth { if ( null === self::$instance ) { self::$instance = new self(); if ( ! self::accounts_enabled() ) { return self::$instance; } add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'attach_jwt_user' ), 5, 3 ); // Authenticate REST requests from the hatch_jwt cookie or Bearer // token so Woo Store API (and any other REST endpoint) sees the // signed-in user. Priority 20 keeps us after WP's own cookie // check so we never override a valid session. add_filter( 'determine_current_user', array( __CLASS__, 'authenticate_jwt' ), 20 ); // Core's cookie-nonce check (rest_cookie_check_errors, priority 100) // stays in force for the WordPress login cookie. This runs just after // it and applies the same rule to the hatch_jwt cookie. add_filter( 'rest_authentication_errors', array( __CLASS__, 'require_nonce_for_cookie_auth' ), 101 ); } return self::$instance; } private function __construct() {} /* --------------------------------------------------------------------- * * Route registration * --------------------------------------------------------------------- */ public static function register_routes(): void { $ns = defined( 'HATCH_REST_NAMESPACE' ) ? HATCH_REST_NAMESPACE : 'hatch/v1'; register_rest_route( $ns, '/auth/login', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'route_login' ), 'permission_callback' => array( 'Hatch_Rest_Permission', 'public_submit' ), ) ); register_rest_route( $ns, '/auth/register', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'route_register' ), 'permission_callback' => array( 'Hatch_Rest_Permission', 'public_submit' ), ) ); register_rest_route( $ns, '/auth/logout', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'route_logout' ), 'permission_callback' => array( 'Hatch_Rest_Permission', 'public_submit' ), ) ); register_rest_route( $ns, '/auth/me', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'route_me' ), 'permission_callback' => array( 'Hatch_Rest_Permission', 'session_required' ), ) ); register_rest_route( $ns, '/auth/refresh', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'route_refresh' ), 'permission_callback' => array( 'Hatch_Rest_Permission', 'session_required' ), ) ); } /* --------------------------------------------------------------------- * * Route: login * --------------------------------------------------------------------- */ /** * POST /auth/login. * * Why this exists: a headless frontend on another domain cannot use wp-login.php * (its cookie would be set for the WordPress domain), so it needs a login * endpoint that returns a session it can hold. The route does not check * passwords itself: it calls core wp_authenticate(), which runs the whole * `authenticate` filter chain, so login-limit, 2FA and CAPTCHA plugins still * apply, and core fires `wp_login_failed` on every failure. Application * Passwords are switched off for this call so a scoped credential cannot be * traded for a full session. * * @param WP_REST_Request $req Request. * @return WP_REST_Response|WP_Error */ public static function route_login( WP_REST_Request $req ) { $ip = self::client_ip(); if ( self::rate_limited( $ip ) ) { return new WP_Error( 'hatch_auth_rate_limited', __( 'Too many attempts. Try again in a few minutes.', 'hatch-connect' ), array( 'status' => 429 ) ); } $username = sanitize_user( (string) $req->get_param( 'username' ), true ); $password = (string) $req->get_param( 'password' ); if ( '' === $username || '' === $password ) { self::bump_rate_limit( $ip ); return new WP_Error( 'hatch_auth_bad_request', __( 'Username and password are required.', 'hatch-connect' ), array( 'status' => 400 ) ); } // During a REST request core also accepts an Application Password here. That // would trade a scoped, revocable credential for a full login session (JWT plus // wp-admin cookie), so this route accepts the account password only. $app_password_hooked = has_filter( 'authenticate', 'wp_authenticate_application_password' ); if ( false !== $app_password_hooked ) { remove_filter( 'authenticate', 'wp_authenticate_application_password', (int) $app_password_hooked ); } $user = wp_authenticate( $username, $password ); if ( false !== $app_password_hooked ) { add_filter( 'authenticate', 'wp_authenticate_application_password', (int) $app_password_hooked, 3 ); } if ( is_wp_error( $user ) ) { self::bump_rate_limit( $ip ); return new WP_Error( 'hatch_auth_invalid', __( 'Invalid username or password.', 'hatch-connect' ), array( 'status' => 401 ) ); } return self::success_response( $user ); } /* --------------------------------------------------------------------- * * Route: register * --------------------------------------------------------------------- */ /** * POST /auth/register. * * Why this exists: a headless frontend cannot post to wp-login.php?action=register * from another domain, so it needs its own endpoint. It only works when the * core "Anyone can register" setting is on. It runs the same checks core's * register_new_user() runs (illegal_user_logins, `register_post`, * `registration_errors`) so anti-spam and security plugins can block it, * creates the user with core wp_create_user() (which assigns the core * "New User Default Role" setting, never a role chosen here), and fires core's * `register_new_user` action and admin notification. * * @param WP_REST_Request $req Request. * @return WP_REST_Response|WP_Error */ public static function route_register( WP_REST_Request $req ) { // Honeypot - bots fill hidden fields. Silent reject. $hp = (string) $req->get_param( 'hp_website' ); if ( '' !== trim( $hp ) ) { return new WP_Error( 'hatch_auth_spam', __( 'Registration failed.', 'hatch-connect' ), array( 'status' => 400 ) ); } if ( ! (int) get_option( 'users_can_register' ) ) { return new WP_Error( 'hatch_auth_disabled', __( 'Registration is disabled on this site.', 'hatch-connect' ), array( 'status' => 403 ) ); } if ( self::default_role_is_staff() ) { return new WP_Error( 'hatch_auth_staff_role', __( 'Registration is disabled on this site.', 'hatch-connect' ), array( 'status' => 403 ) ); } $ip = self::client_ip(); if ( self::rate_limited( $ip ) ) { return new WP_Error( 'hatch_auth_rate_limited', __( 'Too many attempts. Try again in a few minutes.', 'hatch-connect' ), array( 'status' => 429 ) ); } $raw_login = (string) $req->get_param( 'username' ); $username = sanitize_user( $raw_login, true ); $email = sanitize_email( (string) $req->get_param( 'email' ) ); $password = (string) $req->get_param( 'password' ); // Core's registration refuses a login that sanitising would change; do the same. if ( '' !== $username && ( $username !== $raw_login || ! validate_username( $username ) ) ) { self::bump_rate_limit( $ip ); return new WP_Error( 'hatch_auth_bad_username', __( 'That username has characters that are not allowed.', 'hatch-connect' ), array( 'status' => 400 ) ); } if ( '' === $username || ! is_email( $email ) || strlen( $password ) < 8 ) { self::bump_rate_limit( $ip ); return new WP_Error( 'hatch_auth_bad_request', __( 'Username, valid email, and 8+ char password required.', 'hatch-connect' ), array( 'status' => 400 ) ); } if ( username_exists( $username ) || email_exists( $email ) ) { self::bump_rate_limit( $ip ); return new WP_Error( 'hatch_auth_exists', __( 'A user with that username or email already exists.', 'hatch-connect' ), array( 'status' => 409 ) ); } // Same gate core's register_new_user() applies, so security and anti-spam plugins can block this. /** This filter is documented in wp-includes/user.php */ $illegal = array_map( 'strtolower', (array) apply_filters( 'illegal_user_logins', array() ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook, documented in wp-includes/user.php. $errors = new WP_Error(); if ( in_array( strtolower( $username ), $illegal, true ) ) { $errors->add( 'invalid_username', __( 'Sorry, that username is not allowed.', 'hatch-connect' ) ); } /** This action is documented in wp-includes/user.php */ do_action( 'register_post', $username, $email, $errors ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook, documented in wp-includes/user.php. /** This filter is documented in wp-includes/user.php */ $errors = apply_filters( 'registration_errors', $errors, $username, $email ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook, documented in wp-includes/user.php. if ( is_wp_error( $errors ) && $errors->has_errors() ) { self::bump_rate_limit( $ip ); return new WP_Error( 'hatch_auth_registration_blocked', wp_strip_all_tags( $errors->get_error_message() ), array( 'status' => 400 ) ); } // Core's user insert (behind wp_create_user) gives the user the "New User Default Role" setting. $user_id = wp_create_user( $username, $password, $email ); if ( is_wp_error( $user_id ) ) { return new WP_Error( 'hatch_auth_create_failed', $user_id->get_error_message(), array( 'status' => 400 ) ); } // Core's default `register_new_user` hook mails the new user a set-password link, which makes no // sense when they just chose a password. Run the action for every other listener, then tell the admin. $default_hook = has_action( 'register_new_user', 'wp_send_new_user_notifications' ); if ( false !== $default_hook ) { remove_action( 'register_new_user', 'wp_send_new_user_notifications', (int) $default_hook ); } /** This action is documented in wp-includes/user.php */ do_action( 'register_new_user', $user_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook, documented in wp-includes/user.php. if ( false !== $default_hook ) { add_action( 'register_new_user', 'wp_send_new_user_notifications', (int) $default_hook ); } wp_new_user_notification( $user_id, null, 'admin' ); $user = get_user_by( 'id', $user_id ); if ( ! $user ) { return new WP_Error( 'hatch_auth_create_failed', __( 'User creation failed.', 'hatch-connect' ), array( 'status' => 500 ) ); } return self::success_response( $user ); } /* --------------------------------------------------------------------- * * Route: logout * --------------------------------------------------------------------- */ public static function route_logout( WP_REST_Request $req ) { // Bump the user's token generation so every token issued before this // point stops working, not only the cookie held by this browser. $payload = self::verify_incoming_token( $req ); if ( $payload ) { $uid = (int) $payload['sub']; update_user_meta( $uid, self::VERSION_META, self::token_version( $uid ) + 1 ); } self::clear_cookie(); self::end_native_session(); return new WP_REST_Response( array( 'ok' => true ), 200 ); } /** * End the native WordPress session of this browser. * * Login mints a wordpress_logged_in cookie next to the hatch_jwt one, so logout * has to end both. Only the session in this request's cookie is destroyed; the * user's sessions on other devices are not touched (their JWTs are already * invalid after the token version bump). * * @return void */ private static function end_native_session(): void { if ( ! function_exists( 'wp_parse_auth_cookie' ) || ! function_exists( 'wp_clear_auth_cookie' ) ) { return; } $cookie = wp_parse_auth_cookie( '', 'logged_in' ); if ( is_array( $cookie ) && ! empty( $cookie['username'] ) && ! empty( $cookie['token'] ) ) { $owner = get_user_by( 'login', (string) $cookie['username'] ); if ( $owner ) { WP_Session_Tokens::get_instance( (int) $owner->ID )->destroy( (string) $cookie['token'] ); } } if ( ! headers_sent() ) { wp_clear_auth_cookie(); } } /* --------------------------------------------------------------------- * * Route: me * --------------------------------------------------------------------- */ public static function route_me( WP_REST_Request $req ) { $payload = self::verify_incoming_token( $req ); if ( ! $payload ) { return new WP_Error( 'hatch_auth_unauthorized', __( 'Not authenticated.', 'hatch-connect' ), array( 'status' => 401 ) ); } $user = get_user_by( 'id', (int) $payload['sub'] ); if ( ! $user || ! self::token_matches_user( $payload, $user ) ) { return new WP_Error( 'hatch_auth_unauthorized', __( 'Your session is no longer valid. Sign in again.', 'hatch-connect' ), array( 'status' => 401 ) ); } return new WP_REST_Response( array( 'user' => self::user_shape( $user ), 'expires_at' => (int) $payload['exp'], 'nonce' => self::nonce_for( $user ), ), 200 ); } /* --------------------------------------------------------------------- * * Route: refresh * --------------------------------------------------------------------- */ public static function route_refresh( WP_REST_Request $req ) { $payload = self::verify_incoming_token( $req ); if ( ! $payload ) { return new WP_Error( 'hatch_auth_unauthorized', __( 'Not authenticated.', 'hatch-connect' ), array( 'status' => 401 ) ); } $now = time(); $exp = (int) $payload['exp']; if ( ( $exp - $now ) > self::REFRESH_WINDOW ) { return new WP_Error( 'hatch_auth_refresh_too_early', __( 'Token not eligible for refresh yet.', 'hatch-connect' ), array( 'status' => 400 ) ); } $user = get_user_by( 'id', (int) $payload['sub'] ); if ( ! $user || ! self::token_matches_user( $payload, $user ) ) { return new WP_Error( 'hatch_auth_unauthorized', __( 'Your session is no longer valid. Sign in again.', 'hatch-connect' ), array( 'status' => 401 ) ); } return self::success_response( $user ); } /* --------------------------------------------------------------------- * * Comment gating - populate current user from JWT so wp/v2/comments * posts as the JWT holder. * --------------------------------------------------------------------- */ public static function attach_jwt_user( $result, $server, $request ) { if ( ! is_null( $result ) ) { return $result; } $route = (string) $request->get_route(); $method = strtoupper( (string) $request->get_method() ); if ( 'POST' !== $method ) { return $result; } // Match /wp/v2/comments and /hatch/v1/comments. if ( '/wp/v2/comments' !== $route && '/hatch/v1/comments' !== $route ) { return $result; } if ( is_user_logged_in() ) { return $result; } list( $payload, $via_cookie ) = self::incoming_token( $request ); if ( $payload && ! empty( $payload['sub'] ) ) { $uid = (int) $payload['sub']; $token_owner = get_user_by( 'id', $uid ); if ( $token_owner && self::token_matches_user( $payload, $token_owner ) ) { // Technically necessary: a headless commenter holds a signed JWT, not a WordPress cookie, so // core's comment permission check only sees the person once the request user is set. The token // was signed by this site, and a cookie-borne one is dropped again below unless a nonce proves intent. wp_set_current_user( $uid ); // A cookie-borne token is sent by the browser on its own, so it // only counts when the request also proves same-origin intent. if ( $via_cookie && ! self::has_valid_rest_nonce() ) { wp_set_current_user( 0 ); } } } return $result; } /* --------------------------------------------------------------------- * * REST request authentication from hatch_jwt cookie / Bearer token. * Runs on `determine_current_user` so wp_get_current_user() reflects * the JWT holder across every REST endpoint (Woo Store API, wp/v2, etc.) * --------------------------------------------------------------------- */ public static function authenticate_jwt( $user_id ) { if ( ! empty( $user_id ) ) { return $user_id; } // Cookie first. $token = Hatch_Request::cookie( self::COOKIE_NAME ); $via_cookie = '' !== $token; if ( '' === $token ) { $auth = Hatch_Request::authorization(); if ( $auth && stripos( $auth, 'Bearer ' ) === 0 ) { $token = trim( substr( $auth, 7 ) ); } } if ( '' === $token ) { return $user_id; } $payload = self::verify_token( $token ); if ( ! $payload || empty( $payload['sub'] ) ) { return $user_id; } $uid = (int) $payload['sub']; $user = get_user_by( 'id', $uid ); if ( ! $user || ! self::token_matches_user( $payload, $user ) ) { return $user_id; } self::$cookie_auth_uid = $via_cookie ? $uid : 0; return $uid; } /** * Apply WordPress core's cookie rule to a user authenticated from the * hatch_jwt cookie: a state-changing REST request needs a valid X-WP-Nonce. * * Mirrors rest_cookie_check_errors(): with no nonce the request continues * as an anonymous visitor; with a nonce that does not verify it fails with * a 403. Safe methods (GET, HEAD, OPTIONS) are not changed. Requests * authenticated by an Authorization header never reach this branch. * * @param WP_Error|null|true $result Result of earlier authentication checks. * @return WP_Error|null|true */ public static function require_nonce_for_cookie_auth( $result ) { if ( ! empty( $result ) || 0 === self::$cookie_auth_uid ) { return $result; } if ( get_current_user_id() !== self::$cookie_auth_uid ) { return $result; } $method = strtoupper( Hatch_Request::server( 'REQUEST_METHOD' ) ); if ( in_array( $method, array( 'GET', 'HEAD', 'OPTIONS' ), true ) ) { return $result; } $nonce = Hatch_Request::server( 'HTTP_X_WP_NONCE' ); // Dropping to user 0 is a safety step, not a login: a cookie session without a valid nonce acts as a guest (CSRF defence). if ( '' === $nonce ) { wp_set_current_user( 0 ); return $result; } if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { wp_set_current_user( 0 ); return new WP_Error( 'hatch_auth_invalid_nonce', __( 'Cookie check failed. Send a valid X-WP-Nonce header.', 'hatch-connect' ), array( 'status' => 403 ) ); } return $result; } /** * Does this request carry a valid wp_rest nonce for the current user? * * @return bool */ private static function has_valid_rest_nonce(): bool { $nonce = Hatch_Request::server( 'HTTP_X_WP_NONCE' ); return '' !== $nonce && false !== wp_verify_nonce( $nonce, 'wp_rest' ); } /* --------------------------------------------------------------------- * * Response helpers * --------------------------------------------------------------------- */ /** * Issue the frontend session for a signed-in user, or refuse a staff account. * * @param WP_User $user User. * @return WP_REST_Response|WP_Error */ private static function success_response( WP_User $user ) { if ( self::is_staff( $user ) ) { return new WP_Error( 'hatch_auth_staff', __( 'Staff accounts sign in at wp-admin, not on the frontend.', 'hatch-connect' ), array( 'status' => 403 ) ); } $now = time(); $exp = $now + self::TOKEN_TTL; $token = self::sign_token( array( 'iss' => 'hatch', 'iat' => $now, 'exp' => $exp, 'sub' => (int) $user->ID, 'ver' => self::token_version( (int) $user->ID ), 'pw' => self::password_stamp( $user ), 'roles' => array_values( (array) $user->roles ), ) ); self::set_cookie( $token, $exp ); // Also mint a real WordPress session cookie (wordpress_logged_in_*). // The Store API keys carts + orders to the WP user id, which requires // the native auth cookie. JWT alone is not enough for Woo binding. // Standards followed: pluggable.php wp_set_auth_cookie() contract // and RFC 6265 (HttpOnly SameSite cookies). $wp_login_set = false; if ( function_exists( 'wp_set_auth_cookie' ) && ! headers_sent() ) { // Technically necessary: wp_set_auth_cookie() and the Store API read the request user, and the // user was already authenticated above by core wp_authenticate() or just registered. wp_set_current_user( (int) $user->ID ); wp_set_auth_cookie( (int) $user->ID, true, is_ssl() ); do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core action, so login listeners (security and audit plugins) see frontend sign-ins. $wp_login_set = true; } return new WP_REST_Response( array( 'token' => $token, 'user' => self::user_shape( $user ), 'expires_at' => $exp, 'nonce' => self::nonce_for( $user ), 'wp_login' => $wp_login_set, 'ok' => true, ), 200 ); } /** * A wp_rest nonce for the given user, for clients that authenticate with * the hatch_jwt cookie and must send X-WP-Nonce on writes. * * @param WP_User $user User the nonce is for. * @return string */ private static function nonce_for( WP_User $user ): string { if ( get_current_user_id() !== (int) $user->ID ) { // wp_create_nonce() is tied to the request user, so the nonce for this just-authenticated user needs it set. wp_set_current_user( (int) $user->ID ); } return wp_create_nonce( 'wp_rest' ); } /** * Public user object. Roles are read from the user record at request time, * never from the token, so a demotion applies on the next request. * * @param WP_User $user User. * @return array */ private static function user_shape( WP_User $user ): array { return array( 'id' => (int) $user->ID, 'name' => $user->display_name, 'email' => $user->user_email, 'roles' => array_values( (array) $user->roles ), 'avatar' => get_avatar_url( $user->ID, array( 'size' => 96 ) ), ); } /* --------------------------------------------------------------------- * * JWT (HS256) - RFC 7519, fresh implementation on hash_hmac. * --------------------------------------------------------------------- */ private static function get_secret(): string { $secret = (string) get_option( self::OPTION_SECRET, '' ); if ( '' === $secret || strlen( $secret ) < 32 ) { $secret = wp_generate_password( 64, false ); update_option( self::OPTION_SECRET, $secret, false ); } return $secret; } private static function base64_url_encode( string $bytes ): string { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url segment encoding for a JWT (RFC 7515), not obfuscation. return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); } private static function base64_url_decode( string $s ): string { $pad = strlen( $s ) % 4; if ( $pad > 0 ) { $s .= str_repeat( '=', 4 - $pad ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- base64url segment decoding for a JWT (RFC 7515), strict mode; not obfuscation. return (string) base64_decode( strtr( $s, '-_', '+/' ), true ); } /** * Current token generation for a user. Logging out raises it. * * @param int $user_id User ID. * @return int */ private static function token_version( int $user_id ): int { return (int) get_user_meta( $user_id, self::VERSION_META, true ); } /** * Short stamp of the user's current password hash. A password change or * reset alters it, which retires every token minted before the change. * * @param WP_User $user User. * @return string */ private static function password_stamp( WP_User $user ): string { return substr( hash_hmac( 'sha256', (string) $user->user_pass, self::get_secret() ), 0, 16 ); } /** * Is this a staff account (see STAFF_CAPS)? * * @param WP_User $user User. * @return bool */ public static function is_staff( WP_User $user ): bool { foreach ( self::STAFF_CAPS as $cap ) { if ( user_can( $user, $cap ) ) { return true; } } return false; } /** * Would a new sign-up get a staff role? True when the "New User Default * Role" is missing or carries any STAFF_CAPS, so registration never * creates an account that frontend sign-in would then refuse. */ public static function default_role_is_staff(): bool { $role = get_role( (string) get_option( 'default_role', 'subscriber' ) ); if ( ! $role ) { return true; } foreach ( self::STAFF_CAPS as $cap ) { if ( $role->has_cap( $cap ) ) { return true; } } return false; } /** * Does a verified token still belong to the user's current session? * * @param array $payload Verified token payload. * @param WP_User $user User the token names. * @return bool */ private static function token_matches_user( array $payload, WP_User $user ): bool { if ( ! isset( $payload['ver'], $payload['pw'] ) || self::is_staff( $user ) ) { return false; } return self::token_version( (int) $user->ID ) === (int) $payload['ver'] && hash_equals( self::password_stamp( $user ), (string) $payload['pw'] ); } public static function sign_token( array $payload ): string { $header = array( 'alg' => 'HS256', 'typ' => 'JWT', ); $h_seg = self::base64_url_encode( (string) wp_json_encode( $header ) ); $p_seg = self::base64_url_encode( (string) wp_json_encode( $payload ) ); $signing = $h_seg . '.' . $p_seg; $sig_bytes = hash_hmac( 'sha256', $signing, self::get_secret(), true ); return $signing . '.' . self::base64_url_encode( $sig_bytes ); } public static function verify_token( string $token ): ?array { $parts = explode( '.', $token ); if ( 3 !== count( $parts ) ) { return null; } list( $h_seg, $p_seg, $s_seg ) = $parts; $header = json_decode( self::base64_url_decode( $h_seg ), true ); if ( ! is_array( $header ) || ! isset( $header['alg'] ) || 'HS256' !== $header['alg'] ) { return null; } $expected = hash_hmac( 'sha256', $h_seg . '.' . $p_seg, self::get_secret(), true ); $provided = self::base64_url_decode( $s_seg ); if ( ! hash_equals( $expected, $provided ) ) { return null; } $payload = json_decode( self::base64_url_decode( $p_seg ), true ); if ( ! is_array( $payload ) || empty( $payload['sub'] ) || empty( $payload['exp'] ) ) { return null; } if ( time() >= (int) $payload['exp'] ) { return null; } // Reject any token whose lifetime exceeds the current TTL, including // tokens minted before the TTL was shortened from 7 days to 24 hours. if ( empty( $payload['iat'] ) || ( (int) $payload['exp'] - (int) $payload['iat'] ) > self::TOKEN_TTL ) { return null; } if ( ! isset( $payload['iss'] ) || 'hatch' !== $payload['iss'] ) { return null; } return $payload; } /** * Read and verify the token on a REST request. * * @param WP_REST_Request $req Request. * @return array{0: ?array, 1: bool} Verified payload (or null) and whether it came from the cookie. */ private static function incoming_token( WP_REST_Request $req ): array { // Cookie first, then Authorization: Bearer. $tok = Hatch_Request::cookie( self::COOKIE_NAME ); if ( '' !== $tok ) { $p = self::verify_token( $tok ); if ( $p ) { return array( $p, true ); } } $auth = (string) $req->get_header( 'authorization' ); if ( '' === $auth ) { $auth = (string) $req->get_header( 'Authorization' ); } if ( $auth && stripos( $auth, 'Bearer ' ) === 0 ) { $tok = trim( substr( $auth, 7 ) ); return array( self::verify_token( $tok ), false ); } return array( null, false ); } /** * Whether the request carries a Hatch session token that still matches its user. * * @param WP_REST_Request $req Current request. * @return bool */ public static function has_valid_session( WP_REST_Request $req ): bool { $payload = self::verify_incoming_token( $req ); if ( ! $payload ) { return false; } $user = get_user_by( 'id', (int) $payload['sub'] ); return $user instanceof WP_User && self::token_matches_user( $payload, $user ); } private static function verify_incoming_token( WP_REST_Request $req ): ?array { list( $payload ) = self::incoming_token( $req ); return $payload; } /* --------------------------------------------------------------------- * * Cookie - RFC 6265, HttpOnly + SameSite=Lax; Secure only on HTTPS. * --------------------------------------------------------------------- */ private static function set_cookie( string $token, int $expires ): void { if ( headers_sent() ) { return; } $secure = is_ssl(); $samesite = 'Lax'; // PHP 7.3+ array signature. setcookie( self::COOKIE_NAME, $token, array( 'expires' => $expires, 'path' => '/', 'domain' => '', 'secure' => $secure, 'httponly' => true, 'samesite' => $samesite, ) ); $_COOKIE[ self::COOKIE_NAME ] = $token; } private static function clear_cookie(): void { if ( headers_sent() ) { return; } setcookie( self::COOKIE_NAME, '', array( 'expires' => time() - 3600, 'path' => '/', 'domain' => '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); unset( $_COOKIE[ self::COOKIE_NAME ] ); } /* --------------------------------------------------------------------- * * Rate limiting - per-IP, transient-backed. * --------------------------------------------------------------------- */ /** * Return the client IP for rate-limiting. * * Default: read $_SERVER['REMOTE_ADDR'] only. This is safe on every host * (proxy-header spoofing is impossible), at the cost of counting all * traffic behind a CDN/proxy as one bucket. * * Opt-in: when the `hatch_trust_cf_ip` option is truthy AND the direct * connection comes from a Cloudflare edge range, trust CF-Connecting-IP. * Falling back to the first entry of X-Forwarded-For under the same * gate covers non-CF trusted proxies. Anything else falls through to * REMOTE_ADDR - never trust proxy headers from an untrusted peer. * * Backlog #145. * * @return string */ public static function client_ip(): string { $remote = Hatch_Request::server( 'REMOTE_ADDR' ); if ( '' === $remote ) { $remote = '0.0.0.0'; } $ip = $remote; if ( get_option( 'hatch_trust_cf_ip', false ) && self::request_from_trusted_edge( $remote ) ) { $cf_ip = Hatch_Request::server( 'HTTP_CF_CONNECTING_IP' ); $forwarded = Hatch_Request::server( 'HTTP_X_FORWARDED_FOR' ); if ( '' !== $cf_ip ) { $ip = $cf_ip; } elseif ( '' !== $forwarded ) { $parts = explode( ',', $forwarded ); $ip = trim( (string) $parts[0] ); } } // Sanitize to IPv4/IPv6 charset. filter_var enforces real IP shape. $ip = preg_replace( '/[^0-9a-fA-F:\.]/', '', $ip ); if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $remote; // Reject spoofed junk, fall back to REMOTE_ADDR. } return $ip; } /** * Is the direct peer inside a Cloudflare edge range? Cheap CIDR match * against the published /15/13/12 blocks Cloudflare has used since * 2015. Not exhaustive but covers >99% of live edges; operators who * front with something else should keep hatch_trust_cf_ip = false. * * @param string $remote_ip Direct peer IP. * @return bool */ private static function request_from_trusted_edge( string $remote_ip ): bool { if ( '' === $remote_ip || false === filter_var( $remote_ip, FILTER_VALIDATE_IP ) ) { return false; } // Only IPv4 gate implemented; extending to Cloudflare's IPv6 /32/64 // list is a later change. Conservative default: reject on unknown. $ranges = array( '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', ); $ip_long = ip2long( $remote_ip ); if ( false === $ip_long ) { return false; } foreach ( $ranges as $cidr ) { list( $subnet, $bits ) = explode( '/', $cidr ); $subnet_long = ip2long( $subnet ); $mask = -1 << ( 32 - (int) $bits ); if ( ( $ip_long & $mask ) === ( $subnet_long & $mask ) ) { return true; } } return false; } private static function rl_key( string $ip ): string { return 'hatch_auth_rl_' . md5( $ip ); } private static function rate_limited( string $ip ): bool { $n = (int) get_transient( self::rl_key( $ip ) ); return $n >= self::RL_MAX_ATTEMPTS; } private static function bump_rate_limit( string $ip ): void { $key = self::rl_key( $ip ); $n = (int) get_transient( $key ); set_transient( $key, $n + 1, self::RL_WINDOW ); } } // Self-boot: registers rest_api_init + rest_pre_dispatch on first load. Hatch_Auth::instance();