fix(rate-limit): per-username 5/мин + per-IP 30/мин — brute-force на конкретный аккаунт ловится, CI/NAT не страдают
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

Регрессия после 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-04 17:20:28 +05:00
parent ba54155225
commit 9d48ca6483
4 changed files with 299 additions and 26 deletions

View file

@ -6,19 +6,27 @@ namespace foodmarket.Api.Infrastructure.RateLimiting;
/// <summary>Anti-brute-force на чувствительных auth-эндпоинтах: подбор пароля
/// на <c>/connect/token</c> и спам-регистрация на <c>/api/auth/signup</c>.
///
/// Два sliding-window лимита на IP, оба должны пропустить запрос (chained):
/// 5/мин (резкий всплеск) и 20/час (медленный перебор). Применяется
/// глобально, но no-op для всех путей кроме двух auth-эндпоинтов — так
/// нет нужды цеплять policy на каждый minimal-API/контроллер по отдельности,
/// а двойное окно (которое per-endpoint policy выразить не может) работает
/// через <see cref="PartitionedRateLimiter.CreateChained{TResource}"/>.</summary>
/// Стратегия — два независимых партишенера, оба chained-window (1min+1hour):
/// <list type="bullet">
/// <item><b>Per-username</b> (только <c>/connect/token</c>) — тугой лимит
/// <c>5/мин</c>, <c>20/час</c> на один account. Защищает от перебора пароля
/// к конкретному логину независимо от IP атакующего.</item>
/// <item><b>Per-IP</b> — широкий лимит <c>30/мин</c>, <c>200/час</c> на все
/// auth-эндпоинты с одного IP. Защищает от спам-регистрации и от того,
/// что один IP откуда-то перебирает тысячи аккаунтов.</item>
/// </list>
/// e2e/CI поднимают свежие org'и сериально с одного IP → IP-лимит широкий,
/// чтобы их не уронить; username-лимит работает per-account и не съедается
/// чужими логинами.</summary>
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<int?>("PerMinute");
var legacyPerHour = section.GetValue<int?>("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<HttpContext, string>(
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
@ -62,19 +77,37 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
return services;
}
private static PartitionedRateLimiter<HttpContext> BuildWindow(int permitLimit, TimeSpan window) =>
/// <summary>Per-username бакет, только для <c>/connect/token</c>. Username
/// предварительно сложен в HttpContext.Items pre-buffer middleware'ом
/// (<see cref="UseAuthFormUsernameKey"/>). Если ключа нет (signup или иной
/// путь) — no-op limiter.</summary>
private static PartitionedRateLimiter<HttpContext> BuildUserWindow(int permitLimit, TimeSpan window) =>
PartitionedRateLimiter.Create<HttpContext, string>(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,
});
});
/// <summary>Per-IP бакет для всех auth-эндпоинтов. Защита от DDoS и
/// от того, что один IP перебирает тысячи аккаунтов.</summary>
private static PartitionedRateLimiter<HttpContext> BuildIpWindow(int permitLimit, TimeSpan window) =>
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
{
var bucket = ResolveAuthBucket(ctx);
if (bucket is null)
{
return RateLimitPartition.GetNoLimiter(NoLimitPartition);
}
// Отдельный бакет на каждый эндпоинт: успешная регистрация не должна
// съедать лимит логинов (и наоборот). Ключ = "<эндпоинт>:<IP>".
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;
/// <summary>Перед UseRateLimiter() пик POST /connect/token form-body, чтобы
/// per-user бакет знал кому считать. Body буферизуется через
/// <see cref="HttpRequest.EnableBuffering()"/>, читается, перематывается
/// — последующие middleware получают целое тело без перетруска.
///
/// Размер тела ограничен 4 КБ — параметров OAuth password grant
/// (grant_type/username/password/client_id/scope) с запасом хватает.</summary>
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();
});
}
}

View file

@ -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 перекладываем

View file

@ -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:<clientSaleId>').toBe(1)
expect(posOnes[0].id).toBe(serverSaleId)
await ctx.dispose()
})
})

View file

@ -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()
})
})