improve security: back port, base port, login guard, scripts
This commit is contained in:
+20
-5
@@ -13,8 +13,22 @@ server {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:3000;
|
||||
|
||||
proxy_pass http://backend:3100;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 650s;
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
|
||||
location /bus/ {
|
||||
proxy_pass http://backend:3100;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -25,11 +39,12 @@ server {
|
||||
proxy_read_timeout 650s;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
listen 3100;
|
||||
server_name localhost;
|
||||
location / {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_set_header Host \$http_host;
|
||||
proxy_pass http://backend:3100/;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Logger } from './utils/logger';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true, // Отправлять cookies
|
||||
});
|
||||
|
||||
// Interceptor для добавления токена к каждому запросу
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import api from '../api';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@@ -20,17 +22,53 @@ export const useAuth = () => {
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
return localStorage.getItem('token');
|
||||
const initialToken = localStorage.getItem('token');
|
||||
Logger.debug('AuthProvider initialized', 'AuthContext', {
|
||||
hasToken: Boolean(initialToken),
|
||||
});
|
||||
return initialToken;
|
||||
});
|
||||
|
||||
const login = (newToken: string) => {
|
||||
Logger.debug('login() called', 'AuthContext', {
|
||||
tokenLength: newToken.length,
|
||||
});
|
||||
// Сохраняем токен в localStorage для обратной совместимости
|
||||
// Основной токен теперь в httpOnly cookie
|
||||
localStorage.setItem('token', newToken);
|
||||
setToken(newToken);
|
||||
Logger.debug('Token persisted to localStorage and auth state updated', 'AuthContext');
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
Logger.debug('logout() called', 'AuthContext');
|
||||
try {
|
||||
// Вызываем backend для очистки httpOnly cookie
|
||||
await api.post('/auth/logout');
|
||||
Logger.debug('Backend logout request succeeded', 'AuthContext');
|
||||
} catch (error) {
|
||||
const status =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'response' in error &&
|
||||
typeof (error as { response?: unknown }).response === 'object' &&
|
||||
(error as { response?: unknown }).response !== null
|
||||
? ((error as { response?: { status?: number } }).response?.status ?? null)
|
||||
: null;
|
||||
Logger.warn(
|
||||
'Backend logout request failed, continuing local cleanup',
|
||||
'AuthContext',
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
Logger.debug('Local auth state cleared', 'AuthContext');
|
||||
|
||||
// Редирект на страницу входа
|
||||
Logger.debug('Redirecting to /login after logout', 'AuthContext');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from './AuthContext';
|
||||
@@ -8,19 +8,50 @@ export function AxiosInterceptor() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const logoutRef = useRef(logout);
|
||||
const navigateRef = useRef(navigate);
|
||||
const pathnameRef = useRef(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
logoutRef.current = logout;
|
||||
}, [logout]);
|
||||
|
||||
useEffect(() => {
|
||||
navigateRef.current = navigate;
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
pathnameRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
Logger.debug('Registering axios response interceptor', 'AxiosInterceptor');
|
||||
const interceptor = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
Logger.warn('401 Unauthorized detected → logging out and redirecting to /login', 'AxiosInterceptor');
|
||||
// Не делаем logout если уже на странице логина
|
||||
if (location.pathname !== '/login') {
|
||||
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
||||
logout();
|
||||
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
|
||||
navigate('/login');
|
||||
if (pathnameRef.current !== '/login') {
|
||||
try {
|
||||
Logger.debug('Calling logout()', 'AxiosInterceptor');
|
||||
await logoutRef.current();
|
||||
Logger.debug('Navigating to /login...', 'AxiosInterceptor');
|
||||
navigateRef.current('/login');
|
||||
} catch (logoutError) {
|
||||
Logger.error(
|
||||
'logout() failed inside interceptor',
|
||||
'AxiosInterceptor',
|
||||
{
|
||||
message:
|
||||
logoutError instanceof Error
|
||||
? logoutError.message
|
||||
: 'unknown error',
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Logger.debug('Already on /login, skipping auto-logout flow', 'AxiosInterceptor');
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
@@ -28,9 +59,10 @@ export function AxiosInterceptor() {
|
||||
);
|
||||
|
||||
return () => {
|
||||
Logger.debug('Ejecting axios response interceptor', 'AxiosInterceptor');
|
||||
api.interceptors.response.eject(interceptor);
|
||||
};
|
||||
}, [logout, navigate, location.pathname]);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useThemeContext } from '../ThemeContext';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Menu as MenuIcon } from '@mui/icons-material';
|
||||
import { APP_VERSION } from '../utils/version';
|
||||
import { Logger } from '../utils/logger';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick?: () => void;
|
||||
@@ -27,11 +28,14 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {} });
|
||||
|
||||
const handleLogout = () => {
|
||||
Logger.debug('Opening logout confirmation dialog', 'Header');
|
||||
setConfirmDialog({
|
||||
open: true,
|
||||
title: 'Вы действительно хотите выйти?',
|
||||
onConfirm: () => {
|
||||
logout();
|
||||
onConfirm: async () => {
|
||||
Logger.debug('Logout confirmed by user', 'Header');
|
||||
await logout();
|
||||
Logger.debug('logout() resolved in Header', 'Header');
|
||||
navigate('/login');
|
||||
}
|
||||
});
|
||||
@@ -154,12 +158,26 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
<Typography>{confirmDialog.title}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}>Отмена</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
confirmDialog.onConfirm();
|
||||
Logger.debug('Logout canceled by user', 'Header');
|
||||
setConfirmDialog({ ...confirmDialog, open: false });
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await confirmDialog.onConfirm();
|
||||
} catch (error) {
|
||||
Logger.error('Logout confirmation action failed', 'Header', {
|
||||
message: error instanceof Error ? error.message : 'unknown error',
|
||||
});
|
||||
} finally {
|
||||
setConfirmDialog({ ...confirmDialog, open: false });
|
||||
}
|
||||
}}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
@@ -169,4 +187,4 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { getApiErrorMessage } from '../utils/errorHandlers';
|
||||
import { getApiErrorMessage, getApiErrorStatus } from '../utils/errorHandlers';
|
||||
import { APP_VERSION } from '../utils/version';
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -15,7 +15,10 @@ export default function LoginPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
Logger.debug(`Form submit → POST /api/auth/login`, 'Login', { login: creds.login });
|
||||
Logger.debug(`Form submit → POST /api/auth/login`, 'Login', {
|
||||
login: creds.login,
|
||||
hasPassword: Boolean(creds.password),
|
||||
});
|
||||
try {
|
||||
const res = await api.post('/auth/login', creds);
|
||||
|
||||
@@ -23,11 +26,27 @@ export default function LoginPage() {
|
||||
Logger.debug(`Success → token received, calling login()`, 'Login');
|
||||
login(token);
|
||||
|
||||
Logger.debug('Navigating to / after successful login', 'Login');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const status = getApiErrorStatus(error);
|
||||
const message = getApiErrorMessage(error, 'Неверный логин или пароль');
|
||||
Logger.error(`Error: ${message}`, 'Login');
|
||||
setError('Неверный логин или пароль');
|
||||
|
||||
// Rate limit error
|
||||
if (status === 429) {
|
||||
Logger.warn('Too many login attempts. Please try again later.', 'Login', {
|
||||
status,
|
||||
message,
|
||||
});
|
||||
setError('Слишком много попыток входа. Попробуйте позже.');
|
||||
} else {
|
||||
const logMethod = status === 401 ? Logger.warn : Logger.error;
|
||||
logMethod('Login failed', 'Login', {
|
||||
status: status ?? 'unknown',
|
||||
message,
|
||||
});
|
||||
setError('Неверный логин или пароль');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { AuthProvider, useAuth } from '@/auth/AuthContext'
|
||||
import { ReactNode } from 'react'
|
||||
import api from '@/api'
|
||||
|
||||
// Мокаем localStorage
|
||||
const localStorageMock = (() => {
|
||||
@@ -24,25 +25,54 @@ Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
})
|
||||
|
||||
// Мокаем api.post для logout
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: { success: true } }),
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn() },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
)
|
||||
|
||||
describe('AuthContext', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost/',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuth', () => {
|
||||
it('должен выбрасывать ошибку при использовании вне AuthProvider', () => {
|
||||
// Отключаем console.error для этого теста
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useAuth())
|
||||
}).toThrow('useAuth must be used within an AuthProvider')
|
||||
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -50,9 +80,9 @@ describe('AuthContext', () => {
|
||||
describe('initial state', () => {
|
||||
it('должен инициализироваться с token из localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue('test-token-123')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.token).toBe('test-token-123')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith('token')
|
||||
@@ -60,9 +90,9 @@ describe('AuthContext', () => {
|
||||
|
||||
it('должен инициализироваться с null если token отсутствует в localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
@@ -71,11 +101,11 @@ describe('AuthContext', () => {
|
||||
describe('login', () => {
|
||||
it('должен сохранять токен в localStorage и state', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.login('new-token-456')
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.token).toBe('new-token-456')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-token-456')
|
||||
@@ -83,41 +113,42 @@ describe('AuthContext', () => {
|
||||
|
||||
it('должен обновлять isAuthenticated после login', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.login('another-token')
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('должен удалять токен из localStorage и state', () => {
|
||||
it('должен удалять токен из localStorage и state', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('existing-token')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.token).toBe('existing-token')
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('должен корректно работать logout когда token уже null', () => {
|
||||
it('должен корректно работать logout когда token уже null', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
|
||||
expect(result.current.token).toBe(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||
@@ -127,25 +158,25 @@ describe('AuthContext', () => {
|
||||
describe('isAuthenticated', () => {
|
||||
it('должен возвращать true когда token существует', () => {
|
||||
localStorageMock.getItem.mockReturnValue('valid-token')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('должен возвращать false когда token null', () => {
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('должен возвращать false когда token пустая строка', () => {
|
||||
localStorageMock.getItem.mockReturnValue('')
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -153,45 +184,45 @@ describe('AuthContext', () => {
|
||||
describe('context methods', () => {
|
||||
it('должен предоставлять метод login', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.login).toBeDefined()
|
||||
expect(typeof result.current.login).toBe('function')
|
||||
})
|
||||
|
||||
it('должен предоставлять метод logout', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
expect(result.current.logout).toBeDefined()
|
||||
expect(typeof result.current.logout).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple login/logout cycles', () => {
|
||||
it('должен корректно обрабатывать несколько циклов login/logout', () => {
|
||||
it('должен корректно обрабатывать несколько циклов login/logout', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
|
||||
|
||||
// Первый цикл
|
||||
act(() => {
|
||||
result.current.login('token-1')
|
||||
})
|
||||
expect(result.current.token).toBe('token-1')
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
expect(result.current.token).toBe(null)
|
||||
|
||||
|
||||
// Второй цикл
|
||||
act(() => {
|
||||
result.current.login('token-2')
|
||||
})
|
||||
expect(result.current.token).toBe('token-2')
|
||||
|
||||
act(() => {
|
||||
result.current.logout()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
expect(result.current.token).toBe(null)
|
||||
|
||||
|
||||
// Третий цикл
|
||||
act(() => {
|
||||
result.current.login('token-3')
|
||||
|
||||
@@ -24,6 +24,8 @@ beforeAll(() => {
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[API]') ||
|
||||
message.includes('[Login]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Rotation]') ||
|
||||
message.includes('[Domains]') ||
|
||||
message.includes('[Subs]') ||
|
||||
@@ -40,6 +42,8 @@ beforeAll(() => {
|
||||
if (
|
||||
message.includes('[Tunnels]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Domains]') ||
|
||||
message.includes('[Subs]') ||
|
||||
message.includes('[Scanner]')
|
||||
@@ -56,8 +60,11 @@ beforeAll(() => {
|
||||
if (
|
||||
message.includes('act(...)') ||
|
||||
message.includes('An update to') ||
|
||||
message.includes('Not implemented: navigation to another Document') ||
|
||||
message.includes('[Login]') ||
|
||||
message.includes('[Settings]') ||
|
||||
message.includes('[AuthContext]') ||
|
||||
message.includes('[AxiosInterceptor]') ||
|
||||
message.includes('[Subs]')
|
||||
) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user