пва
This commit is contained in:
Binary file not shown.
+40
-1
@@ -19,13 +19,14 @@ import { prisma } from './lib/prisma.js'
|
||||
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||
import { registerAuth } from './plugins/auth.js'
|
||||
import { registerIpGate } from './plugins/ip-gate.js'
|
||||
import { registerSecurityHeaders } from './plugins/security-headers.js'
|
||||
import { registerApiRoutes } from './routes/api.js'
|
||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||
import { registerSseRoutes } from './routes/sse.js'
|
||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
||||
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
||||
import { registerUserCartRoutes } from './routes/user-cart.js'
|
||||
import { registerSseRoutes } from './routes/sse.js'
|
||||
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||
@@ -48,6 +49,44 @@ await fastify.register(cors, {
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
await registerSecurityHeaders(fastify)
|
||||
|
||||
fastify.get('/health', async () => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
return { status: 'ok', database: 'connected', uptime: process.uptime() }
|
||||
} catch {
|
||||
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
|
||||
}
|
||||
})
|
||||
|
||||
fastify.setErrorHandler(function errorHandler(error, request, reply) {
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
if (error.validation) {
|
||||
return reply.code(400).send({
|
||||
error: 'Ошибка валидации',
|
||||
details: isProd ? undefined : error.validation,
|
||||
})
|
||||
}
|
||||
|
||||
if (error.code === 'FST_ERR_VALIDATION') {
|
||||
return reply.code(400).send({ error: 'Неверный формат запроса' })
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.message || 'Произошла ошибка',
|
||||
})
|
||||
}
|
||||
|
||||
request.log.error(error)
|
||||
|
||||
return reply.code(500).send({
|
||||
error: isProd ? 'Внутренняя ошибка сервера' : error.message,
|
||||
})
|
||||
})
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createAvatar } from '@dicebear/core'
|
||||
import { avataaars } from '@dicebear/collection'
|
||||
import { createAvatar } from '@dicebear/core'
|
||||
|
||||
const DEFAULT_STYLE = avataaars
|
||||
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
const windows = new Map()
|
||||
|
||||
const MAX_ATTEMPTS = 5
|
||||
const WINDOW_MS = 60_000
|
||||
const DEFAULT_MAX_ATTEMPTS = 5
|
||||
const DEFAULT_WINDOW_MS = 60_000
|
||||
|
||||
// Per-endpoint rate limits
|
||||
const LIMITS = {
|
||||
login: { maxAttempts: 5, windowMs: 60_000 },
|
||||
codeRequest: { maxAttempts: 3, windowMs: 60_000 },
|
||||
codeVerify: { maxAttempts: 5, windowMs: 60_000 },
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [ip, entry] of windows) {
|
||||
if (now - entry.start > WINDOW_MS) windows.delete(ip)
|
||||
if (now - entry.start > DEFAULT_WINDOW_MS) windows.delete(ip)
|
||||
}
|
||||
}, 5 * 60_000).unref()
|
||||
|
||||
export function checkLoginRateLimit(ip) {
|
||||
function getKey(ip, scope) {
|
||||
return `${scope}:${ip}`
|
||||
}
|
||||
|
||||
function checkRateLimit(ip, scope) {
|
||||
const limit = LIMITS[scope] || { maxAttempts: DEFAULT_MAX_ATTEMPTS, windowMs: DEFAULT_WINDOW_MS }
|
||||
const key = getKey(ip, scope)
|
||||
const now = Date.now()
|
||||
const entry = windows.get(ip)
|
||||
if (!entry || now - entry.start > WINDOW_MS) {
|
||||
windows.set(ip, { start: now, count: 1 })
|
||||
const entry = windows.get(key)
|
||||
if (!entry || now - entry.start > limit.windowMs) {
|
||||
windows.set(key, { start: now, count: 1 })
|
||||
return { allowed: true }
|
||||
}
|
||||
entry.count += 1
|
||||
if (entry.count > MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000)
|
||||
if (entry.count > limit.maxAttempts) {
|
||||
const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000)
|
||||
return { allowed: false, retryAfter }
|
||||
}
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
export function checkLoginRateLimit(ip) {
|
||||
return checkRateLimit(ip, 'login')
|
||||
}
|
||||
|
||||
export function checkCodeRequestRateLimit(ip) {
|
||||
return checkRateLimit(ip, 'codeRequest')
|
||||
}
|
||||
|
||||
export function checkCodeVerifyRateLimit(ip) {
|
||||
return checkRateLimit(ip, 'codeVerify')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export async function registerSecurityHeaders(fastify) {
|
||||
fastify.addHook('onSend', async (request, reply) => {
|
||||
reply.header('X-Content-Type-Options', 'nosniff')
|
||||
reply.header('X-Frame-Options', 'DENY')
|
||||
reply.header('X-XSS-Protection', '0')
|
||||
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||
'frame-src https://*.yookassa.ru',
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; ')
|
||||
|
||||
reply.header('Content-Security-Policy', cspDirectives)
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
describe('OAuth — User model fields', () => {
|
||||
const createdIds = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdIds) {
|
||||
try {
|
||||
await prisma.user.delete({ where: { id } })
|
||||
} catch {
|
||||
// Already deleted by another test or cleanup — ignore
|
||||
}
|
||||
}
|
||||
createdIds.length = 0
|
||||
})
|
||||
|
||||
it('stores displayName and avatar fields on User model', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -11,10 +24,10 @@ describe('OAuth — User model fields', () => {
|
||||
},
|
||||
})
|
||||
|
||||
createdIds.push(user.id)
|
||||
|
||||
expect(user.displayName).toBe('Test User')
|
||||
expect(user.avatar).toBe('https://example.com/avatar.jpg')
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } })
|
||||
})
|
||||
|
||||
it('allows nullable fields', async () => {
|
||||
@@ -24,9 +37,9 @@ describe('OAuth — User model fields', () => {
|
||||
},
|
||||
})
|
||||
|
||||
createdIds.push(user.id)
|
||||
|
||||
expect(user.displayName).toBeNull()
|
||||
expect(user.avatar).toBeNull()
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Fastify from 'fastify'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
|
||||
|
||||
@@ -84,7 +84,12 @@ describe('buildSseListeners', () => {
|
||||
|
||||
it('forwards order:statusChanged to matching userId', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' })
|
||||
eventBus.emit('order:statusChanged', {
|
||||
orderId: 'o1',
|
||||
userId: 'user-1',
|
||||
oldStatus: 'PENDING_PAYMENT',
|
||||
newStatus: 'PAID',
|
||||
})
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
||||
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../lib/auth.js'
|
||||
import { generateAvatar } from '../lib/generate-avatar.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { checkLoginRateLimit } from '../lib/rate-limit.js'
|
||||
import { checkCodeRequestRateLimit, checkCodeVerifyRateLimit, checkLoginRateLimit } from '../lib/rate-limit.js'
|
||||
|
||||
export function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
@@ -30,6 +30,15 @@ export async function registerAuthRoutes(fastify) {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
|
||||
const ip = request.ip
|
||||
const rate = checkCodeRequestRateLimit(ip)
|
||||
if (!rate.allowed) {
|
||||
return reply
|
||||
.code(429)
|
||||
.header('Retry-After', String(rate.retryAfter))
|
||||
.send({ error: `Слишком много запросов. Попробуйте через ${rate.retryAfter} сек.` })
|
||||
}
|
||||
|
||||
const code = await issueEmailCode({ email, purpose: 'login' })
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||
@@ -50,6 +59,15 @@ export async function registerAuthRoutes(fastify) {
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
|
||||
const ip = request.ip
|
||||
const rate = checkCodeVerifyRateLimit(ip)
|
||||
if (!rate.allowed) {
|
||||
return reply
|
||||
.code(429)
|
||||
.header('Retry-After', String(rate.retryAfter))
|
||||
.send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` })
|
||||
}
|
||||
|
||||
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user