Контекст: В frontend/src/lib/api.ts реализован простой wrapper над fetch. В useAuth.tsx используется для авторизации.
Текущий API клиент имеет пробелы:
credentials: 'include' + Authorization header — потенциальный CSRF (хотя SameSite=Lax помогает)// frontend/src/lib/api.ts:15-26
const res = await fetch(`${API_URL}${path}`, {
...options,
headers,
credentials: 'include', // Куки + Bearer токен одновременно
})
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }))
throw new ApiRequestError(res.status, error.detail || 'Unknown error', error)
}
// frontend/src/hooks/useAuth.tsx:30-40
const refreshSession = useCallback(async () => {
try {
const res = await api.post<{ access_token: string }>('/auth/refresh')
setTokens(res.access_token)
const userRes = await api.get<User>('/auth/me')
setUser(userRes)
} catch {
clearTokens()
setUser(null)
}
}, [setTokens, clearTokens])
// Проблема: refresh вызывается при каждом монтировании, нет защиты от race condition
// frontend/src/lib/api.ts
let refreshPromise: Promise<string> | null = null
async function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Refresh token в HttpOnly куке
})
if (!res.ok) throw new Error('Refresh failed')
const data = await res.json()
return data.access_token
})()
try {
return await refreshPromise
} finally {
refreshPromise = null
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getAccessToken()
const headers = { 'Content-Type': 'application/json', ...options.headers }
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${API_URL}${path}`, { ...options, headers, credentials: 'include' })
if (res.status === 401 && !path.includes('/auth/')) {
// Автоматический refresh и retry
const newToken = await refreshAccessToken()
headers['Authorization'] = `Bearer ${newToken}`
const retryRes = await fetch(`${API_URL}${path}`, { ...options, headers, credentials: 'include' })
if (!retryRes.ok) throw await parseError(retryRes)
return retryRes.json()
}
if (!res.ok) throw await parseError(res)
return res.json()
}
Backend изменения (в auth.go):
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0Frontend: убрать localStorage для refresh, использовать credentials: 'include'
// frontend/src/types/api.ts
export interface ApiErrorResponse {
type: string
title: string
status: number
detail: string
errors?: { field: string; message: string }[]
}
export class ApiError extends Error {
constructor(public response: ApiErrorResponse) {
super(response.detail)
this.name = 'ApiError'
}
}
| Подход | Плюсы | Минусы |
|---|---|---|
| Улучшенный fetch wrapper (рекомендую) | Без зависимостей, полный контроль | Нужно писать boilerplate |
| TanStack Query (React Query) | Кэширование, deduping, retry, devtools | Зависимость, кривая обучения |
| SWR | Легкий, кэширование | Меньше фич чем TanStack Query |
| Axios | Interceptors, привычный API | Устаревший, тяжелый, не нужен в 2024 |
Задача: Code review PhotoPlaces — улучшение API клиента и auth flow
#frontend #api-client #auth #react #typescript #best-practice