Расширить детектор подозрительной активности
Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
This commit is contained in:
+1
-1
@@ -45,7 +45,7 @@
|
||||
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
||||
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
||||
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
||||
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
||||
| BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE |
|
||||
|
||||
## 5. Технический долг (качество/архитектура)
|
||||
|
||||
|
||||
@@ -1387,7 +1387,10 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
|
||||
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
|
||||
(общий `RuntimeDepthsCollector`, без дублей SQL).
|
||||
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
|
||||
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; пороги — константы).
|
||||
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных
|
||||
логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт
|
||||
`SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429
|
||||
rate limiter (`rate_limit`) и блокировке входа (`login_blocked`).
|
||||
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
|
||||
|
||||
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
|
||||
|
||||
@@ -46,12 +46,15 @@ public static class AuthEndpoints
|
||||
IOptions<CookieOptions> cookieOptions,
|
||||
HttpContext context,
|
||||
CancellationToken ct,
|
||||
LoginAttemptGuard loginAttemptGuard)
|
||||
LoginAttemptGuard loginAttemptGuard,
|
||||
SuspiciousActivityReporter suspicious)
|
||||
{
|
||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||
|
||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||
{
|
||||
// Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
|
||||
suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
|
||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,13 @@ public static class RateLimitPolicies
|
||||
|
||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
HttpContext http = context.HttpContext;
|
||||
// Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима).
|
||||
SuspiciousActivityReporter? reporter = http.RequestServices.GetService<SuspiciousActivityReporter>();
|
||||
string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http);
|
||||
reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor);
|
||||
|
||||
http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ builder.Services.AddSingleton(rateLimitOptions);
|
||||
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
|
||||
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
|
||||
builder.Services.AddScoped<LoginAttemptGuard>();
|
||||
builder.Services.AddScoped<SuspiciousActivityReporter>();
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddDealRateLimiter(rateLimitOptions);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics;
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог).
|
||||
/// </summary>
|
||||
/// <param name="logger">Логгер событий.</param>
|
||||
public sealed class SuspiciousActivityReporter(ILogger<SuspiciousActivityReporter> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Вид: сработал rate limiter (слишком много запросов).
|
||||
/// </summary>
|
||||
public const string RateLimitKind = "rate_limit";
|
||||
|
||||
/// <summary>
|
||||
/// Вид: вход заблокирован после серии неудачных попыток.
|
||||
/// </summary>
|
||||
public const string LoginBlockedKind = "login_blocked";
|
||||
|
||||
// Актор неизвестен (нет ни тенанта, ни IP).
|
||||
private const string UnknownActor = "-";
|
||||
|
||||
/// <summary>
|
||||
/// Учитывает событие подозрительной активности.
|
||||
/// </summary>
|
||||
/// <param name="kind">Вид события (константы класса).</param>
|
||||
/// <param name="actor">Актор: id тенанта либо IP; null/пусто — «-».</param>
|
||||
public void Report(string kind, string? actor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor;
|
||||
DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } });
|
||||
logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService
|
||||
/// </summary>
|
||||
public const int AuthFailuresPerTenantThreshold = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
|
||||
/// </summary>
|
||||
public const int DistinctLoginsPerIpThreshold = 5;
|
||||
|
||||
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
||||
private const int HighSeverityMultiplier = 2;
|
||||
|
||||
@@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService
|
||||
/// </summary>
|
||||
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Правило: перебор разных логинов с одного IP.
|
||||
/// </summary>
|
||||
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
|
||||
|
||||
/// <summary>
|
||||
/// Уровень находки
|
||||
/// </summary>
|
||||
@@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService
|
||||
AddFailedLoginsPerLogin(records, findings);
|
||||
AddManyIpsPerActor(records, findings);
|
||||
AddAuthFailuresPerTenant(records, findings);
|
||||
AddDistinctLoginsPerIp(records, findings);
|
||||
|
||||
findings.Sort(static (left, right) =>
|
||||
{
|
||||
@@ -255,6 +266,48 @@ public sealed class SuspiciousActivityService
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «перебор разных логинов с одного IP» (credential stuffing).
|
||||
// records: Записи окна.
|
||||
// findings: Накопитель находок.
|
||||
private static void AddDistinctLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||
{
|
||||
var loginsByIp = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (AuditRecordDto record in records)
|
||||
{
|
||||
if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? login = ExtractLogin(record);
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!loginsByIp.TryGetValue(record.Ip, out HashSet<string>? logins))
|
||||
{
|
||||
logins = new HashSet<string>(StringComparer.Ordinal);
|
||||
loginsByIp[record.Ip] = logins;
|
||||
}
|
||||
|
||||
logins.Add(login);
|
||||
}
|
||||
|
||||
foreach ((string ip, HashSet<string> logins) in loginsByIp)
|
||||
{
|
||||
if (logins.Count >= DistinctLoginsPerIpThreshold)
|
||||
{
|
||||
findings.Add(new SuspiciousFindingDto(
|
||||
KindDistinctLoginsPerIp,
|
||||
SeverityFor(logins.Count, DistinctLoginsPerIpThreshold),
|
||||
ip,
|
||||
logins.Count,
|
||||
$"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
|
||||
// records: Записи окна.
|
||||
// predicate: Отбор записей правила.
|
||||
|
||||
@@ -44,6 +44,11 @@ public static class DealMetrics
|
||||
/// </summary>
|
||||
public const string TenantTagName = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метки вида события безопасности
|
||||
/// </summary>
|
||||
public const string SecurityKindTagName = "kind";
|
||||
|
||||
/// <summary>
|
||||
/// Имя метрики доли израсходованного ИИ-бюджета
|
||||
/// </summary>
|
||||
@@ -82,6 +87,12 @@ public static class DealMetrics
|
||||
public static readonly Counter<long> AuditEvents =
|
||||
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
|
||||
|
||||
/// <summary>
|
||||
/// События подозрительной активности по видам
|
||||
/// </summary>
|
||||
public static readonly Counter<long> SecurityEvents =
|
||||
Meter.CreateCounter<long>("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked).");
|
||||
|
||||
/// <summary>
|
||||
/// Суммарная глубина очереди пайплайна
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Deal.Api.Services;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Tests.Unit.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-тесты учёта подозрительной активности
|
||||
/// </summary>
|
||||
public sealed class SuspiciousActivityReporterTests
|
||||
{
|
||||
// Актор сценариев.
|
||||
private const string Actor = "tenant-1";
|
||||
|
||||
/// <summary>
|
||||
/// Report инкрементит deal.security.suspicious с меткой kind
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Report_IncrementsSecurityCounter_WithKindTag()
|
||||
{
|
||||
using var capture = new SecurityEventCapture();
|
||||
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
|
||||
|
||||
reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor);
|
||||
|
||||
Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind));
|
||||
(string? Kind, long Value) measurement = Assert.Single(
|
||||
capture.Snapshot(),
|
||||
item => item.Kind == SuspiciousActivityReporter.RateLimitKind);
|
||||
Assert.Equal(1, measurement.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report пишет предупреждение с видом и актором
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Report_WritesWarningWithKindAndActor()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var reporter = new SuspiciousActivityReporter(logger);
|
||||
|
||||
reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor);
|
||||
|
||||
LogEntry entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message);
|
||||
Assert.Contains($"actor={Actor}", entry.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// null/пусто/пробелы actor → в логе «-»
|
||||
/// </summary>
|
||||
/// <param name="actor">Пустой актор сценария.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Report_UnknownActor_WritesDash(string? actor)
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var reporter = new SuspiciousActivityReporter(logger);
|
||||
|
||||
reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor);
|
||||
|
||||
LogEntry entry = Assert.Single(logger.Entries);
|
||||
Assert.Contains("actor=-", entry.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пустой/пробельный kind → ArgumentException
|
||||
/// </summary>
|
||||
/// <param name="kind">Пустой вид события сценария.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Report_EmptyOrWhitespaceKind_Throws(string? kind)
|
||||
{
|
||||
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
|
||||
|
||||
Assert.ThrowsAny<ArgumentException>(() => reporter.Report(kind!, Actor));
|
||||
}
|
||||
|
||||
// In-memory логгер: копит записи с уровнем, сообщением и исключением.
|
||||
private sealed class RecordingLogger : ILogger<SuspiciousActivityReporter>
|
||||
{
|
||||
/// <summary>
|
||||
/// Записи лога в порядке поступления.
|
||||
/// </summary>
|
||||
public List<LogEntry> Entries { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
|
||||
// Запись лога, снятая in-memory логгером.
|
||||
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||
}
|
||||
@@ -137,6 +137,91 @@ public sealed class SuspiciousActivityServiceTests
|
||||
Assert.Equal(Tenant.ToString("D"), finding.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
|
||||
}
|
||||
|
||||
SuspiciousActivityService service = Create(store);
|
||||
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||
|
||||
SuspiciousFindingDto finding = Assert.Single(
|
||||
report.Items,
|
||||
item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||
Assert.Equal("10.3.0.1", finding.Subject);
|
||||
Assert.Equal(SuspiciousActivityService.DistinctLoginsPerIpThreshold, finding.Count);
|
||||
Assert.Equal(SuspiciousActivityService.SeverityMedium, finding.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.2", login: $"user{i}", minutesAgo: i);
|
||||
}
|
||||
|
||||
SuspiciousActivityService service = Create(store);
|
||||
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||
|
||||
SuspiciousFindingDto finding = Assert.Single(
|
||||
report.Items,
|
||||
item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||
Assert.Equal(count, finding.Count);
|
||||
Assert.Equal(SuspiciousActivityService.SeverityHigh, finding.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
|
||||
}
|
||||
|
||||
SuspiciousActivityService service = Create(store);
|
||||
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
|
||||
}
|
||||
|
||||
SuspiciousActivityService service = Create(store);
|
||||
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||
{
|
||||
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
|
||||
}
|
||||
|
||||
SuspiciousActivityService service = Create(store);
|
||||
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(report.Items, item => item.Kind == SuspiciousActivityService.KindDistinctLoginsPerIp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
|
||||
{
|
||||
@@ -171,5 +256,24 @@ public sealed class SuspiciousActivityServiceTests
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
// Пишет запись «успешный вход» с заданным временем.
|
||||
private static void SeedSuccess(
|
||||
FakeAuditLogStore store,
|
||||
string ip,
|
||||
string login,
|
||||
int minutesAgo)
|
||||
{
|
||||
store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditActorTypes.Tenant,
|
||||
ActorId: null,
|
||||
TenantId: null,
|
||||
Ip: ip,
|
||||
DetailJson: AuditService.ToDetailJson(new { login }),
|
||||
At: Now.AddMinutes(-minutesAgo)),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,39 @@ public sealed class LoginAttemptEndpointHttpTests
|
||||
rateLimitOptions: EnabledRateLimitOptions());
|
||||
}
|
||||
|
||||
// ─── Учёт подозрительной активности ──────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Блокировка входа после серии неудач → событие login_blocked в метрике
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Login_WhenBlocked_ReportsLoginBlockedSecurityEvent()
|
||||
{
|
||||
using var capture = new SecurityEventCapture();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
new FakeOperatorAuthStore(),
|
||||
NewUserStore(),
|
||||
async (baseAddress, _, _, _, _, _, _) =>
|
||||
{
|
||||
HttpClient client = CreateClient(baseAddress);
|
||||
|
||||
for (int attempt = 0; attempt < MaxAttempts; attempt++)
|
||||
{
|
||||
using HttpResponseMessage failed = await PostJsonAsync(
|
||||
client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode);
|
||||
}
|
||||
|
||||
using HttpResponseMessage blocked = await PostJsonAsync(
|
||||
client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password });
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||
},
|
||||
rateLimitOptions: EnabledRateLimitOptions());
|
||||
|
||||
Assert.True(capture.Sum(SuspiciousActivityReporter.LoginBlockedKind) >= 1);
|
||||
}
|
||||
|
||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||
|
||||
private static RateLimitOptions EnabledRateLimitOptions() =>
|
||||
|
||||
@@ -210,6 +210,7 @@ internal static class OperatorAuthHttpHost
|
||||
builder.Services.AddSingleton(effectiveRateLimitOptions);
|
||||
builder.Services.AddSingleton<IRateLimitCounterStore>(new FakeRateLimitCounterStore());
|
||||
builder.Services.AddScoped<LoginAttemptGuard>();
|
||||
builder.Services.AddScoped<SuspiciousActivityReporter>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseMiddleware<SessionMiddleware>();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Deal.SharedKernel.Observability;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Слушатель счётчика подозрительной активности deal.security.suspicious.
|
||||
/// </summary>
|
||||
public sealed class SecurityEventCapture : IDisposable
|
||||
{
|
||||
// Защита накопленных измерений (callback может прийти из потока запроса).
|
||||
private readonly object _gate = new();
|
||||
|
||||
private readonly List<(string? Kind, long Value)> _measurements = [];
|
||||
|
||||
private readonly MeterListener _listener;
|
||||
|
||||
/// <summary>
|
||||
/// Начинает слушать счётчик подозрительной активности.
|
||||
/// </summary>
|
||||
public SecurityEventCapture()
|
||||
{
|
||||
_listener = new MeterListener();
|
||||
_listener.InstrumentPublished = (instrument, current) =>
|
||||
{
|
||||
if (instrument.Meter.Name == DealMetrics.MeterName
|
||||
&& instrument.Name == DealMetrics.SecurityEvents.Name)
|
||||
{
|
||||
current.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
string? kind = null;
|
||||
foreach (KeyValuePair<string, object?> tag in tags)
|
||||
{
|
||||
if (tag.Key == DealMetrics.SecurityKindTagName)
|
||||
{
|
||||
kind = tag.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_measurements.Add((kind, value));
|
||||
}
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Снимок измерений с момента создания слушателя.
|
||||
/// </summary>
|
||||
/// <returns>Пары (вид события, значение инкремента).</returns>
|
||||
public IReadOnlyList<(string? Kind, long Value)> Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _measurements];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сумма инкрементов по виду события.
|
||||
/// </summary>
|
||||
/// <param name="kind">Вид события (метка kind).</param>
|
||||
/// <returns>Суммарное значение измерений с указанным видом.</returns>
|
||||
public long Sum(string kind)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _measurements.Where(measurement => measurement.Kind == kind).Sum(measurement => measurement.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user