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
+1
View File
@@ -31,6 +31,7 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
PORT: ${PORT}
LOG_LEVEL: ${LOG_LEVEL}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
ports:
- "${PORT}:${PORT}"
networks:
+45 -10
View File
@@ -246,6 +246,16 @@ DB_PASS=$(openssl rand -base64 12)
JWT_SECRET=$(openssl rand -base64 32)
ADMIN_USER=$(openssl rand -base64 8)
ADMIN_PASS=$(openssl rand -base64 12)
# Определяем ALLOWED_ORIGINS из домена или IP
if [[ -n "${UI_HOST:-}" ]]; then
if [[ "$UI_HOST" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
ALLOWED_ORIGINS="http://${UI_HOST}"
else
ALLOWED_ORIGINS="https://${UI_HOST}"
fi
else
ALLOWED_ORIGINS=""
fi
log "Сгенерированы секретные ключи для БД и JWT."
#################################
@@ -334,6 +344,9 @@ DB_PASSWORD=${DB_PASS}
DB_NAME=3dp_manager
ADMIN_LOGIN=${ADMIN_USER}
ADMIN_PASSWORD=${ADMIN_PASS}
PORT=3100
LOG_LEVEL=error
ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
EOF
if [[ "$USE_SSL" == "true" ]]; then
@@ -355,7 +368,16 @@ server {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_pass http://backend:3100/api/;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_send_timeout 650s;
proxy_read_timeout 650s;
}
location /bus/ {
proxy_pass http://backend:3100/bus/;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
@@ -365,7 +387,7 @@ server {
}
}
server {
listen 3000 ssl;
listen 3100 ssl;
server_name $UI_HOST;
client_max_body_size 50M;
@@ -373,7 +395,7 @@ server {
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://backend:3000/;
proxy_pass http://backend:3100/;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
@@ -419,7 +441,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS}
PORT: 3000
PORT: 3100
volumes:
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
networks:
@@ -433,7 +455,7 @@ services:
- backend
ports:
- "${FINAL_PORT}:443"
- "3000:3000"
- "3100:3100"
volumes:
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
- ${CERT_PATH}:/etc/nginx/certs/fullchain.pem:ro
@@ -465,7 +487,20 @@ server {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_pass http://backend:3100/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$http_host;
proxy_cache_bypass \$http_upgrade;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_send_timeout 650s;
proxy_read_timeout 650s;
}
location /bus/ {
proxy_pass http://backend:3100/bus/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -479,10 +514,10 @@ server {
}
}
server {
listen 3000;
listen 3100;
server_name localhost;
location / {
proxy_pass http://backend:3000/;
proxy_pass http://backend:3100/;
proxy_set_header Host \$http_host;
}
}
@@ -525,7 +560,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS}
PORT: 3000
PORT: 3100
volumes:
- /etc/hysteria/config.yaml:/etc/hysteria/config.yaml:ro
networks:
@@ -539,7 +574,7 @@ services:
- backend
ports:
- "${FINAL_PORT}:80"
- "3000:3000"
- "3100:3100"
volumes:
- ./client/nginx-client.conf:/etc/nginx/conf.d/default.conf:ro
networks:
+2 -2
View File
@@ -50,8 +50,8 @@ COPY --from=scanner-builder /out/RealiTLScanner-linux-64 /usr/local/bin/RealiTLS
RUN chmod +x /usr/local/bin/RealiTLScanner-linux-64
ENV NODE_ENV=production
ENV PORT=3000
ENV PORT=3100
EXPOSE 3000
EXPOSE 3100
CMD ["node", "dist/main"]
+32
View File
@@ -18,12 +18,14 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.17.1",
@@ -2461,6 +2463,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/typeorm": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz",
@@ -4825,6 +4838,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+5
View File
@@ -29,12 +29,14 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.17.1",
@@ -94,6 +96,9 @@
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
},
"setupFilesAfterEnv": [
"<rootDir>/test/jest.setup.ts"
],
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.module.ts",
+11
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@@ -27,6 +28,12 @@ import { SessionModule } from './session/session.module';
ConfigModule.forRoot({
isGlobal: true,
}),
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 5,
},
]),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
@@ -51,6 +58,10 @@ import { SessionModule } from './session/session.module';
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
+63 -3
View File
@@ -4,9 +4,15 @@ import {
Body,
HttpException,
HttpStatus,
Res,
Logger,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
interface LoginDto {
login: string;
@@ -15,19 +21,73 @@ interface LoginDto {
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
private readonly logger = new Logger(AuthController.name);
constructor(
private authService: AuthService,
private configService: ConfigService,
) {}
@Public()
@Throttle({
default: { limit: 5, ttl: 60000 },
})
@Post('login')
async login(@Body() req: LoginDto) {
async login(
@Body() req: LoginDto,
@Res({ passthrough: true }) res: Response,
) {
this.logger.debug(`Login request received for user: ${req.login}`);
const user = await this.authService.validateUser(req.login, req.password);
if (!user) {
this.logger.warn(`Login failed for user: ${req.login}`);
throw new HttpException(
'Неверный логин или пароль',
HttpStatus.UNAUTHORIZED,
);
}
return this.authService.login(user as { login: string });
const { access_token } = this.authService.login(user as { login: string });
// Устанавливаем httpOnly cookie
const isProduction =
this.configService.get<string>('NODE_ENV') === 'production';
this.logger.debug(
`Login succeeded for user: ${req.login}. Setting auth cookie (secure=${isProduction}, sameSite=lax, maxAgeMs=86400000)`,
);
res.cookie('access_token', access_token, {
httpOnly: true,
secure: isProduction, // HTTPS только в production
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24 часа
path: '/',
});
this.logger.debug(`Login response prepared for user: ${req.login}`);
return { access_token };
}
@Public()
@Post('logout')
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const hadCookie = Boolean(
(req.cookies as Record<string, unknown> | undefined)?.access_token,
);
this.logger.debug(`Logout request received. Cookie present: ${hadCookie}`);
// Очищаем httpOnly cookie
const isProduction =
this.configService.get<string>('NODE_ENV') === 'production';
res.clearCookie('access_token', {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
});
this.logger.debug(
`Auth cookie cleared (secure=${isProduction}, sameSite=lax, path=/)`,
);
return { success: true };
}
@Post('change-password')
+4 -7
View File
@@ -30,13 +30,9 @@ export class AuthService {
where: { key: 'admin_password' },
});
if (!dbLogin) {
this.logger.error('Пользователь admin_login не найден в базе данных!');
return null;
}
if (!dbPass) {
this.logger.error('Пароль admin_password не найден в базе данных!');
// Проверяем наличие учётных данных (без деталей для безопасности)
if (!dbLogin || !dbPass) {
this.logger.error('Учётные данные не найдены в базе данных');
return null;
}
@@ -55,6 +51,7 @@ export class AuthService {
login(user: { login: string }) {
const payload = { username: user.login };
this.logger.debug(`Генерация access token для пользователя: ${user.login}`);
return {
access_token: this.jwtService.sign(payload),
};
+34 -1
View File
@@ -8,6 +8,10 @@ import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
type RequestWithCookies = Request & {
cookies?: unknown;
};
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
private readonly logger = new Logger(JwtAuthGuard.name);
@@ -17,7 +21,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest<RequestWithCookies>();
const cookies: Record<string, unknown> =
request.cookies && typeof request.cookies === 'object'
? (request.cookies as Record<string, unknown>)
: {};
this.logger.debug(
`canActivate called for: ${request.url} ${request.method}`,
);
@@ -33,6 +41,23 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
return true;
}
// Поддержка токена из cookie (httpOnly)
const tokenFromCookieValue = cookies.access_token;
const tokenFromCookie =
typeof tokenFromCookieValue === 'string'
? tokenFromCookieValue
: undefined;
if (tokenFromCookie && !request.headers.authorization) {
this.logger.debug(
`Token found in cookie, adding to Authorization header`,
);
request.headers.authorization = `Bearer ${tokenFromCookie}`;
} else if (tokenFromCookie) {
this.logger.debug(
`Token found in cookie, but Authorization header already exists`,
);
}
// Support token from query parameter (for SSE connections)
const tokenFromQuery = request.query.token as string | undefined;
if (tokenFromQuery && !request.headers.authorization) {
@@ -40,6 +65,14 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
`Token found in query parameter, adding to Authorization header`,
);
request.headers.authorization = `Bearer ${tokenFromQuery}`;
} else if (tokenFromQuery) {
this.logger.debug(
`Token found in query parameter, but Authorization header already exists`,
);
} else if (!request.headers.authorization) {
this.logger.debug(
`No token found in Authorization header, cookie, or query`,
);
}
this.logger.debug(`Calling super.canActivate()`);
+46 -4
View File
@@ -3,7 +3,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { AuthService } from './auth/auth.service';
import { RequestMethod, Logger, LogLevel } from '@nestjs/common';
import { Request, Response } from 'express';
import { Request, Response, NextFunction, RequestHandler } from 'express';
import cookieParser from 'cookie-parser';
import { HttpExceptionFilter } from './client/client.exception-filter';
import { ConfigService } from '@nestjs/config';
@@ -23,12 +24,53 @@ async function bootstrap() {
app.useLogger(logLevels);
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = Date.now();
res.on('finish', () => {
logger.debug(
`${req.method} ${req.originalUrl} -> ${res.statusCode} (${Date.now() - startedAt}ms)`,
);
});
next();
});
app.set('trust proxy', 1);
// Cookie parser для работы с httpOnly cookies
const cookieParserFactory = cookieParser as unknown as () => RequestHandler;
app.use(cookieParserFactory());
const authService = app.get(AuthService);
await authService.seedAdmin();
app.enableCors();
const allowedOrigins = (
configService.get<string>('ALLOWED_ORIGINS', '') || ''
)
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
logger.log(
`CORS origins: ${
allowedOrigins.length > 0
? allowedOrigins.join(', ')
: 'all origins allowed'
}`,
);
app.enableCors({
origin: (origin, callback) => {
// Разрешаем запросы без origin (например, из мобильных приложений или curl)
if (!origin) return callback(null, true);
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) {
return callback(null, true);
}
logger.warn(`CORS blocked origin: ${origin}`);
return callback(new Error('Not allowed by CORS'), false);
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
app.useGlobalFilters(new HttpExceptionFilter());
app.setGlobalPrefix('api', {
exclude: [
@@ -37,8 +79,8 @@ async function bootstrap() {
],
});
const port = configService.get<number>('PORT', 3000);
await app.listen(port);
const port = configService.get<number>('PORT', 3100);
await app.listen(port, '0.0.0.0');
logger.log(`Application started on port ${port}`);
}
void bootstrap();
+115
View File
@@ -0,0 +1,115 @@
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { Test } from '@nestjs/testing';
import {
getOptionsToken,
ThrottlerGuard,
ThrottlerModule,
} from '@nestjs/throttler';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { AppModule } from 'src/app.module';
type DynamicModuleLike = {
module?: unknown;
global?: boolean;
providers?: Array<{
provide?: unknown;
useClass?: unknown;
useValue?: unknown;
}>;
};
type AppImportEntry = DynamicModuleLike | Promise<DynamicModuleLike>;
describe('AppModule конфигурация', () => {
afterEach(() => {
delete process.env.ALLOWED_ORIGINS;
delete process.env.JWT_SECRET;
});
it('должен регистрировать ThrottlerModule с лимитом 5/60000ms', () => {
const imports = (Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) ??
[]) as DynamicModuleLike[];
const throttlerDynamicModule = imports.find(
(entry) => entry.module === ThrottlerModule,
);
expect(throttlerDynamicModule).toBeDefined();
const throttlerOptionsProvider = throttlerDynamicModule?.providers?.find(
(provider) => provider.provide === getOptionsToken(),
);
expect(throttlerOptionsProvider).toBeDefined();
expect(throttlerOptionsProvider?.useValue).toEqual(
expect.arrayContaining([
expect.objectContaining({
limit: 5,
ttl: 60000,
}),
]),
);
});
it('должен регистрировать ThrottlerGuard и JwtAuthGuard как глобальные guards', () => {
const providers = (Reflect.getMetadata(
MODULE_METADATA.PROVIDERS,
AppModule,
) ?? []) as DynamicModuleLike['providers'];
expect(providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
provide: APP_GUARD,
useClass: ThrottlerGuard,
}),
expect.objectContaining({
provide: APP_GUARD,
useClass: JwtAuthGuard,
}),
]),
);
});
it('должен подключать ConfigModule как глобальный', async () => {
const imports = (Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) ??
[]) as AppImportEntry[];
const resolvedImports = await Promise.all(
imports.map((entry) =>
entry instanceof Promise ? entry : Promise.resolve(entry),
),
);
const configDynamicModule = resolvedImports.find(
(entry) => entry.module === ConfigModule,
);
expect(configDynamicModule).toBeDefined();
expect(configDynamicModule?.global).toBe(true);
});
it('должен делать ConfigService доступным и читать ALLOWED_ORIGINS из env', async () => {
process.env.ALLOWED_ORIGINS = 'http://test.local:8080,http://localhost';
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
}),
],
}).compile();
const configService = moduleRef.get<ConfigService>(ConfigService);
expect(configService).toBeDefined();
expect(configService.get<string>('ALLOWED_ORIGINS')).toBe(
'http://test.local:8080,http://localhost',
);
});
it('должен использовать достаточно длинный JWT_SECRET в тестовых env', () => {
process.env.JWT_SECRET = 'test-secret-key-for-jwt-signing-1234567890';
const jwtSecret = process.env.JWT_SECRET;
expect(jwtSecret).toBeDefined();
expect(jwtSecret?.length).toBeGreaterThanOrEqual(32);
});
});
+156 -4
View File
@@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { HttpException } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthController } from 'src/auth/auth.controller';
import { AuthService } from 'src/auth/auth.service';
import type { Request, Response } from 'express';
describe('AuthController', () => {
let controller: AuthController;
@@ -17,6 +19,7 @@ describe('AuthController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
controllers: [AuthController],
providers: [
{
@@ -35,6 +38,18 @@ describe('AuthController', () => {
});
describe('login', () => {
const mockResponse = {
cookie: jest
.fn<ReturnType<Response['cookie']>, Parameters<Response['cookie']>>()
.mockReturnThis(),
clearCookie: jest
.fn<
ReturnType<Response['clearCookie']>,
Parameters<Response['clearCookie']>
>()
.mockReturnThis(),
} satisfies Pick<Response, 'cookie' | 'clearCookie'>;
it('должен вернуть access_token при успешной аутентификации', async () => {
const loginDto = { login: 'admin', password: 'password' };
const mockUser = { login: 'admin' };
@@ -43,7 +58,10 @@ describe('AuthController', () => {
mockAuthService.validateUser.mockResolvedValue(mockUser);
mockAuthService.login.mockReturnValue(mockToken);
const result = await controller.login(loginDto);
const result = await controller.login(
loginDto,
mockResponse as unknown as Response,
);
expect(result).toEqual(mockToken);
expect(authService.validateUser).toHaveBeenCalledWith(
@@ -51,6 +69,16 @@ describe('AuthController', () => {
'password',
);
expect(authService.login).toHaveBeenCalledWith(mockUser);
expect(mockResponse.cookie).toHaveBeenCalledWith(
'access_token',
'jwt-token',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
maxAge: 86400000,
path: '/',
}),
);
});
it('должен бросить HttpException при неверных учётных данных', async () => {
@@ -58,11 +86,135 @@ describe('AuthController', () => {
mockAuthService.validateUser.mockResolvedValue(null);
await expect(controller.login(loginDto)).rejects.toThrow(HttpException);
await expect(
controller.login(loginDto, mockResponse as unknown as Response),
).rejects.toThrow(HttpException);
await expect(controller.login(loginDto)).rejects.toThrow(
'Неверный логин или пароль',
await expect(
controller.login(loginDto, mockResponse as unknown as Response),
).rejects.toThrow('Неверный логин или пароль');
expect(mockResponse.cookie).not.toHaveBeenCalled();
});
it('должен установить secure=true cookie в production режиме', async () => {
process.env.NODE_ENV = 'production';
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
const prodController = module.get<AuthController>(AuthController);
const loginDto = { login: 'admin', password: 'password' };
const mockUser = { login: 'admin' };
const mockToken = { access_token: 'jwt-token' };
mockAuthService.validateUser.mockResolvedValue(mockUser);
mockAuthService.login.mockReturnValue(mockToken);
await prodController.login(loginDto, mockResponse as unknown as Response);
expect(mockResponse.cookie).toHaveBeenCalledWith(
'access_token',
'jwt-token',
expect.objectContaining({
secure: true,
}),
);
delete process.env.NODE_ENV;
});
});
describe('logout', () => {
const mockResponse = {
cookie: jest
.fn<ReturnType<Response['cookie']>, Parameters<Response['cookie']>>()
.mockReturnThis(),
clearCookie: jest
.fn<
ReturnType<Response['clearCookie']>,
Parameters<Response['clearCookie']>
>()
.mockReturnThis(),
} satisfies Pick<Response, 'cookie' | 'clearCookie'>;
it('должен очистить access_token cookie и вернуть success', () => {
const mockRequest = {
cookies: { access_token: 'some-token' },
} as unknown as Request;
const result = controller.logout(
mockRequest,
mockResponse as unknown as Response,
);
expect(result).toEqual({ success: true });
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
'access_token',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
path: '/',
}),
);
});
it('должен очистить cookie даже если cookie не было в запросе', () => {
const mockRequest = {
cookies: {},
} as unknown as Request;
const result = controller.logout(
mockRequest,
mockResponse as unknown as Response,
);
expect(result).toEqual({ success: true });
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
'access_token',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
path: '/',
}),
);
});
it('должен установить secure=true для clearCookie в production режиме', async () => {
process.env.NODE_ENV = 'production';
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
const prodController = module.get<AuthController>(AuthController);
const mockRequest = {
cookies: { access_token: 'some-token' },
} as unknown as Request;
prodController.logout(mockRequest, mockResponse as unknown as Response);
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
'access_token',
expect.objectContaining({
secure: true,
}),
);
delete process.env.NODE_ENV;
});
});
+109 -91
View File
@@ -1,90 +1,143 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-floating-promises */
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
type TestRequest = {
url: string;
method: string;
headers: Record<string, string | undefined>;
query: Record<string, unknown>;
cookies?: unknown;
};
function createContext(request: TestRequest): ExecutionContext {
return {
switchToHttp: () =>
({
getRequest: () => request,
}) as ReturnType<ExecutionContext['switchToHttp']>,
getHandler: () => ({}),
getClass: () => class TestController {},
} as ExecutionContext;
}
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: Reflector;
let mockContext: ExecutionContext;
let mockRequest: any;
let parentCanActivateSpy: jest.SpyInstance;
beforeEach(() => {
reflector = new Reflector();
guard = new JwtAuthGuard(reflector);
mockRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: {},
const parentPrototype = Object.getPrototypeOf(JwtAuthGuard.prototype) as {
canActivate: (context: ExecutionContext) => boolean | Promise<boolean>;
};
mockContext = {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue(mockRequest),
getResponse: jest.fn(),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as any;
parentCanActivateSpy = jest
.spyOn(parentPrototype, 'canActivate')
.mockReturnValue(true);
});
afterEach(() => {
jest.clearAllMocks();
parentCanActivateSpy.mockRestore();
jest.restoreAllMocks();
});
describe('canActivate', () => {
it('должен вернуть true для публичного маршрута', () => {
const request: TestRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: {},
};
const context = createContext(request);
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true);
const result = guard.canActivate(mockContext);
const result = guard.canActivate(context);
expect(result).toBe(true);
expect(parentCanActivateSpy).not.toHaveBeenCalled();
});
it('должен вызвать super.canActivate для защищенного маршрута', () => {
it('должен вызывать super.canActivate для защищенного маршрута', async () => {
const request: TestRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: {},
};
const context = createContext(request);
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
const superCanActivate = jest
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
.mockImplementation(() => true);
await guard.canActivate(context);
guard.canActivate(mockContext);
expect(superCanActivate).toHaveBeenCalled();
expect(parentCanActivateSpy).toHaveBeenCalledWith(context);
});
it('НЕ должен добавлять токен, если authorization уже есть', () => {
it('должен добавлять токен из cookie в authorization header', async () => {
const request: TestRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: {},
cookies: { access_token: 'cookie-jwt-token' },
};
const context = createContext(request);
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
mockRequest.query.token = 'test-jwt-token';
mockRequest.headers.authorization = 'Bearer existing-token';
const _superCanActivate = jest
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
.mockImplementation(() => true);
await guard.canActivate(context);
guard.canActivate(mockContext);
expect(mockRequest.headers.authorization).toBe('Bearer existing-token');
expect(request.headers.authorization).toBe('Bearer cookie-jwt-token');
});
it('должен вернуть результат super.canActivate', () => {
it('не должен перезаписывать существующий authorization header', async () => {
const request: TestRequest = {
url: '/api/test',
method: 'GET',
headers: { authorization: 'Bearer existing-token' },
query: { token: 'query-jwt-token' },
cookies: { access_token: 'cookie-jwt-token' },
};
const context = createContext(request);
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
const _superCanActivate = jest
.spyOn(JwtAuthGuard.prototype, 'canActivate' as any)
.mockImplementation(() => 'PENDING_RESULT');
await guard.canActivate(context);
const result = guard.canActivate(mockContext);
expect(request.headers.authorization).toBe('Bearer existing-token');
});
expect(result).toBe('PENDING_RESULT');
it('должен добавлять токен из query параметра в authorization header', async () => {
const request: TestRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: { token: 'query-jwt-token' },
cookies: {},
};
const context = createContext(request);
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
await guard.canActivate(context);
expect(request.headers.authorization).toBe('Bearer query-jwt-token');
});
it('не должен падать при невалидном формате cookies', async () => {
const request: TestRequest = {
url: '/api/test',
method: 'GET',
headers: {},
query: {},
cookies: 'invalid-cookies',
};
const context = createContext(request);
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false);
await guard.canActivate(context);
expect(request.headers.authorization).toBeUndefined();
});
});
@@ -97,53 +150,18 @@ describe('JwtAuthGuard', () => {
expect(result).toEqual(user);
});
it('должен бросить ошибку при наличии ошибки', () => {
const _error = new Error('Invalid token');
it('должен пробрасывать Error как есть', () => {
const authError = new Error('Invalid token');
expect(() => guard.handleRequest(_error, null, null)).toThrow(Error);
expect(() => {
guard.handleRequest(authError, null, null);
}).toThrow('Invalid token');
});
it('должен бросить UnauthorizedException, если пользователь null и нет ошибки', () => {
expect(() => guard.handleRequest(null, null, null)).toThrow(
UnauthorizedException,
);
});
it('должен бросить ошибку с сообщением из Error', () => {
const _error = new Error('Custom error message');
try {
guard.handleRequest(_error, null, null);
} catch (e) {
expect((e as Error).message).toContain('Custom error message');
}
});
it('должен бросить ошибку с сообщением из строки', () => {
const _error = 'String error message';
// handleRequest пробрасывает строковую ошибку как есть через throw
expect(() => guard.handleRequest(_error as any, null, null)).toThrow(
'String error message',
);
});
it('должен бросить ошибку с JSON сообщением', () => {
const _error = { message: 'JSON error' };
// Для объекта берётся message поле
expect(() => guard.handleRequest(_error as any, null, null)).toThrow(
'JSON error',
);
});
it('должен бросить ошибку с сообщением "null", если error=null и user=null', () => {
try {
it('должен бросать UnauthorizedException, если нет user и err', () => {
expect(() => {
guard.handleRequest(null, null, null);
} catch (_e: any) {
// UnauthorizedException имеет пустое сообщение по умолчанию
expect(_e).toBeInstanceOf(UnauthorizedException);
}
}).toThrow(UnauthorizedException);
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Logger } from '@nestjs/common';
beforeAll(() => {
// Keep successful test runs quiet by silencing Nest logger output.
Logger.overrideLogger(false);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'verbose').mockImplementation(() => undefined);
});
+181
View File
@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import cookieParser from 'cookie-parser';
import type { Response, NextFunction } from 'express';
describe('main.ts - CORS и middleware', () => {
describe('CORS конфигурация', () => {
it('должен разрешить origin из ALLOWED_ORIGINS', () => {
process.env.ALLOWED_ORIGINS = 'http://localhost:8080,http://localhost';
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
expect(allowedOrigins).toContain('http://localhost:8080');
expect(allowedOrigins).toContain('http://localhost');
delete process.env.ALLOWED_ORIGINS;
});
it('должен вернуть пустой массив если ALLOWED_ORIGINS не задан', () => {
process.env.ALLOWED_ORIGINS = '';
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
expect(allowedOrigins).toHaveLength(0);
delete process.env.ALLOWED_ORIGINS;
});
it('должен разрешить все origin если ALLOWED_ORIGINS пустой', () => {
const allowedOrigins: string[] = [];
const result = allowedOrigins.length === 0 ? 'all' : 'restricted';
expect(result).toBe('all');
});
it('должен заблокировать origin не из ALLOWED_ORIGINS', () => {
process.env.ALLOWED_ORIGINS = 'http://localhost:8080';
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const testOrigin = 'http://evil.com';
const isAllowed = allowedOrigins.includes(testOrigin);
expect(isAllowed).toBe(false);
delete process.env.ALLOWED_ORIGINS;
});
it('должен разрешить запросы без origin (mobile apps, curl)', () => {
const callback = jest.fn();
const origin = undefined;
// Симуляция CORS callback для запросов без origin
if (!origin) {
callback(null, true);
}
expect(callback).toHaveBeenCalledWith(null, true);
});
});
describe('Request tracing middleware', () => {
it('должен логировать время выполнения запроса', () => {
const mockResponse = {
statusCode: 200,
on: jest.fn((event: string, handler: () => void) => {
if (event === 'finish') {
handler();
}
}),
} as unknown as Response;
const next = jest.fn<
ReturnType<NextFunction>,
Parameters<NextFunction>
>();
// Симуляция middleware
const startedAt = Date.now();
mockResponse.on('finish', () => {
const duration = Date.now() - startedAt;
expect(duration).toBeGreaterThanOrEqual(0);
});
next();
expect(next).toHaveBeenCalled();
});
it('должен вызвать next() для продолжения обработки', () => {
const next = jest.fn<
ReturnType<NextFunction>,
Parameters<NextFunction>
>();
// Симуляция middleware
next();
expect(next).toHaveBeenCalledTimes(1);
});
});
describe('cookie-parser middleware', () => {
it('должен быть импортирован и использован как функция', () => {
// Проверяем что cookie-parser может быть вызван как функция
// Реальная проверка происходит в runtime через main.ts
expect(typeof cookieParser).toBe('function');
expect(typeof cookieParser()).toBe('function');
});
});
describe('app.listen конфигурация', () => {
it('должен слушать на 0.0.0.0 для работы в Docker', () => {
const port = 3100;
const host = '0.0.0.0';
// Проверка что хост задан правильно
expect(host).toBe('0.0.0.0');
expect(port).toBe(3100);
});
});
describe('ConfigService использование', () => {
it('должен получить ALLOWED_ORIGINS из ConfigService', () => {
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: string) => {
if (key === 'ALLOWED_ORIGINS') {
return 'http://localhost:8080';
}
return defaultValue;
}),
};
const allowedOrigins = mockConfigService.get('ALLOWED_ORIGINS', '');
expect(allowedOrigins).toBe('http://localhost:8080');
expect(mockConfigService.get).toHaveBeenCalledWith('ALLOWED_ORIGINS', '');
});
it('должен вернуть пустую строку если ALLOWED_ORIGINS не задан', () => {
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: string) => {
if (key === 'ALLOWED_ORIGINS') {
return defaultValue;
}
return defaultValue;
}),
};
const allowedOrigins = mockConfigService.get('ALLOWED_ORIGINS', '');
expect(allowedOrigins).toBe('');
});
it('должен получить NODE_ENV для определения production режима', () => {
process.env.NODE_ENV = 'production';
const mockConfigService = {
get: jest.fn((key: string, defaultValue?: string) => {
return process.env[key] ?? defaultValue;
}),
};
const nodeEnv = mockConfigService.get('NODE_ENV');
expect(nodeEnv).toBe('production');
delete process.env.NODE_ENV;
});
});
});
@@ -50,7 +50,7 @@ describe('SettingsController', () => {
describe('findAll', () => {
it('должен вернуть все настройки как объект', async () => {
const mockSettings = [
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
];
@@ -60,7 +60,7 @@ describe('SettingsController', () => {
const result = await controller.findAll();
expect(result).toEqual({
xui_url: 'http://localhost:3000',
xui_url: 'http://localhost:3100',
xui_login: 'admin',
xui_password: 'password',
});
@@ -79,7 +79,7 @@ describe('SettingsController', () => {
describe('checkConnection', () => {
it('должен проверить подключение к 3x-ui', async () => {
const body = {
xui_url: 'http://localhost:3000',
xui_url: 'http://localhost:3100',
xui_login: 'admin',
xui_password: 'password',
};
@@ -98,7 +98,7 @@ describe('SettingsController', () => {
it('должен вернуть false при неудачном подключении', async () => {
const body = {
xui_url: 'http://localhost:3000',
xui_url: 'http://localhost:3100',
xui_login: 'admin',
xui_password: 'wrong',
};
+4 -4
View File
@@ -70,7 +70,7 @@ describe('XuiService', () => {
it('должен вернуть true при успешном логине', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
@@ -92,7 +92,7 @@ describe('XuiService', () => {
it('должен вернуть false при ошибке логина', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
@@ -106,7 +106,7 @@ describe('XuiService', () => {
it('должен вернуть false, если нет cookie в ответе', async () => {
mockSettingsRepo.find.mockResolvedValue([
{ key: 'xui_url', value: 'http://localhost:3000' },
{ key: 'xui_url', value: 'http://localhost:3100' },
{ key: 'xui_login', value: 'admin' },
{ key: 'xui_password', value: 'password' },
]);
@@ -126,7 +126,7 @@ describe('XuiService', () => {
mockAxiosInstance.post.mockRejectedValue(new Error('Connection failed'));
const result = await service.checkConnection(
'http://localhost:3000',
'http://localhost:3100',
'admin',
'password',
);
+118 -22
View File
@@ -61,26 +61,25 @@ check_containers_running() {
check_and_fix_credentials() {
log "Проверка учётных данных на безопасность..."
local env_file=".env"
local compose_file="docker-compose.yml"
local credentials_changed=0
# Проверяем, существует ли .env файл
if [[ ! -f "$env_file" ]]; then
log "Создание .env файла с безопасными учётными данными..."
# Генерируем случайные пароли
local db_pass
local jwt_secret
local admin_login
local admin_pass
db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
jwt_secret=$(openssl rand -base64 32)
admin_login=$(openssl rand -base64 8 | tr -dc 'A-Za-z0-9' | cut -c1-8)
admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
# Создаём .env файл
cat > "$env_file" <<EOF
POSTGRES_USER=admin
@@ -89,15 +88,18 @@ POSTGRES_DB=3dp_manager
JWT_SECRET=${jwt_secret}
ADMIN_LOGIN=${admin_login}
ADMIN_PASSWORD=${admin_pass}
PORT=3100
LOG_LEVEL=error
ALLOWED_ORIGINS=
EOF
log "Сгенерированы новые учётные данные:"
log " ADMIN_LOGIN: ${admin_login}"
log " ADMIN_PASSWORD: ${admin_pass}"
log " POSTGRES_PASSWORD: ${db_pass}"
log " JWT_SECRET: ${jwt_secret}"
log "⚠️ Сохраните эти данные в безопасном месте!"
credentials_changed=1
else
# Проверяем, не используются ли дефолтные значения
@@ -105,52 +107,52 @@ EOF
local admin_pass_val
local jwt_secret_val
local db_pass_val
admin_login_val=$(grep -E "^ADMIN_LOGIN=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
admin_pass_val=$(grep -E "^ADMIN_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
jwt_secret_val=$(grep -E "^JWT_SECRET=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
db_pass_val=$(grep -E "^POSTGRES_PASSWORD=" "$env_file" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "")
local needs_update=0
if [[ "$admin_login_val" == "admin" ]] || [[ -z "$admin_login_val" ]]; then
warn "Обнаружен дефолтный ADMIN_LOGIN=admin"
needs_update=1
fi
if [[ "$admin_pass_val" == "admin" ]] || [[ -z "$admin_pass_val" ]]; then
warn "Обнаружен дефолтный ADMIN_PASSWORD=admin"
needs_update=1
fi
if [[ "$jwt_secret_val" == "secretKey" ]] || [[ -z "$jwt_secret_val" ]]; then
warn "Обнаружен дефолтный JWT_SECRET=secretKey"
needs_update=1
fi
if [[ "$db_pass_val" == "admin" ]] || [[ -z "$db_pass_val" ]]; then
warn "Обнаружен дефолтный POSTGRES_PASSWORD=admin"
needs_update=1
fi
if [[ $needs_update -eq 1 ]]; then
log "Генерация новых безопасных учётных данных..."
# Генерируем новые пароли
local new_db_pass
local new_jwt_secret
local new_admin_login
local new_admin_pass
new_db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
new_jwt_secret=$(openssl rand -base64 32)
new_admin_login=$(openssl rand -base64 8 | tr -dc 'A-Za-z0-9' | cut -c1-8)
new_admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | cut -c1-12)
# Сохраняем существующие значения, которые не нужно менять
local existing_postgres_user
existing_postgres_user=$(grep -E "^POSTGRES_USER=" "$env_file" 2>/dev/null | cut -d'=' -f2 || echo "admin")
# Создаём новый .env файл
cat > "$env_file" <<EOF
POSTGRES_USER=${existing_postgres_user:-admin}
@@ -160,23 +162,108 @@ JWT_SECRET=${new_jwt_secret}
ADMIN_LOGIN=${new_admin_login}
ADMIN_PASSWORD=${new_admin_pass}
EOF
log "Сгенерированы новые учётные данные:"
log " ADMIN_LOGIN: ${new_admin_login}"
log " ADMIN_PASSWORD: ${new_admin_pass}"
log " POSTGRES_PASSWORD: ${new_db_pass}"
log " JWT_SECRET: ${new_jwt_secret}"
log "⚠️ Сохраните эти данные в безопасном месте!"
credentials_changed=1
else
log "Учётные данные безопасны ✅"
fi
fi
return $credentials_changed
}
ensure_nginx_api_timeouts() {
local nginx_conf="$1"
[[ -f "$nginx_conf" ]] || return 0
local tmp_file
tmp_file="$(mktemp)"
awk '
BEGIN { in_api = 0; in_bus = 0; injected = 0 }
{
line = $0
if (line ~ /^[[:space:]]*location[[:space:]]+\/api\/[[:space:]]*\{/) {
in_api = 1
in_bus = 0
injected = 0
}
if (line ~ /^[[:space:]]*location[[:space:]]+\/bus\/[[:space:]]*\{/) {
in_bus = 1
in_api = 0
injected = 0
}
if ((in_api || in_bus) && line ~ /proxy_(connect|send|read)_timeout[[:space:]]+[0-9]+s;/) {
next
}
print line
if ((in_api || in_bus) && line ~ /proxy_set_header[[:space:]]+X-Forwarded-For[[:space:]]+/ && injected == 0) {
print " proxy_connect_timeout 10s;"
print " proxy_send_timeout 650s;"
print " proxy_read_timeout 650s;"
injected = 1
}
if ((in_api || in_bus) && line ~ /^[[:space:]]*}/) {
in_api = 0
in_bus = 0
injected = 0
}
}
' "$nginx_conf" > "$tmp_file"
mv "$tmp_file" "$nginx_conf"
}
ensure_bus_location() {
local nginx_conf="$1"
[[ -f "$nginx_conf" ]] || return 0
# Проверяем, есть ли уже location /bus/
if grep -q "location /bus/" "$nginx_conf"; then
return 0
fi
local tmp_file
tmp_file="$(mktemp)"
awk '
{
print $0
if ($0 ~ /^[[:space:]]*location[[:space:]]+\/api\//) {
found_api = 1
}
if (found_api && $0 ~ /^[[:space:]]*\}/) {
print ""
print " location /bus/ {"
print " proxy_pass http://backend:3100/bus/;"
print " proxy_set_header Host $http_host;"
print " proxy_set_header X-Real-IP $remote_addr;"
print " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
print " proxy_connect_timeout 10s;"
print " proxy_send_timeout 650s;"
print " proxy_read_timeout 650s;"
print " }"
found_api = 0
}
}
' "$nginx_conf" > "$tmp_file"
mv "$tmp_file" "$nginx_conf"
}
need_root() {
[[ $EUID -eq 0 ]] || die "Запускать только от root"
}
@@ -209,6 +296,12 @@ log "Compose команда: ${COMPOSE_CMD[*]}"
#################################
check_and_fix_credentials || true
#################################
# FIX NGINX CONFIG
#################################
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
#################################
# REBUILD BACKEND
#################################
@@ -222,6 +315,9 @@ fi
log "Пересоздание контейнеров..."
"${COMPOSE_CMD[@]}" up -d
# Перезапуск frontend для применения nginx.conf
"${COMPOSE_CMD[@]}" restart frontend
# Проверка: все ли контейнеры запустились
if ! check_containers_running 60; then
error "Не удалось запустить контейнеры. Логи:"