Files
portal/app/resources/js/api/client.ts
T
Дмитрий 75897b1636 phase2(rate-limit): login + 2FA verify (5/15min) + frontend lockout
- AuthController: RateLimiter::hit/clear на login + verifyTwoFactor по ключу email|ip / pending_user_id|ip
- 429 + Retry-After header + JSON retry_after (lockoutResponse helper)
- ТЗ §22.4.4: 5 попыток / 15 мин; success чистит throttle; inactive user тоже расходует попытки
- extractRateLimitRetry в api/client.ts; auth-store.lockoutSeconds; v-alert в LoginView/TwoFactorView
- Pest +6 в RateLimitTest.php (73/73 за 8.07с, 246 assertions)
- Vitest +4 в auth-store + LoginView (149/149 за 12.31с)
- Quirk: wrong-password в тестах ≥8 символов (LoginRequest::min:8) — иначе валидация падает до controller
- Quirk: vi.mock api/client в auth-store.spec — иначе axios.isAxiosError в jsdom возвращает false для plain Error
- TODO (отдельные коммиты): IP-lockout 10/час через auth_log + email при 3 неудачах
- Регресс: lint+type+format OK; build 886ms; story:build 21/28 за 37.19с; Pint+Stan passed
- CLAUDE.md v1.35→v1.36, реестр v1.44→v1.45

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:49:47 +03:00

74 lines
3.2 KiB
TypeScript

import axios, { type AxiosInstance } from 'axios';
/**
* Axios-инстанс для API-запросов.
*
* Sanctum SPA mode (см. `bootstrap/app.php` + `routes/web.php` § /api/auth/*):
* 1. `withCredentials: true` — отправляем session-cookie + XSRF-TOKEN cookie.
* 2. `withXSRFToken: true` — axios читает XSRF-TOKEN cookie и кладёт
* в `X-XSRF-TOKEN` header автоматически (Laravel валидирует CSRF).
* 3. На первый запрос — `await ensureCsrfCookie()` забирает CSRF-cookie через
* `GET /sanctum/csrf-cookie` (выставляется один раз за сессию).
*
* baseURL не указываем — используем относительные пути (`/api/auth/login`),
* браузер шлёт same-origin → cookie работают без CORS-настроек.
*/
export const apiClient: AxiosInstance = axios.create({
withCredentials: true,
withXSRFToken: true,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
let csrfCookieFetched = false;
export async function ensureCsrfCookie(): Promise<void> {
if (csrfCookieFetched) return;
await apiClient.get('/sanctum/csrf-cookie');
csrfCookieFetched = true;
}
/**
* Хелпер для обработки validation-error 422 от Laravel.
* Возвращает `{ field: [messages] }` или null.
*/
export function extractValidationErrors(error: unknown): Record<string, string[]> | null {
if (!axios.isAxiosError(error)) return null;
if (error.response?.status !== 422) return null;
const data = error.response.data as { errors?: Record<string, string[]> };
return data.errors ?? null;
}
/**
* Хелпер для general error-message (любая ошибка → human-readable строка).
*/
export function extractErrorMessage(error: unknown, fallback = 'Произошла ошибка. Попробуйте позже.'): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as { message?: string } | undefined;
if (data?.message) return data.message;
if (error.response?.status === 401) return 'Требуется вход.';
if (error.response?.status === 403) return 'Нет прав на это действие.';
if (error.response?.status === 500) return 'Внутренняя ошибка сервера.';
}
return fallback;
}
/**
* Хелпер для 429 Too Many Requests (ТЗ §22.4.4: 5 попыток / 15 мин).
* Возвращает retry_after в секундах или null для других ошибок.
*
* Backend кладёт `retry_after: number` в JSON и `Retry-After` в headers
* (см. AuthController::lockoutResponse).
*/
export function extractRateLimitRetry(error: unknown): number | null {
if (!axios.isAxiosError(error)) return null;
if (error.response?.status !== 429) return null;
const data = error.response.data as { retry_after?: number } | undefined;
if (typeof data?.retry_after === 'number') return data.retry_after;
const header = error.response.headers['retry-after'];
if (header) return parseInt(String(header), 10) || null;
return null;
}