Repository navigation
[Feature] Web API: контракт customer auth для Nuxt SSR (me / TTL / cookie+Bearer) #571
Description
Описание функции
Зафиксировать и довести контракт 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) должен:
- Понять на SSR, гость это или залогиненный покупатель.
- Сохранить корзину при login (guest → customer).
- Не класть API-токен в
localStorage. - Корректно пережить TTL и logout.
- Настроить 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)
Authorization: Bearer …- Header
MS3TOKEN(legacy) $_REQUEST['ms3_token']/token(в т.ч. после inject cookie)- 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)
- Всегда новый API token (anti fixation, [Bug] Login/Register: rebind guest API-токена без ротации (token fixation) #412).
- Guest/own previous token →
transferDraftToToken, старый row remove. session_regenerate_id(true).- Cookie обновляется через
persistApiToken/ CookieHelper.
Источник 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
- Требует валидный API token.
- Ротация: новый api token, старый revoke (или короткий overlap).
- Опционально later: пара access +
type=refreshс более длинным TTL (схема уже допускаетrefresh). - Не возвращать false
successstub ([Bug] Web API: /token/refresh возвращает success, хотя не реализован #334).
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):
- Nuxt BFF держит session cookie своего origin (httpOnly).
- Server-side proxy к MODX с
Authorization: Bearer <ms3_token>. - Браузер не видит raw MODX token.
Same-site / cookie path:
GET /customer/token/getили auto-mint на cart.- Браузер шлёт httpOnly
ms3_token(credentials: 'include'). ms3_cors_allowed_origins= точный Nuxt origin.- Для cross-site cookie:
Secure+SameSite=None(отдельные settings или documented overridesession_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.phpCustomerAuthController/ новый thinCustomerSessionControllerAuthManager/TokenService(refresh или sliding)- Login/Register processors — bind Bearer guest
CookieHelper— optional dedicated settingsTokenMiddleware— publicRoutes для me? (me не public)- docs.modx.pro Web API auth
- тесты
Альтернативные варианты
- Только документация без
GET /me. Недостаточно для SSR hydrate. - JWT access/refresh. Ломает текущую revoke-модель и тесты. Не нужно для закрытия gap.
- Вернуть ложный
token/refreshstub. Запрещено ([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 продолжает работать.
Тесты (должны появиться)
GET /customer/me: guest →authenticated:false; после login → DTO + expires.- Login с
Authorization: Bearer <guest>переносит draft (как cookie path). - Refresh (или sliding): новый expires / новый token, старый api token не принимает (если rotate).
- Logout: me становится guest; старый Bearer → 401.
- Response me не содержит password / internal fields (allowlist DTO).
- Регрессии [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 существующих клиентов сохранена.
Дополнительный контекст
- Закрытые: [Bug] Web API: /token/refresh возвращает success, хотя не реализован #334 refresh stub, [Bug] TokenMiddleware: expired API-token бесконечно продлевается без ротации #371 expiry policy, [Bug] Login/Register: rebind guest API-токена без ротации (token fixation) #412 token fixation, [Bug] Web API: email/verify логинит в session без bind API-токена (split-brain) #411 email verify bind, [Bug] CORS: дефолт allowed_origins=* вместе с allow_credentials=true #335 CORS wildcard, [Bug] REST API: cart/get недоступен анонимно, рассинхрон publicRoutes и хардкод строк вместо лексикона #408 public routes, [Feature] Web API: публичный Customer DTO + allowlist полей для /customer/add #424 CustomerPublicDto.
- Related headless: [Feature] Web API: публичный список методов доставки (delivery/list) #568/[Feature] Web API: публичный список методов оплаты (payment/list) #569 delivery/payment discovery, [Feature] Web API: нормализовать контракт ответа корзины (cart/get) для headless #570 cart response.
EOF
Activity
test(web-api): align query-token smoke with TokenService resolver
test(web-api): align query-token smoke with TokenService resolver
feat(web-api): контракт customer auth для Nuxt SSR (me / refresh / Be…
chore: sync beta with upstream modx-pro/MiniShop3 (#1)