From 9d48ca64830733fce3d8752aa8f373c539da1e20 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 4 Jun 2026 17:20:28 +0500 Subject: [PATCH] =?UTF-8?q?fix(rate-limit):=20per-username=205/=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=20+=20per-IP=2030/=D0=BC=D0=B8=D0=BD=20=E2=80=94?= =?UTF-8?q?=20brute-force=20=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D1=82=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82=D1=81=D1=8F,=20CI?= =?UTF-8?q?/NAT=20=D0=BD=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=8E=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регрессия после ba54155: rate-limit 5/мин per-IP сваливал stage e2e (75 тестов с одного IP, каждый по /connect/token при apiSignup → 22+ из них падали на 429 после 5-й попытки). Per-IP лимит был неправильной осью защиты. Новая стратегия в AuthRateLimiterExtensions: - Per-username (только /connect/token): 5/мин, 20/час. Защищает от перебора пароля к конкретному account независимо от IP атакующего. Username вытаскивается form-body peek-middleware'ом перед UseRateLimiter (EnableBuffering + ручной парс x-www-form-urlencoded, тело ≤4KB). - Per-IP (token+signup): 30/мин, 200/час. Защищает от спам-регистрации и от 1-IP-перебирает-тысячи-аккаунтов сценария. - Back-compat: legacy RateLimiting:PerMinute/PerHour мапятся в IP-лимит. Проверено через https://test.admin.food-market.kz: - 6 неверных попыток на ОДНУ учётку → 6-я → 429 ✓ - 8 неверных попыток на РАЗНЫЕ учётки с того же IP → все 400 (IP-лимит 30/мин не достигнут) ✓ Также добавлены verify-spec'и stage-ui-verify-pos-sync (п.14) и stage-ui-verify-stock-race (п.15). Co-Authored-By: Claude Opus 4.7 --- .../RateLimiting/AuthRateLimiterExtensions.cs | 128 ++++++++++++++---- src/food-market.api/Program.cs | 5 +- .../stage-ui-verify-pos-sync.spec.ts | 97 +++++++++++++ .../stage-ui-verify-stock-race.spec.ts | 95 +++++++++++++ 4 files changed, 299 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts create mode 100644 tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts diff --git a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs index c82791a..9f605cd 100644 --- a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs +++ b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs @@ -6,19 +6,27 @@ namespace foodmarket.Api.Infrastructure.RateLimiting; /// Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля /// на /connect/token и спам-регистрация на /api/auth/signup. /// -/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained): -/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется -/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так -/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности, -/// а двойное окно (которое per-endpoint policy выразить не может) работает -/// через . +/// Стратегия — два независимых партишенера, оба chained-window (1min+1hour): +/// +/// Per-username (только /connect/token) — тугой лимит +/// 5/мин, 20/час на один account. Защищает от перебора пароля +/// к конкретному логину независимо от IP атакующего. +/// Per-IP — широкий лимит 30/мин, 200/час на все +/// auth-эндпоинты с одного IP. Защищает от спам-регистрации и от того, +/// что один IP откуда-то перебирает тысячи аккаунтов. +/// +/// e2e/CI поднимают свежие org'и сериально с одного IP → IP-лимит широкий, +/// чтобы их не уронить; username-лимит работает per-account и не съедается +/// чужими логинами. public static class AuthRateLimiterExtensions { - // Дефолты. Переопределяются конфигом RateLimiting:* (см. ниже) — например, - // интеграционные тесты с общим loopback-IP поднимают лимит/выключают его, - // чтобы повторные логины не упирались в 429. - public const int DefaultPerMinutePermitLimit = 5; - public const int DefaultPerHourPermitLimit = 20; + // Дефолты per-username (тугой) — переопределяются RateLimiting:PerUserPerMinute/Hour. + public const int DefaultPerUserPerMinute = 5; + public const int DefaultPerUserPerHour = 20; + + // Дефолты per-IP (широкий, для CI/NAT) — переопределяются RateLimiting:PerIpPerMinute/Hour. + public const int DefaultPerIpPerMinute = 30; + public const int DefaultPerIpPerHour = 200; private const string NoLimitPartition = "__not-an-auth-endpoint"; @@ -26,18 +34,25 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser { var section = config.GetSection("RateLimiting"); var enabled = section.GetValue("Enabled", true); - var perMinute = section.GetValue("PerMinute", DefaultPerMinutePermitLimit); - var perHour = section.GetValue("PerHour", DefaultPerHourPermitLimit); + // Back-compat: PerMinute/PerHour действовали раньше как IP-лимит. Если кто-то + // их выставил — продолжают работать как IP-лимит, чтобы не сломать прод-конфиг. + var legacyPerMinute = section.GetValue("PerMinute"); + var legacyPerHour = section.GetValue("PerHour"); + var perUserMinute = section.GetValue("PerUserPerMinute", DefaultPerUserPerMinute); + var perUserHour = section.GetValue("PerUserPerHour", DefaultPerUserPerHour); + var perIpMinute = legacyPerMinute ?? section.GetValue("PerIpPerMinute", DefaultPerIpPerMinute); + var perIpHour = legacyPerHour ?? section.GetValue("PerIpPerHour", DefaultPerIpPerHour); services.AddRateLimiter(options => { - // По умолчанию RateLimiter отдаёт 503 — нам нужен честный 429. options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = enabled ? PartitionedRateLimiter.CreateChained( - BuildWindow(perMinute, TimeSpan.FromMinutes(1)), - BuildWindow(perHour, TimeSpan.FromHours(1))) + BuildUserWindow(perUserMinute, TimeSpan.FromMinutes(1)), + BuildUserWindow(perUserHour, TimeSpan.FromHours(1)), + BuildIpWindow(perIpMinute, TimeSpan.FromMinutes(1)), + BuildIpWindow(perIpHour, TimeSpan.FromHours(1))) : PartitionedRateLimiter.Create( _ => RateLimitPartition.GetNoLimiter(NoLimitPartition)); @@ -62,19 +77,37 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser return services; } - private static PartitionedRateLimiter BuildWindow(int permitLimit, TimeSpan window) => + /// Per-username бакет, только для /connect/token. Username + /// предварительно сложен в HttpContext.Items pre-buffer middleware'ом + /// (). Если ключа нет (signup или иной + /// путь) — no-op limiter. + private static PartitionedRateLimiter BuildUserWindow(int permitLimit, TimeSpan window) => + PartitionedRateLimiter.Create(ctx => + { + if (ResolveAuthBucket(ctx) != "token") return RateLimitPartition.GetNoLimiter(NoLimitPartition); + var user = ctx.Items[FormUsernameKeyItem] as string; + if (string.IsNullOrEmpty(user)) return RateLimitPartition.GetNoLimiter(NoLimitPartition); + return RateLimitPartition.GetSlidingWindowLimiter( + $"user:{user.ToLowerInvariant()}", + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = window, + SegmentsPerWindow = 6, + QueueLimit = 0, + AutoReplenishment = true, + }); + }); + + /// Per-IP бакет для всех auth-эндпоинтов. Защита от DDoS и + /// от того, что один IP перебирает тысячи аккаунтов. + private static PartitionedRateLimiter BuildIpWindow(int permitLimit, TimeSpan window) => PartitionedRateLimiter.Create(ctx => { var bucket = ResolveAuthBucket(ctx); - if (bucket is null) - { - return RateLimitPartition.GetNoLimiter(NoLimitPartition); - } - - // Отдельный бакет на каждый эндпоинт: успешная регистрация не должна - // съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:". + if (bucket is null) return RateLimitPartition.GetNoLimiter(NoLimitPartition); return RateLimitPartition.GetSlidingWindowLimiter( - $"{bucket}:{ResolveClientKey(ctx)}", + $"ip:{bucket}:{ResolveClientKey(ctx)}", _ => new SlidingWindowRateLimiterOptions { PermitLimit = permitLimit, @@ -110,4 +143,49 @@ private static string ResolveClientKey(HttpContext ctx) } return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; } + + internal const string FormUsernameKeyItem = "auth.rate.username"; + private const int MaxBodyPeekBytes = 4096; + + /// Перед UseRateLimiter() пик POST /connect/token form-body, чтобы + /// per-user бакет знал кому считать. Body буферизуется через + /// , читается, перематывается + /// — последующие middleware получают целое тело без перетруска. + /// + /// Размер тела ограничен 4 КБ — параметров OAuth password grant + /// (grant_type/username/password/client_id/scope) с запасом хватает. + public static IApplicationBuilder UseAuthFormUsernameKey(this IApplicationBuilder app) + { + return app.Use(async (ctx, next) => + { + if (HttpMethods.IsPost(ctx.Request.Method) + && ctx.Request.Path.StartsWithSegments("/connect/token", StringComparison.OrdinalIgnoreCase) + && ctx.Request.ContentType is { } ct + && ct.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) + && (ctx.Request.ContentLength ?? 0) > 0 + && (ctx.Request.ContentLength ?? 0) <= MaxBodyPeekBytes) + { + ctx.Request.EnableBuffering(); + var pos = ctx.Request.Body.Position; + using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + ctx.Request.Body.Position = pos; + + // Ручной парсер чтобы не дёргать FormReader (он триггерит form-read, + // который OpenIddict ниже по конвейеру тоже захочет сделать — оставляем + // ему чистую копию). URL-encoded формат: key=value&key=value. + foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var eq = pair.IndexOf('='); + if (eq <= 0) continue; + var key = pair[..eq]; + if (!string.Equals(key, "username", StringComparison.OrdinalIgnoreCase)) continue; + var raw = pair[(eq + 1)..]; + ctx.Items[FormUsernameKeyItem] = Uri.UnescapeDataString(raw); + break; + } + } + await next(); + }); + } } diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 4f2d5ce..908ed9c 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -346,7 +346,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings). app.UseHttpMetrics(); // До аутентификации: лимитируем перебор пароля ещё на входе, не доводя - // до проверки credential'ов в БД. + // до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token, + // чтобы per-username бакет имел ключ — без этого мы можем считать только по IP, + // и любая NAT/CI-машина роняется первой. + app.UseAuthFormUsernameKey(); app.UseRateLimiter(); // SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому // токен приходит как `?access_token=...`. До UseAuthentication перекладываем diff --git a/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts b/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts new file mode 100644 index 0000000..1a5bf21 --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-verify-pos-sync.spec.ts @@ -0,0 +1,97 @@ +/** + * Verify-Sprint п.14: POS Sync API с idempotency-key через домен. + * Сценарий: + * - signup новой org → bootstrap (склад, товар, остаток через Enter). + * - POST /api/pos/v1/sales с idempotencyKey K1 → 200, ServerSaleId != Empty. + * - повтор того же body+K1 → 200, replayedFromCache=true, ServerSaleId === тот же. + * - psql проверяет, что в retail_sales ровно 1 строка с notes начинающимся на 'pos:'. + */ +import { test, expect, request as apiRequest } from '@playwright/test' +import { apiSignup } from '../lib/ui.js' +import { generateEan13 } from '../lib/barcode.js' +import { randomUUID } from 'node:crypto' + +const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' + +test.describe('Verify POS Sync idempotency', () => { + test('V-14 POST /api/pos/v1/sales дважды с одним IdempotencyKey → один RetailSale, replayedFromCache=true', async () => { + test.setTimeout(120_000) + const sess = await apiSignup('v14') + const ctx = await apiRequest.newContext({ + baseURL: BASE, ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, + }) + + // Bootstrap: склад, товар, цена, остаток через Enter. + const stores = await ctx.get('/api/inventory/stores') + expect(stores.status()).toBe(200) + const storeId = (await stores.json()).items[0]?.id + ?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id + const units = await ctx.get('/api/catalog/units-of-measure') + const unitId = (await units.json()).items[0].id + const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id + const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items + const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id + const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()) + const groupId = groupsRes.items[0]?.id + ?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id + const prod = await ctx.post('/api/catalog/products', { + data: { + name: `POSverify ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId, + vat: 12, vatEnabled: true, + barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }], + prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }], + }, + }) + expect(prod.status()).toBe(201) + const productId = (await prod.json()).id + + // Enter (приход без поставщика) на 10 штук, чтобы был остаток для продажи. + const ent = await ctx.post('/api/inventory/enter', { + data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 10, costPrice: 200 }] }, + }) + expect(ent.status()).toBe(201) + const entId = (await ent.json()).id + expect((await ctx.post(`/api/inventory/enter/${entId}/post`)).status()).toBe(200) + + // POS batch — 1 продажа. + const idempKey = randomUUID() + const clientSaleId = randomUUID() + const body = { + idempotencyKey: idempKey, + sales: [{ + clientSaleId, storeId, + soldAt: new Date().toISOString(), + paymentMethod: 1, // Cash + lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }], + paidCash: 500, paidCard: 0, change: 0, + }], + } + + const r1 = await ctx.post(`/api/pos/v1/sales?storeId=${storeId}`, { data: body }) + expect(r1.status(), 'first POS batch').toBe(200) + const b1 = await r1.json() as { accepted: Array<{ serverSaleId: string }>, replayedFromCache: boolean } + expect(b1.replayedFromCache).toBe(false) + expect(b1.accepted.length).toBe(1) + const serverSaleId = b1.accepted[0].serverSaleId + expect(serverSaleId).toMatch(/^[0-9a-f-]{36}$/i) + + // Повтор того же body+idempotencyKey. + const r2 = await ctx.post(`/api/pos/v1/sales?storeId=${storeId}`, { data: body }) + expect(r2.status(), 'second POS batch (replay)').toBe(200) + const b2 = await r2.json() as { accepted: Array<{ serverSaleId: string }>, replayedFromCache: boolean } + expect(b2.replayedFromCache, 'replayed flag').toBe(true) + expect(b2.accepted[0].serverSaleId, 'same serverSaleId').toBe(serverSaleId) + + // Проверяем через API: GET /api/sales/retail должен показать ровно 1 чек + // в этой org (помимо seed-данных, у свежей org 0 sales до нашего батча). + const list = await ctx.get('/api/sales/retail?pageSize=200') + expect(list.status()).toBe(200) + const items = (await list.json() as { items: Array<{ id: string, notes: string | null }> }).items + const posOnes = items.filter(s => s.notes != null && s.notes.startsWith(`pos:${clientSaleId.replace(/-/g, '')}`)) + expect(posOnes.length, 'ровно 1 retail_sale с маркером pos:').toBe(1) + expect(posOnes[0].id).toBe(serverSaleId) + + await ctx.dispose() + }) +}) diff --git a/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts b/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts new file mode 100644 index 0000000..e7ad5fc --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-verify-stock-race.spec.ts @@ -0,0 +1,95 @@ +/** + * Verify-Sprint п.15: stock-инвариант под конкуренцией. + * + * 5 параллельных POST /api/sales/retail/{id}/post по 1 единице товара, + * остаток=3. Ожидание: ровно 3 → 200, 2 → 4xx (409 conflict либо 400 oversell). + * После dust settles: Stock = 0, движения = -3. + * + * Использует Serializable + xmin row-version (UseXminAsConcurrencyToken) — если + * один из параллельных post проходит, остальные должны получить DbUpdateConcurrencyException + * → API возвращает 409 либо retry'ит и натыкается на oversell. + */ +import { test, expect, request as apiRequest } from '@playwright/test' +import { apiSignup } from '../lib/ui.js' +import { generateEan13 } from '../lib/barcode.js' + +const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' + +test.describe('Verify stock race', () => { + test('V-15 5 параллельных Post(1шт) на остаток=3 → 3×200, 2×4xx, Stock=0', async () => { + test.setTimeout(180_000) + const sess = await apiSignup('v15') + const ctx = await apiRequest.newContext({ + baseURL: BASE, ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, + }) + + // Bootstrap. + const stores = await ctx.get('/api/inventory/stores') + const storeId = (await stores.json()).items[0]?.id + ?? (await (await ctx.post('/api/inventory/stores', { data: { name: 'Главный', address: 'Алматы' } })).json()).id + const units = await ctx.get('/api/catalog/units-of-measure') + const unitId = (await units.json()).items[0].id + const cur = (await (await ctx.get('/api/catalog/currencies?pageSize=200')).json()).items.find((c: { code: string }) => c.code === 'KZT').id + const ptList = (await (await ctx.get('/api/catalog/price-types')).json()).items + const pt = (ptList.find((p: { isRetail: boolean }) => p.isRetail) ?? ptList[0]).id + const groupsRes = (await (await ctx.get('/api/catalog/product-groups?pageSize=10')).json()) + const groupId = groupsRes.items[0]?.id + ?? (await (await ctx.post('/api/catalog/product-groups', { data: { name: 'G', parentId: null } })).json()).id + const prod = await ctx.post('/api/catalog/products', { + data: { + name: `RaceProd ${Date.now()}`, unitOfMeasureId: unitId, productGroupId: groupId, + vat: 12, vatEnabled: true, + barcodes: [{ code: generateEan13(7), type: 1, isPrimary: true }], + prices: [{ priceTypeId: pt, currencyId: cur, amount: 500 }], + }, + }) + expect(prod.status()).toBe(201) + const productId = (await prod.json()).id + + // Enter: 3 штуки в остаток. + const ent = await ctx.post('/api/inventory/enter', { + data: { storeId, date: new Date().toISOString(), lines: [{ productId, qty: 3, costPrice: 200 }] }, + }) + const entId = (await ent.json()).id + expect((await ctx.post(`/api/inventory/enter/${entId}/post`)).status()).toBe(200) + + // Создаём 5 RetailSale-черновиков с qty=1 каждый. + const draftIds: string[] = [] + for (let i = 0; i < 5; i++) { + const r = await ctx.post('/api/sales/retail', { + data: { + storeId, date: new Date().toISOString(), + lines: [{ productId, qty: 1, unitPrice: 500, vat: 12 }], + paidCash: 500, paidCard: 0, change: 0, + }, + }) + expect(r.status(), `draft ${i}`).toBe(201) + draftIds.push((await r.json()).id) + } + + // Параллельный Post всех 5. + const postResults = await Promise.all(draftIds.map(id => + ctx.post(`/api/sales/retail/${id}/post`).then(r => ({ id, status: r.status() })) + )) + const ok = postResults.filter(r => r.status === 200) + const failed = postResults.filter(r => r.status >= 400) + test.info().annotations.push({ type: 'post-results', description: JSON.stringify(postResults) }) + + expect(ok.length, 'ровно 3 успешных Post').toBe(3) + expect(failed.length, 'ровно 2 неуспешных Post').toBe(2) + for (const f of failed) { + expect(f.status, `failed Post должен быть 4xx (409 conflict / 400 oversell)`).toBeGreaterThanOrEqual(400) + expect(f.status, `failed Post должен быть < 500`).toBeLessThan(500) + } + + // Stock в этой org для productId = 0. + const stocks = await ctx.get(`/api/inventory/stock?productId=${productId}&storeId=${storeId}`) + expect(stocks.status()).toBe(200) + const stockRows = (await stocks.json() as { items: Array<{ quantity: number }> }).items + const qty = stockRows.length ? stockRows[0].quantity : 0 + expect(qty, 'остаток = 0 после трёх успешных продаж').toBe(0) + + await ctx.dispose() + }) +})