diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 009b5f7c..bb763a9e 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -319,7 +319,7 @@ REST_FRAMEWORK = { SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(os.getenv("JWT_ACCESS_MIN", "30"))), - "REFRESH_TOKEN_LIFETIME": timedelta(days=int(os.getenv("JWT_REFRESH_DAYS", "7"))), + "REFRESH_TOKEN_LIFETIME": timedelta(days=int(os.getenv("JWT_REFRESH_DAYS", "90"))), "ALGORITHM": "HS256", "AUTH_HEADER_TYPES": ("Bearer",), "LEEWAY": 30, diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx index 8dc0268b..b6d8cc49 100644 --- a/frontend/src/contexts/UserContext.tsx +++ b/frontend/src/contexts/UserContext.tsx @@ -33,6 +33,7 @@ export function UserProvider({ children }: { children: ReactNode }) { } // Загружаем данные пользователя + // ApiClient автоматически обновит токен если он истёк const userData = await apiClient.getCurrentUser(); setUser(userData); } catch (err: any) { @@ -40,9 +41,14 @@ export function UserProvider({ children }: { children: ReactNode }) { setError(err.message); setUser(null); - // Если ошибка 401 - токен невалиден, очищаем - if (err.message?.includes('401')) { - apiClient.clearToken(); + // Токены уже очищены в ApiClient если refresh token тоже истёк + // Проверяем, остались ли токены - если нет, значит сессия истекла + const hasToken = typeof window !== 'undefined' && localStorage.getItem('access_token'); + if (!hasToken) { + // Токены очищены - сессия истекла, redirect на login + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.href = '/login'; + } } } finally { setLoading(false); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 60256b43..b6855a43 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,12 +17,19 @@ interface LoginResponse { class ApiClient { private tokenKey = 'access_token'; private refreshTokenKey = 'refresh_token'; + private isRefreshing = false; + private refreshSubscribers: Array<(token: string) => void> = []; private getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(this.tokenKey); } + private getRefreshToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(this.refreshTokenKey); + } + private setToken(token: string): void { if (typeof window !== 'undefined') { localStorage.setItem(this.tokenKey, token); @@ -42,6 +49,49 @@ class ApiClient { } } + private onRefreshed(token: string): void { + this.refreshSubscribers.forEach((callback) => callback(token)); + this.refreshSubscribers = []; + } + + private addRefreshSubscriber(callback: (token: string) => void): void { + this.refreshSubscribers.push(callback); + } + + /** + * Обновление access token с помощью refresh token + */ + private async refreshAccessToken(): Promise { + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await fetch('/api/auth/token/refresh/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh: refreshToken }), + }); + + if (!response.ok) { + // Refresh token истёк или невалиден - нужно перелогиниться + this.clearToken(); + throw new Error('Refresh token expired'); + } + + const data: LoginResponse = await response.json(); + this.setToken(data.access); + + // Если бэкенд вернул новый refresh token, сохраняем его + if (data.refresh) { + this.setRefreshToken(data.refresh); + } + + return data.access; + } + private async request( endpoint: string, options: RequestInit = {} @@ -62,6 +112,74 @@ class ApiClient { headers, }); + // Если получили 401 и есть refresh token - пробуем обновить токен + if (response.status === 401 && this.getRefreshToken()) { + if (!this.isRefreshing) { + this.isRefreshing = true; + + try { + const newToken = await this.refreshAccessToken(); + this.isRefreshing = false; + this.onRefreshed(newToken); + + // Повторяем оригинальный запрос с новым токеном + headers['Authorization'] = `Bearer ${newToken}`; + const retryResponse = await fetch(endpoint, { + ...options, + headers, + }); + + if (!retryResponse.ok) { + let errorDetails = ''; + try { + const errorData = await retryResponse.json(); + errorDetails = errorData.detail || JSON.stringify(errorData); + } catch { + errorDetails = retryResponse.statusText; + } + throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`); + } + + // Обрабатываем успешный ответ + if (options.method === 'DELETE' && retryResponse.status === 204) { + return undefined as T; + } + + const contentLength = retryResponse.headers.get('content-length'); + if (contentLength === '0' || retryResponse.status === 204) { + return undefined as T; + } + + return retryResponse.json(); + } catch (refreshError) { + this.isRefreshing = false; + this.refreshSubscribers = []; + throw refreshError; + } + } else { + // Если уже идёт обновление токена, ждём его завершения + return new Promise((resolve, reject) => { + this.addRefreshSubscriber((newToken: string) => { + headers['Authorization'] = `Bearer ${newToken}`; + fetch(endpoint, { ...options, headers }) + .then((res) => { + if (!res.ok) { + reject(new Error(`API Error: ${res.status}`)); + return; + } + if (options.method === 'DELETE' && res.status === 204) { + resolve(undefined as T); + return; + } + return res.json(); + }) + .then((data) => resolve(data)) + .catch(reject); + }); + }); + } + } + if (!response.ok) { // Попытка получить детали ошибки let errorDetails = '';