В классической монолитной архитектуре WordPress клиентский интерфейс неразрывно связан с серверным рендерингом PHP. Безопасность сессий базируется на cookie-файлах ядра (LOGGED_IN_COOKIE) и одноразовых токенах Nonce. Однако в современных экосистемах WordPress все чаще выступает в роли Headless CMS или поставщика данных для мобильных приложений на React Native/Flutter, SPA-витрин на Next.js и внешних корпоративных микросервисов.
Попытка использовать стандартные сессионные механизмы WordPress для внешних клиентов сталкивается с непреодолимыми архитектурными барьерами:
app.company.com к api.company.com.functions.php, пренебрегают JSON-схемами, выполняют сырую санитизацию в теле функции и возвращают произвольные форматы ответов, нарушая контракты API.'permission_callback' => '__return_true' для мутирующих запросов (POST, PUT, DELETE) или проверка прав внутри основного обработчика вместо permission_callback. Это приводит к утечке закрытых данных и некорректным HTTP-статусам (возврат 200 OK с телом { "error": "unauthorized" } вместо 401/403).localStorage браузера подвергает учетные записи угрозе кражи через XSS, а отсутствие механизма отзыва (Revocation List) не позволяет заблокировать скомпрометированный токен до истечения его срока жизни.Для построения безопасного промышленного API требуется переход к строго типизированным контроллерам на базе WP_REST_Controller, централизованной декларации схемы сущностей и stateless-аутентификации.
В WordPress REST API аутентификация (установление личности) и авторизация (проверка прав) разнесены по разным фазам жизненного цикла:
determine_current_user. Токен извлекается из заголовка Authorization: Bearer <token>, валидируется криптографическая подпись, и ядро устанавливает глобального пользователя через wp_set_current_user($user_id).permission_callback конкретного маршрута. Здесь проверяются возможности (current_user_can()) применительно к запрашиваемому объекту. Если доступ запрещен, ядро возвращает клиенту стандартный RFC-совместимый ответ 401 Unauthorized или 403 Forbidden без запуска бизнес-логики.Архитектура REST API WordPress построена на принципах конечного автомата, преобразующего входящий глобальный HTTP-запрос в строго структурированный объект WP_REST_Request, который проходит сквозь цепочку фильтров аутентификации, диспетчеризации и проверки схемы перед передачей контроллеру.
Сервер перехватывает HTTP-заголовок Authorization: Bearer. JWT-сервис валидирует сигнатуру токена, проверяет срок годности (exp) и устанавливает контекст пользователя в ядре WordPress.
При недействительном или отозванном токене возвращается WP_Error с HTTP-статусом 401.
Для междоменных запросов проверяется белый список разрешенных доменов Origin. Браузерные Preflight-запросы (OPTIONS) получают заголовки разрешенных методов и кэшируются на 24 часа.
Запросы OPTIONS обязаны пропускаться без требования заголовка Authorization.
Ядро сопоставляет запрошенный URI с таблицей зарегистрированных маршрутов WP_REST_Server. Входящие аргументы автоматически валидируются по строгой JSON-схеме контроллера.
Некорректные типы данных отсекаются с кодом 400 Bad Request до запуска контроллера.
Выполняется проверка прав доступа текущего пользователя (current_user_can). Проверяется не только роль, но и право на работу с конкретным экземпляром сущности (ID ресурса).
Никакой код контроллера не выполняется, пока permission_callback не вернет true.
Метод контроллера извлекает данные из хранилища, форматирует их в соответствии со схемой prepare_item_for_response(), генерирует навигационные HATEOAS-ссылки и отдает JSON.
Заголовки пагинации X-WP-Total и X-WP-TotalPages рассчитываются и передаются в ответе.
Понимание последовательности хуков исключает распространенные ошибки внедрения аутентификации в неподходящие фазы загрузки WordPress:
Запрос Next.js / Mobile с заголовком Authorization: Bearer <token>
JWT Auth Middleware парсит подпись HMAC-SHA256, верифицирует claims и вызывает wp_set_current_user()
Глобальный фильтр ошибок авторизации; отсекает невалидные токены с кодом 401
Поиск маршрута в пространстве имен agenticlab/v1 и валидация аргументов по get_item_schema()
Изолированная проверка capability (current_user_can) для запрошенного ресурса
Выполнение бизнес-логики в наследнике WP_REST_Controller, генерация HATEOAS и WP_REST_Response
В каком месте архитектуры WordPress REST API разработчик обязан реализовать проверку прав пользователя на редактирование сущности (например, current_user_can('edit_post', $id))?
При интеграции внешних сервисов с WordPress разработчик должен осознанно выбирать протокол взаимодействия исходя из архитектурных требований безопасности и природы клиентов:
Фронтенд на Next.js отправляет POST-запрос с заголовком 'Authorization: Bearer <token>' на WordPress REST API. В консоли браузера падает ошибка: 'CORS Preflight request failed: 401 Unauthorized'. Что послужило причиной сбоя и как его устранить?
Ниже представлены четыре полнофункциональных компонента production-уровня, реализующих архитектурный стандарт Fullstack Agentic Lab 2.0 для изолированного REST API.
Данный класс демонстрирует эталонное наследование WP_REST_Controller с декларацией пространства имен, строгой схемы JSON Schema v4, типизацией параметров, валидацией, санитарной очисткой и генерацией HATEOAS-связей.
<?php
declare(strict_types=1);
namespace AgenticLab\Api;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WP_Post;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Промышленный контроллер для управления портфолио проектов через REST API.
* Реализует стандарты WP_REST_Controller, строгую JSON-схему и HATEOAS ссылки.
*/
class AgenticLab_Projects_Controller extends WP_REST_Controller {
/**
* Префикс пространства имен и версия API.
*/
protected $namespace = 'agenticlab/v1';
/**
* Имя ресурса (базовый слаг маршрутов).
*/
protected $rest_base = 'projects';
/**
* Пользовательский тип записи, с которым работает контроллер.
*/
private const POST_TYPE = 'agentic_project';
/**
* Регистрация маршрутов коллекции и отдельных сущностей.
*/
public function register_routes(): void {
// Маршрут коллекции: GET (список) и POST (создание)
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => $this->get_collection_params(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'create_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
// Маршрут отдельного элемента: GET (чтение), PUT/PATCH (обновление), DELETE (удаление)
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
'args' => [
'id' => [
'description' => __( 'Уникальный целочисленный идентификатор проекта.', 'agenticlab' ),
'type' => 'integer',
'validate_callback' => static fn( $param ) => is_numeric( $param ) && (int) $param > 0,
],
],
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_item' ],
'permission_callback' => [ $this, 'get_item_permissions_check' ],
'args' => [
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'delete_item_permissions_check' ],
'args' => [
'force' => [
'type' => 'boolean',
'default' => false,
'description' => __( 'Удалить безвозвратно в обход корзины.', 'agenticlab' ),
],
],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
/**
* Проверка прав доступа для чтения коллекции проектов.
*/
public function get_items_permissions_check( $request ): bool {
// Публичный доступ к опубликованным проектам разрешен всем
return true;
}
/**
* Получение коллекции проектов с пагинацией и расчетом заголовков.
*/
public function get_items( $request ): WP_REST_Response|WP_Error {
$page = (int) $request->get_param( 'page' );
$per_page = (int) $request->get_param( 'per_page' );
$search = $request->get_param( 'search' );
$query_args = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'paged' => $page,
'posts_per_page' => $per_page,
];
if ( ! empty( $search ) ) {
$query_args['s'] = sanitize_text_field( (string) $search );
}
$query = new \WP_Query( $query_args );
$posts = $query->get_posts();
$data = [];
foreach ( $posts as $post ) {
$response = $this->prepare_item_for_response( $post, $request );
$data[] = $this->prepare_response_for_collection( $response );
}
$response = new WP_REST_Response( $data, 200 );
// Пагинация в заголовках по стандартам RFC
$total_posts = (int) $query->found_posts;
$max_pages = (int) $query->max_num_pages;
$response->header( 'X-WP-Total', (string) $total_posts );
$response->header( 'X-WP-TotalPages', (string) $max_pages );
return $response;
}
/**
* Проверка прав на создание сущности.
*/
public function create_item_permissions_check( $request ): bool|WP_Error {
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_not_logged_in',
__( 'Для создания проектов требуется авторизация.', 'agenticlab' ),
[ 'status' => 401 ]
);
}
if ( ! current_user_can( 'publish_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'У вашей учетной записи недостаточно прав для публикации.', 'agenticlab' ),
[ 'status' => 403 ]
);
}
return true;
}
/**
* Создание нового проекта на основе входящих данных.
*/
public function create_item( $request ): WP_REST_Response|WP_Error {
$prepared = $this->prepare_item_for_database( $request );
if ( is_wp_error( $prepared ) ) {
return $prepared;
}
$post_id = wp_insert_post( wp_slash( (array) $prepared ), true );
if ( is_wp_error( $post_id ) ) {
return new WP_Error(
'rest_cannot_create',
$post_id->get_error_message(),
[ 'status' => 500 ]
);
}
// Сохранение метаполей из запроса
if ( $request->has_param( 'budget' ) ) {
update_post_meta( $post_id, '_project_budget', (float) $request->get_param( 'budget' ) );
}
if ( $request->has_param( 'client_url' ) ) {
update_post_meta( $post_id, '_project_client_url', esc_url_raw( (string) $request->get_param( 'client_url' ) ) );
}
$post = get_post( $post_id );
$response = $this->prepare_item_for_response( $post, $request );
$response->set_status( 201 );
$response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $post_id ) ) );
return $response;
}
/**
* Подготовка данных поста для записи в базу данных.
*/
protected function prepare_item_for_database( $request ): array|WP_Error {
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$content = wp_kses_post( (string) $request->get_param( 'content' ) );
if ( empty( $title ) ) {
return new WP_Error( 'rest_invalid_title', __( 'Заголовок проекта обязателен.', 'agenticlab' ), [ 'status' => 400 ] );
}
return [
'post_type' => self::POST_TYPE,
'post_title' => $title,
'post_content' => $content,
'post_status' => 'publish',
'post_author' => get_current_user_id(),
];
}
/**
* Подготовка объекта к сериализации в JSON с HATEOAS ссылками.
*/
public function prepare_item_for_response( $item, $request ): WP_REST_Response {
$post = $item instanceof WP_Post ? $item : get_post( (int) $item );
$data = [
'id' => (int) $post->ID,
'title' => get_the_title( $post ),
'content' => apply_filters( 'the_content', $post->post_content ),
'slug' => $post->post_name,
'created_at' => mysql_to_rfc3339( $post->post_date_gmt ),
'budget' => (float) get_post_meta( $post->ID, '_project_budget', true ),
'client_url' => (string) get_post_meta( $post->ID, '_project_client_url', true ),
];
$response = new WP_REST_Response( $data );
// Добавление HATEOAS гипермедиа-ссылок
$links = [
'self' => [
'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $post->ID ) ),
],
'collection' => [
'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
],
'author' => [
'href' => rest_url( sprintf( 'wp/v2/users/%d', $post->post_author ) ),
'embeddable' => true,
],
];
$response->add_links( $links );
return $response;
}
/**
* Определение строгой декларативной JSON-схемы сущности.
*/
public function get_item_schema(): array {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'project',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Уникальный числовой ID проекта.', 'agenticlab' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'title' => [
'description' => __( 'Наименование проекта.', 'agenticlab' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'required' => true,
'arg_options' => [
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
],
],
'content' => [
'description' => __( 'Подробное описание кейса.', 'agenticlab' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'sanitize_callback' => 'wp_kses_post',
],
],
'budget' => [
'description' => __( 'Бюджет проекта в долларах США.', 'agenticlab' ),
'type' => 'number',
'minimum' => 0,
'context' => [ 'view', 'edit' ],
'arg_options' => [
'validate_callback' => static fn( $val ) => is_numeric( $val ) && (float) $val >= 0,
],
],
'client_url' => [
'description' => __( 'Ссылка на продакшн сайт клиента.', 'agenticlab' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit' ],
'arg_options' => [
'sanitize_callback' => 'esc_url_raw',
'validate_callback' => 'rest_validate_request_arg',
],
],
],
];
return $this->add_additional_fields_schema( $this->schema );
}
/**
* Параметры коллекции для пагинации и поиска.
*/
public function get_collection_params(): array {
$params = parent::get_collection_params();
$params['per_page']['default'] = 10;
$params['per_page']['maximum'] = 50;
return $params;
}
}
Сервис реализует stateless-авторизацию по протоколу RFC 7519 на чистом PHP 8.2 без раздутых внешних зависимостей. Он подключается к хукам determine_current_user и rest_authentication_errors, поддерживает пары Access + Refresh Token и ротацию сессий.
<?php
declare(strict_types=1);
namespace AgenticLab\Auth;
use WP_Error;
use WP_User;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Криптографический сервис генерации и валидации JWT (HMAC-SHA256).
* Обеспечивает прозрачную stateless-аутентификацию для WP REST API.
*/
class JWT_Auth_Service {
private string $secret_key;
private int $access_ttl; // Время жизни Access Token в секундах (например, 15 мин)
private int $refresh_ttl; // Время жизни Refresh Token в секундах (например, 14 дней)
public function __construct() {
// Секретный ключ извлекается строго из wp-config.php
$this->secret_key = defined( 'Fullstack Agentic Lab_JWT_SECRET' ) ? Fullstack Agentic Lab_JWT_SECRET : '';
$this->access_ttl = 900; // 15 минут
$this->refresh_ttl = 1209600; // 14 дней
}
/**
* Подключение хуков ядра WordPress.
*/
public function init(): void {
// Перехват аутентификации пользователя для каждого запроса
add_filter( 'determine_current_user', [ $this, 'authenticate_request' ], 20 );
// Проверка ошибок аутентификации REST API
add_filter( 'rest_authentication_errors', [ $this, 'check_auth_errors' ] );
// Регистрация эндпоинтов выдачи и обновления токенов
add_action( 'rest_api_init', [ $this, 'register_auth_routes' ] );
}
/**
* Регистрация системных эндпоинтов аутентификации.
*/
public function register_auth_routes(): void {
register_rest_route( 'agenticlab/v1', '/auth/token', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'handle_login' ],
'permission_callback' => '__return_true', // Публичный вход
'args' => [
'username' => [ 'required' => true, 'type' => 'string' ],
'password' => [ 'required' => true, 'type' => 'string' ],
],
] );
register_rest_route( 'agenticlab/v1', '/auth/refresh', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'handle_refresh' ],
'permission_callback' => '__return_true',
'args' => [
'refresh_token' => [ 'required' => true, 'type' => 'string' ],
],
] );
}
/**
* Обработка авторизации по логину и паролю.
*/
public function handle_login( \WP_REST_Request $request ): \WP_REST_Response|WP_Error {
$username = sanitize_user( (string) $request->get_param( 'username' ) );
$password = (string) $request->get_param( 'password' );
$user = wp_authenticate( $username, $password );
if ( is_wp_error( $user ) ) {
return new WP_Error(
'jwt_invalid_credentials',
__( 'Неверное имя пользователя или пароль.', 'agenticlab' ),
[ 'status' => 403 ]
);
}
return new \WP_REST_Response( $this->generate_token_pair( $user ) );
}
/**
* Обработка обновления Access Token через Refresh Token.
*/
public function handle_refresh( \WP_REST_Request $request ): \WP_REST_Response|WP_Error {
$raw_refresh = (string) $request->get_param( 'refresh_token' );
$payload = $this->decode_and_verify( $raw_refresh, 'refresh' );
if ( is_wp_error( $payload ) ) {
return $payload;
}
$user_id = (int) $payload['sub'];
$user = get_user_by( 'id', $user_id );
if ( ! $user ) {
return new WP_Error( 'jwt_invalid_user', __( 'Пользователь не найден.', 'agenticlab' ), [ 'status' => 401 ] );
}
// Проверка совпадения версии токена (защита от повторного использования отозванного токена)
$stored_version = (int) get_user_meta( $user_id, '_jwt_token_version', true );
if ( (int) ( $payload['v'] ?? 0 ) !== $stored_version ) {
return new WP_Error( 'jwt_revoked_token', __( 'Сессия была отозвана. Авторизуйтесь заново.', 'agenticlab' ), [ 'status' => 401 ] );
}
return new \WP_REST_Response( $this->generate_token_pair( $user ) );
}
/**
* Генерация пары Access + Refresh Token.
*/
public function generate_token_pair( WP_User $user ): array {
$now = time();
$version = (int) get_user_meta( $user->ID, '_jwt_token_version', true );
$access_payload = [
'iss' => get_bloginfo( 'url' ),
'sub' => $user->ID,
'typ' => 'access',
'iat' => $now,
'exp' => $now + $this->access_ttl,
'v' => $version,
'user' => [
'id' => $user->ID,
'email' => $user->user_email,
'display_name' => $user->display_name,
'roles' => $user->roles,
],
];
$refresh_payload = [
'iss' => get_bloginfo( 'url' ),
'sub' => $user->ID,
'typ' => 'refresh',
'iat' => $now,
'exp' => $now + $this->refresh_ttl,
'v' => $version,
];
return [
'access_token' => $this->sign_jwt( $access_payload ),
'expires_in' => $this->access_ttl,
'refresh_token' => $this->sign_jwt( $refresh_payload ),
'token_type' => 'Bearer',
];
}
/**
* Валидация заголовка Authorization и установка пользователя в ядре.
*/
public function authenticate_request( int|bool $current_user_id ): int|bool {
// Если пользователь уже определен другим механизмом, не перезаписываем
if ( ! empty( $current_user_id ) ) {
return $current_user_id;
}
$header = $this->extract_authorization_header();
if ( empty( $header ) || ! str_starts_with( $header, 'Bearer ' ) ) {
return $current_user_id;
}
$jwt = substr( $header, 7 );
$payload = $this->decode_and_verify( $jwt, 'access' );
if ( is_wp_error( $payload ) ) {
// Сохраняем ошибку в глобальном реестре для rest_authentication_errors
$GLOBALS['agenticlab_jwt_auth_error'] = $payload;
return false;
}
return (int) $payload['sub'];
}
/**
* Контроль ошибок в хуке rest_authentication_errors.
*/
public function check_auth_errors( $error ) {
// Исключаем Preflight OPTIONS запросы из проверки аутентификации!
if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
return true;
}
if ( ! empty( $GLOBALS['agenticlab_jwt_auth_error'] ) && is_wp_error( $GLOBALS['agenticlab_jwt_auth_error'] ) ) {
return $GLOBALS['agenticlab_jwt_auth_error'];
}
return $error;
}
/**
* Извлечение заголовка с обходом ограничений Apache/FastCGI.
*/
private function extract_authorization_header(): string {
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
return trim( (string) $_SERVER['HTTP_AUTHORIZATION'] );
}
if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
return trim( (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
}
if ( function_exists( 'apache_request_headers' ) ) {
$headers = apache_request_headers();
if ( isset( $headers['Authorization'] ) ) {
return trim( (string) $headers['Authorization'] );
}
}
return '';
}
/**
* Подписание JWT токена алгоритмом HMAC-SHA256.
*/
private function sign_jwt( array $payload ): string {
$header = [ 'typ' => 'JWT', 'alg' => 'HS256' ];
$segments = [
$this->base64url_encode( (string) wp_json_encode( $header ) ),
$this->base64url_encode( (string) wp_json_encode( $payload ) ),
];
$signing_input = implode( '.', $segments );
$signature = hash_hmac( 'sha256', $signing_input, $this->secret_key, true );
$segments[] = $this->base64url_encode( $signature );
return implode( '.', $segments );
}
/**
* Верификация криптографической подписи и срока действия токена.
*/
private function decode_and_verify( string $jwt, string $expected_type ): array|WP_Error {
if ( empty( $this->secret_key ) ) {
return new WP_Error( 'jwt_misconfigured', 'JWT_SECRET не сконфигурирован в wp-config.php.', [ 'status' => 500 ] );
}
$parts = explode( '.', $jwt );
if ( count( $parts ) !== 3 ) {
return new WP_Error( 'jwt_invalid_format', 'Неверный формат токена.', [ 'status' => 401 ] );
}
[ $raw_header, $raw_payload, $raw_signature ] = $parts;
// Проверка подписи
$signing_input = $raw_header . '.' . $raw_payload;
$expected_signature = hash_hmac( 'sha256', $signing_input, $this->secret_key, true );
if ( ! hash_equals( $expected_signature, $this->base64url_decode( $raw_signature ) ) ) {
return new WP_Error( 'jwt_invalid_signature', 'Криптографическая подпись токена недействительна.', [ 'status' => 401 ] );
}
$payload = json_decode( $this->base64url_decode( $raw_payload ), true );
if ( ! is_array( $payload ) ) {
return new WP_Error( 'jwt_corrupted_payload', 'Поврежденная полезная нагрузка токена.', [ 'status' => 401 ] );
}
// Проверка срока действия
if ( isset( $payload['exp'] ) && time() >= (int) $payload['exp'] ) {
return new WP_Error( 'jwt_expired_token', 'Срок действия токена истек.', [ 'status' => 401 ] );
}
// Проверка типа токена (защита от подстановки refresh вместо access)
if ( ( $payload['typ'] ?? '' ) !== $expected_type ) {
return new WP_Error( 'jwt_wrong_type', 'Недопустимый тип токена для данной операции.', [ 'status' => 401 ] );
}
return $payload;
}
private function base64url_encode( string $data ): string {
return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
}
private function base64url_decode( string $data ): string {
return base64_decode( strtr( $data, '-_', '+/' ) . str_repeat( '=', 3 - ( 3 + strlen( $data ) ) % 4 ) );
}
}
Мидлвар гарантирует строгое соблюдение спецификации W3C CORS, исключая опасные подстановки Access-Control-Allow-Origin: * при передаче авторизационных заголовков.
<?php
declare(strict_types=1);
namespace AgenticLab\Security;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Промышленный CORS Middleware для WordPress REST API.
* Управляет Preflight запросами OPTIONS, белым списком доменов и кэшированием заголовков.
*/
class REST_CORS_Middleware {
/**
* Белый список доверенных фронтенд-источников (Origin).
*/
private const ALLOWED_ORIGINS = [
'https://agenticlab.dev',
'https://admin.agenticlab.dev',
'http://localhost:3000', // Локальная разработка Next.js
];
public static function init(): void {
// Подключение на ранней фазе инициализации REST API
add_action( 'rest_api_init', [ __CLASS__, 'handle_preflight_early' ], 0 );
add_filter( 'rest_pre_serve_request', [ __CLASS__, 'send_cors_headers' ], 10, 4 );
}
/**
* Ранний перехват Preflight запроса OPTIONS до запуска контроллеров ядра.
*/
public static function handle_preflight_early(): void {
if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
$origin = self::get_validated_origin();
if ( $origin ) {
self::apply_headers( $origin );
status_header( 200 );
exit; // Завершаем preflight-запрос немедленно
}
}
}
/**
* Внедрение CORS-заголовков в стандартный ответ REST API.
*/
public static function send_cors_headers( $served, $result, $request, $server ): bool {
$origin = self::get_validated_origin();
if ( $origin ) {
self::apply_headers( $origin );
}
return $served;
}
/**
* Проверка источника запроса по строгому белому списку.
*/
private static function get_validated_origin(): ?string {
$origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? trim( (string) $_SERVER['HTTP_ORIGIN'] ) : '';
if ( empty( $origin ) ) {
return null;
}
if ( in_array( $origin, self::ALLOWED_ORIGINS, true ) ) {
return $origin;
}
return null;
}
/**
* Установка корректных HTTP-заголовков CORS.
*/
private static function apply_headers( string $origin ): void {
header( "Access-Control-Allow-Origin: {$origin}" );
header( 'Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS' );
header( 'Access-Control-Allow-Credentials: true' );
header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce, X-Requested-With' );
header( 'Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link' );
header( 'Access-Control-Max-Age: 86400' ); // Кэширование preflight на 24 часа
}
}
Фронтенд-клиент для Next.js 15 / React 19, реализующий очередь запросов и мьютекс для предотвращения состояния гонки (Race Condition) при одновременном обновлении токена.
/**
* Промышленный HTTP-клиент для Headless WordPress REST API.
* Реализует автоматический перехват 401 ошибок, ротацию Refresh Token
* и мьютекс для защиты от состояния гонки при параллельных запросах.
*/
interface TokenPair {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
interface ProjectItem {
id: number;
title: string;
content: string;
slug: string;
budget: number;
client_url: string;
created_at: string;
_links?: Record<string, Array<{ href: string }>>;
}
class WordPressApiClient {
private baseUrl: string;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
public setTokens(access: string, refresh: string): void {
this.accessToken = access;
this.refreshToken = refresh;
}
/**
* Выполнение авторизованного HTTP-запроса с авто-повтором при 401.
*/
public async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}`;
const headers = new Headers(options.headers || {});
headers.set('Content-Type', 'application/json');
if (this.accessToken) {
headers.set('Authorization', `Bearer ${this.accessToken}`);
}
let response = await fetch(url, { ...options, headers });
// Если токен истек (401), запускаем механизм ротации через мьютекс
if (response.status === 401 && this.refreshToken) {
if (this.isRefreshing) {
// Ожидаем в очереди завершения обновления текущим параллельным потоком
const newAccessToken = await new Promise<string>((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
});
headers.set('Authorization', `Bearer ${newAccessToken}`);
response = await fetch(url, { ...options, headers });
} else {
this.isRefreshing = true;
try {
const freshTokens = await this.performTokenRefresh();
this.setTokens(freshTokens.access_token, freshTokens.refresh_token);
this.processQueue(null, freshTokens.access_token);
// Повторяем исходный запрос с новым токеном
headers.set('Authorization', `Bearer ${freshTokens.access_token}`);
response = await fetch(url, { ...options, headers });
} catch (refreshError) {
this.processQueue(refreshError as Error, null);
this.logout();
throw refreshError;
} finally {
this.isRefreshing = false;
}
}
}
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(errorBody.message || `HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
}
/**
* Вызов эндпоинта обновления токена.
*/
private async performTokenRefresh(): Promise<TokenPair> {
const response = await fetch(`${this.baseUrl}/agenticlab/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: this.refreshToken }),
});
if (!response.ok) {
throw new Error('Refresh token expired or invalid');
}
return response.json();
}
/**
* Обработка накопившейся очереди запросов.
*/
private processQueue(error: Error | null, token: string | null): void {
this.failedQueue.forEach((promise) => {
if (error) {
promise.reject(error);
} else if (token) {
promise.resolve(token);
}
});
this.failedQueue = [];
}
private logout(): void {
this.accessToken = null;
this.refreshToken = null;
}
// Прикладные методы работы с сущностями
public async getProjects(page = 1, perPage = 10): Promise<ProjectItem[]> {
return this.request<ProjectItem[]>(`agenticlab/v1/projects?page=${page}&per_page=${perPage}`);
}
public async createProject(data: { title: string; content?: string; budget?: number; client_url?: string }): Promise<ProjectItem> {
return this.request<ProjectItem>('agenticlab/v1/projects', {
method: 'POST',
body: JSON.stringify(data),
});
}
}
export const apiClient = new WordPressApiClient(process.env.NEXT_PUBLIC_WP_API_URL || 'https://agenticlab.dev/wp-json');
При разработке и сопровождении REST API в WordPress часто допускаются следующие критические ошибки:
Authorization: Bearer <token>, однако WordPress упорно считает запрос анонимным. При дампе $_SERVER заголовок HTTP_AUTHORIZATION полностью отсутствует.Authorization, не передавая его в среду PHP.CGIPassAuth On в файл .htaccess или проксирование через директиву модуля rewrite:<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule ^(.*) - [E=HTTP_AUTHORIZATION:%1]
</IfModule>
CGIPassAuth OnX-WP-Nonce в запросах из мобильного приложения или внешнего SPA.wp_create_nonce('wp_rest') неразрывно привязан к сессионной куке LOGGED_IN_COOKIE текущего домена. Для внешнего клиента, у которого нет cookie-сессии WordPress, nonce всегда генерируется для анонимного пользователя (user_id = 0) и становится бесполезным.Authorization: Bearer <token>. Nonce используется только при вызове REST API из скриптов, работающих внутри того же браузерного домена WordPress.getProfile, getNotifications, getCart), пользователь внезапно разлогинивается.usermeta и инвалидирует старый. Второй и третий запросы приходят со старым Refresh Token и падают с фатальной ошибкой авторизации.isRefreshing (мьютекса) на клиенте, как реализовано в нашем WordPressApiClient.WC_Payment_Gateway, обработка асинхронных вебхуков с подписью HMAC и идемпотентностью.