Skip to content

[Feature] Web API: контракт customer auth для Nuxt SSR (me / TTL / cookie+Bearer) #571

Closed

Description

@Ibochkarev

Описание функции

Зафиксировать и довести контракт customer auth Web API для Nuxt SSR / headless: как жить с opaque ms3_token (Bearer / cookie), без перехода на JWT.

Сейчас login/register/logout, ротация токена, привязка guest-корзины и CORS уже есть. Не хватает явного session endpoint, стратегии продления сессии и документированного SSR/cross-origin flow.

Проблема, которую решает

Nuxt на отдельном origin (или BFF) должен:

  1. Понять на SSR, гость это или залогиненный покупатель.
  2. Сохранить корзину при login (guest → customer).
  3. Не класть API-токен в localStorage.
  4. Корректно пережить TTL и logout.
  5. Настроить CORS + cookie без угадывания SameSite/credentials.

Без контракта каждый storefront изобретает свой storage и ломает security assumptions ядра.

Текущее состояние (проверено в коде)

Модель (не JWT)

Opaque token в msCustomerToken (type=api), строка bin2hex(random_bytes(32)), TTL из ms3_customer_token_ttl (default 604800 = 7 дней).

Проверка: TokenService::resolveApiToken() — lookup в БД. Expired → remove, без продления той же строки (#371). JWT не используется и не предлагается как обязательная замена.

В схеме есть TYPE_REFRESH, но публичного refresh API нет (stub удалён в #334, тест TokenRefreshRouteRemovedTest).

Как клиент передаёт токен (TokenMiddleware::resolveToken)

  1. Authorization: Bearer …
  2. Header MS3TOKEN (legacy)
  3. $_REQUEST['ms3_token'] / token (в т.ч. после inject cookie)
  4. PHP session cache (всё равно валидируется по БД)

Cookie: CookieHelper → httpOnly ms3_token, domain/path/secure/samesite из MODX session_cookie_*, lifetime = ms3_customer_token_ttl.

Routes (config/routes/web.php)

Endpoint TokenMiddleware Назначение
POST /api/v1/customer/login нет auth + rotate
POST /api/v1/customer/register нет register + session
POST /api/v1/customer/logout да revoke + guest mint
GET /api/v1/customer/token/get public guest/current token
PUT /api/v1/customer/profile да update
addresses / orders / … да cabinet

Нет GET /api/v1/customer/me (или /session / /profile GET) для introspection.

Login success (Processors\Api\Customer\Login):

{
  "success": true,
  "data": {
    "customer": { "id": 1, "email": "…", "…CustomerPublicDto…": "" },
    "token": "…",
    "expires_at": "Y-m-d H:i:s",
    "redirect_url": ""
  }
}

Login / cart bind (AuthManager::establishCustomerSession)

Источник previous token: TokenService::getBindableTokenString() (session, затем cookie). Не читает Authorization сам по себе на login-роуте без middleware. Для Nuxt: перед login нужен guest token в cookie или клиент должен обеспечить bindable token (иначе корзина не переедет). Сейчас login без TokenMiddleware не кладёт Bearer в $_REQUEST автоматически, если только header без cookie/session.

Logout

AuthManager::logoutCurrentCustomer: revoke всех API tokens customer, clear cookie/session, mint guest token, regenerate session id. Тест: TokenSessionRevokePolicyTest.

CORS

CorsMiddleware: ms3_cors_allowed_origins, methods, headers Content-Type, Authorization, X-Requested-With, MS3TOKEN, allow_credentials: true. Wildcard + credentials нормализуется (#335).

Профиль

CustomerPublicDto allowlist (#424). Update через PUT/add. Read «кто я» отдельным GET не экспонирован.

Тесты (уже есть)

CustomerAuthSessionSemanticsTest, AuthManagerLifecycleTest, ResolveApiTokenTest, ApiTokenExpiryPolicyTest, TokenRefreshRouteRemovedTest, TokenMiddlewarePublicRoutesTest, email verify session tests (#411).

Отдельного open issue на Nuxt/SSR auth contract не найдено.

Почему этого мало для Nuxt SSR

Тема Факт Gap
Session hydrate Login отдаёт customer один раз Нет GET …/me → SSR не узнает user по cookie/Bearer без костылей
Refresh Stub удалён (#334) После TTL только re-login; TYPE_REFRESH мёртв
Cross-origin cookie httpOnly есть Атрибуты = session_cookie_* (часто SameSite=Lax). Nuxt на другом origin cookie не шлёт без SameSite=None + Secure + allowlist origin
Token in JSON Login возвращает token Риск положить в localStorage (XSS). Нужен канонический advice
Guest cart on login Bind через cookie/session Login без TokenMiddleware: Bearer-only Nuxt может потерять merge корзины
CSRF credentials CORS Cookie-auth cross-site без CSRF-токена опаснее, чем Bearer через BFF
PHP session Middleware зовёт SessionHelper Node SSR не шарит PHP session → канон = Bearer (или BFF cookie → Bearer)

Предлагаемое решение

Инкремент к /api/v1 + docs. JWT не вводить. Opaque DB token оставить.

1. Session introspection (обязательный минимум)

Метод Endpoint Auth
GET /api/v1/customer/me TokenMiddleware

Ответ:

{
  "success": true,
  "data": {
    "authenticated": true,
    "customer": { "id": 1, "email": "a@b.c", "first_name": "…", "…": "" },
    "token": {
      "expires_at": "2026-08-23 12:00:00",
      "customer_id": 1
    }
  }
}

Гость (customer_id=0):

{
  "success": true,
  "data": {
    "authenticated": false,
    "customer": null,
    "token": { "expires_at": "…", "customer_id": 0 }
  }
}

Customer через CustomerPublicDto (без password/hash/internal token columns).

2. Продление сессии (выбрать один вариант в PR, задокументировать)

A (предпочтительно для headless): POST /api/v1/customer/token/refresh

B (проще): opt-in sliding TTL в TokenMiddleware при reason=ok — обновлять expires_at не чаще N минут, с жёстким max lifetime. Не возвращать поведение #371 (infinite без политики).

Минимум для issue: реализовать A или B + тесты. Не оба сразу без нужды.

3. Login/register: принять Bearer guest token

На login/register (или тонкий middleware только для bind):

  • Читать guest token так же, как TokenMiddleware (Authorization / MS3TOKEN / cookie).
  • Кормить getBindableTokenString / transfer path.

Иначе Nuxt с Bearer-only storage теряет корзину при login.

4. Документированный Nuxt flow (docs, не обязательный код)

Рекомендуемый production path (SSR-safe):

  1. Nuxt BFF держит session cookie своего origin (httpOnly).
  2. Server-side proxy к MODX с Authorization: Bearer <ms3_token>.
  3. Браузер не видит raw MODX token.

Same-site / cookie path:

  1. GET /customer/token/get или auto-mint на cart.
  2. Браузер шлёт httpOnly ms3_token (credentials: 'include').
  3. ms3_cors_allowed_origins = точный Nuxt origin.
  4. Для cross-site cookie: Secure + SameSite=None (отдельные settings или documented override session_cookie_*). Иначе использовать BFF.

Запрет в docs: хранить API token в localStorage / sessionStorage.

5. CSRF

  • Cookie + cross-origin: либо BFF (предпочтительно), либо SameSite=Lax same-site, либо follow-up CSRF token.
  • Не требовать CSRF для чистого Bearer без cookie.

6. Cookie settings (опционально в том же или follow-up PR)

Если MODX session_cookie_* неудобны для API-only host: settings ms3_token_cookie_samesite|secure|domain|path с fallback на session_*.

Затронутые места (ожидаемо)

  • config/routes/web.php
  • CustomerAuthController / новый thin CustomerSessionController
  • AuthManager / TokenService (refresh или sliding)
  • Login/Register processors — bind Bearer guest
  • CookieHelper — optional dedicated settings
  • TokenMiddleware — publicRoutes для me? (me не public)
  • docs.modx.pro Web API auth
  • тесты

Альтернативные варианты

  1. Только документация без GET /me. Недостаточно для SSR hydrate.
  2. JWT access/refresh. Ломает текущую revoke-модель и тесты. Не нужно для закрытия gap.
  3. Вернуть ложный token/refresh stub. Запрещено ([Bug] Web API: /token/refresh возвращает success, хотя не реализован #334).

Примеры использования

GET /api/v1/customer/token/get
POST /api/v1/customer/login
Authorization: Bearer <guest_token>
Content-Type: application/json

{"email":"a@b.c","password":"…"}

GET /api/v1/customer/me
Authorization: Bearer <api_token>

POST /api/v1/customer/token/refresh
Authorization: Bearer <api_token>

POST /api/v1/customer/logout
Authorization: Bearer <api_token>

Nuxt server/api proxy: читает свой cookie → подставляет Bearer на MODX.

Обратная совместимость

  • Сущерующие login/logout/token/get/cart с Bearer/cookie/MS3TOKEN сохранить.
  • GET /me и refresh — additive.
  • Не менять default TTL без setting.
  • Не включать sliding по умолчанию без opt-in, если выбирают вариант B.
  • Классическая same-origin витрина с httpOnly cookie продолжает работать.

Тесты (должны появиться)

  1. GET /customer/me: guest → authenticated:false; после login → DTO + expires.
  2. Login с Authorization: Bearer <guest> переносит draft (как cookie path).
  3. Refresh (или sliding): новый expires / новый token, старый api token не принимает (если rotate).
  4. Logout: me становится guest; старый Bearer → 401.
  5. Response me не содержит password / internal fields (allowlist DTO).
  6. Регрессии [Bug] Login/Register: rebind guest API-токена без ротации (token fixation) #412 / [Bug] TokenMiddleware: expired API-token бесконечно продлевается без ротации #371 / [Bug] Web API: /token/refresh возвращает success, хотя не реализован #334 остаются зелёными.

Критерии приёмки

  • Есть GET /api/v1/customer/me с authenticated + CustomerPublicDto | null.
  • Задокументированы два канонических Nuxt flow: BFF+Bearer и same-site cookie; запрет localStorage.
  • Есть рабочая стратегия TTL (refresh endpoint или контролируемый sliding) без ложного success stub.
  • Login/register умеют bind guest cart из Bearer, не только из cookie/session.
  • CORS + cookie (SameSite/Secure/credentials) описаны для cross-origin Nuxt.
  • CSRF: явное решение (BFF / SameSite / follow-up), без «молчаливой» дыры в docs.
  • Logout по-прежнему revoke + guest mint.
  • JWT не внедрён как обязательная замена.
  • Тесты выше зелёные. BC существующих клиентов сохранена.

Дополнительный контекст

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: mediumСредний приоритет

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions