improve security: back port, base port, login guard, scripts

This commit is contained in:
iqubik
2026-04-03 02:23:05 +03:00
parent 41265ccf37
commit e23de664ac
26 changed files with 1172 additions and 221 deletions
+20 -5
View File
@@ -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;
}
}
+1
View File
@@ -3,6 +3,7 @@ import { Logger } from './utils/logger';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // Отправлять cookies
});
// Interceptor для добавления токена к каждому запросу
+41 -3
View File
@@ -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 (
+41 -9
View File
@@ -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;
}
}
+23 -5
View File
@@ -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>
</>
);
}
}
+24 -5
View File
@@ -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('Неверный логин или пароль');
}
}
};
+73 -42
View File
@@ -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')
+7
View File
@@ -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