Токены, правильная работа
This commit is contained in:
@@ -3,11 +3,74 @@ const API_BASE = '/api';
|
||||
|
||||
export class HttpClient {
|
||||
private token: string | null = null;
|
||||
private isRefreshing = false;
|
||||
private refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private subscribeTokenRefresh(cb: (token: string) => void) {
|
||||
this.refreshSubscribers.push(cb);
|
||||
}
|
||||
|
||||
private onRefreshed(token: string) {
|
||||
this.refreshSubscribers.forEach(cb => cb(token));
|
||||
this.refreshSubscribers = [];
|
||||
}
|
||||
|
||||
private async handle401(): Promise<string | null> {
|
||||
if (this.isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
this.subscribeTokenRefresh(token => {
|
||||
resolve(token);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const refreshToken = localStorage.getItem('knot_refresh_token');
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
localStorage.removeItem('knot_token');
|
||||
localStorage.removeItem('knot_refresh_token');
|
||||
this.token = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newToken = data.accessToken;
|
||||
const newRefreshToken = data.refreshToken;
|
||||
|
||||
localStorage.setItem('knot_token', newToken);
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem('knot_refresh_token', newRefreshToken);
|
||||
}
|
||||
|
||||
this.token = newToken;
|
||||
this.onRefreshed(newToken);
|
||||
return newToken;
|
||||
} catch (err) {
|
||||
localStorage.removeItem('knot_token');
|
||||
localStorage.removeItem('knot_refresh_token');
|
||||
this.token = null;
|
||||
return null;
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
||||
const { timeout = 30_000, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
@@ -41,6 +104,36 @@ export class HttpClient {
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (response.status === 401 && endpoint !== '/auth/refresh') {
|
||||
const newToken = await this.handle401();
|
||||
if (newToken) {
|
||||
// Retry the original request with new token
|
||||
const retryHeaders: Record<string, string> = {
|
||||
...computedHeaders,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
};
|
||||
|
||||
const retryController = new AbortController();
|
||||
const retryTimer = timeout > 0 ? setTimeout(() => retryController.abort(), timeout) : undefined;
|
||||
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers: retryHeaders,
|
||||
signal: retryController.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(retryTimer);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Время ожидания запроса истекло');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { User } from '../../../core/domain/types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -23,6 +24,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (t) AuthApi.setToken(t);
|
||||
return t;
|
||||
})(),
|
||||
refreshToken: (() => {
|
||||
return localStorage.getItem('knot_refresh_token');
|
||||
})(),
|
||||
user: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
@@ -33,17 +37,20 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
const res = await AuthApi.getConfig();
|
||||
set({ config: res });
|
||||
} catch {}
|
||||
} catch { }
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
try {
|
||||
set({ error: null, isLoading: true });
|
||||
const { token, user } = await AuthApi.login(username, password);
|
||||
const { token, refreshToken, user } = await AuthApi.login(username, password);
|
||||
localStorage.setItem('knot_token', token);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('knot_refresh_token', refreshToken);
|
||||
}
|
||||
AuthApi.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
set({ token, refreshToken, user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -55,11 +62,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
register: async (username, displayName, password, bio) => {
|
||||
try {
|
||||
set({ error: null, isLoading: true });
|
||||
const { token, user } = await AuthApi.register(username, displayName, password, bio);
|
||||
const { token, refreshToken, user } = await AuthApi.register(username, displayName, password, bio);
|
||||
localStorage.setItem('knot_token', token);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('knot_refresh_token', refreshToken);
|
||||
}
|
||||
AuthApi.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
set({ token, refreshToken, user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -70,9 +80,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('knot_token');
|
||||
localStorage.removeItem('knot_refresh_token');
|
||||
AuthApi.setToken(null);
|
||||
disconnectSocket();
|
||||
set({ token: null, user: null });
|
||||
set({ token: null, refreshToken: null, user: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
@@ -121,11 +132,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
errorMsg.includes('Недействительный токен') ||
|
||||
errorMsg.includes('Требуется авторизация')
|
||||
) {
|
||||
localStorage.removeItem('knot_token');
|
||||
set({ token: null, user: null, isLoading: false });
|
||||
localStorage.removeItem('knot_token');
|
||||
localStorage.removeItem('knot_refresh_token');
|
||||
set({ token: null, refreshToken: null, user: null, isLoading: false });
|
||||
} else {
|
||||
// Keep the token but stop loading if we're just offline/network error/500
|
||||
set({ isLoading: false });
|
||||
// Keep the token but stop loading if we're just offline/network error/500
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { User } from '../../../core/domain/types';
|
||||
|
||||
export class AuthApi {
|
||||
static async login(username: string, password: string) {
|
||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
||||
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
return {
|
||||
token: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
@@ -20,13 +21,32 @@ export class AuthApi {
|
||||
}
|
||||
|
||||
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
||||
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, displayName, password, bio }),
|
||||
});
|
||||
|
||||
return {
|
||||
token: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
displayName: response.displayName,
|
||||
avatar: null
|
||||
} as User
|
||||
};
|
||||
}
|
||||
|
||||
static async refresh(refreshToken: string) {
|
||||
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
return {
|
||||
token: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
|
||||
Reference in New Issue
Block a user