feat: add auto token refresh and increase refresh token lifetime to 90 days

This commit is contained in:
igor_nadein 2026-02-27 16:35:36 +03:00
parent 23844dd879
commit 8596b82726
3 changed files with 128 additions and 4 deletions

View File

@ -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,

View File

@ -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);

View File

@ -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<string> {
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<T>(
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 = '';