diff --git a/backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitOptions.cs b/backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitOptions.cs new file mode 100644 index 0000000..c2e5293 --- /dev/null +++ b/backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitOptions.cs @@ -0,0 +1,61 @@ +using System.Net; +using Microsoft.Extensions.Options; + +namespace Platform.Api.Modules.Identity.RateLimiting; + +/// +/// Pragovi i poverljivi proxy-ji. Čitaju se iz konfiguracije/okruženja (ADM-04), npr. +/// RateLimiting__Login__PermitLimit=10 ili RateLimiting__KnownProxies__0=10.0.0.5. +/// +public sealed class IdentityRateLimitOptions +{ + public const string Section = "RateLimiting"; + + public WindowLimit Login { get; set; } = new() { PermitLimit = 30, WindowSeconds = 60 }; + public WindowLimit PasswordReset { get; set; } = new() { PermitLimit = 10, WindowSeconds = 900 }; + + /// Tačne IP adrese reverse proxy-ja čiji se X-Forwarded-For prihvata. + public string[] KnownProxies { get; set; } = []; + + /// Mreže reverse proxy-ja u CIDR obliku, npr. "10.0.0.0/8". + public string[] KnownNetworks { get; set; } = []; +} + +public sealed class WindowLimit +{ + public int PermitLimit { get; set; } + public int WindowSeconds { get; set; } +} + +/// Neispravna konfiguracija zaustavlja pokretanje umesto da limiter tiho ne radi. +public sealed class IdentityRateLimitOptionsValidator : IValidateOptions +{ + public const int MaxPermitLimit = 10_000; + public const int MaxWindowSeconds = 86_400; + + public ValidateOptionsResult Validate(string? name, IdentityRateLimitOptions options) + { + var errors = new List(); + CheckLimit(errors, nameof(options.Login), options.Login); + CheckLimit(errors, nameof(options.PasswordReset), options.PasswordReset); + + foreach (var proxy in options.KnownProxies) + if (!IPAddress.TryParse(proxy, out _)) + errors.Add($"RateLimiting:KnownProxies sadrzi neispravnu IP adresu '{proxy}'."); + + foreach (var network in options.KnownNetworks) + if (!System.Net.IPNetwork.TryParse(network, out _)) + errors.Add($"RateLimiting:KnownNetworks sadrzi neispravnu mrezu '{network}' (ocekuje se CIDR, npr. 10.0.0.0/8)."); + + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); + } + + private static void CheckLimit(List errors, string name, WindowLimit? limit) + { + if (limit is null) { errors.Add($"RateLimiting:{name} nije podesen."); return; } + if (limit.PermitLimit is < 1 or > MaxPermitLimit) + errors.Add($"RateLimiting:{name}:PermitLimit mora biti izmedju 1 i {MaxPermitLimit}."); + if (limit.WindowSeconds is < 1 or > MaxWindowSeconds) + errors.Add($"RateLimiting:{name}:WindowSeconds mora biti izmedju 1 i {MaxWindowSeconds}."); + } +} diff --git a/backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitingExtensions.cs b/backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitingExtensions.cs new file mode 100644 index 0000000..0d53988 --- /dev/null +++ b/backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitingExtensions.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; + +namespace Platform.Api.Modules.Identity.RateLimiting; + +/// +/// IDN-05: ograničenje broja pokušaja prijave i reseta lozinke po IP adresi klijenta. +/// +public static class IdentityRateLimitingExtensions +{ + private const string LogCategory = "Platform.Api.Modules.Identity.RateLimiting"; + + public static IServiceCollection AddIdentityRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(IdentityRateLimitOptions.Section)) + .ValidateOnStart(); + services.AddSingleton, IdentityRateLimitOptionsValidator>(); + + // X-Forwarded-For se prihvata ISKLJUČIVO od eksplicitno navedenih proxy-ja. + // Bez toga bi napadač slao lažnu adresu u svakom zahtevu i zaobišao limiter. + services.AddOptions() + .Configure>((forwarded, rateLimit) => + { + forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + forwarded.ForwardLimit = 1; // uzima se samo adresa koju je dopisao naš proxy + forwarded.KnownProxies.Clear(); + forwarded.KnownIPNetworks.Clear(); // podrazumevano veruje loopback-u; ovde ništa nije implicitno + foreach (var proxy in rateLimit.Value.KnownProxies) + if (IPAddress.TryParse(proxy, out var address)) forwarded.KnownProxies.Add(address); + foreach (var network in rateLimit.Value.KnownNetworks) + if (System.Net.IPNetwork.TryParse(network, out var parsed)) forwarded.KnownIPNetworks.Add(parsed); + + // Pažnja: kada su obe liste prazne, ForwardedHeadersMiddleware NE proverava izvor + // i prihvata header od bilo koga. Zato bez podešenog proxy-ja potpuno isključujemo obradu. + if (forwarded.KnownProxies.Count == 0 && forwarded.KnownIPNetworks.Count == 0) + forwarded.ForwardedHeaders = ForwardedHeaders.None; + }); + + services.AddRateLimiter(limiter => + { + limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + limiter.AddPolicy(RateLimitPolicies.Login, http => Partition(http, o => o.Login)); + limiter.AddPolicy(RateLimitPolicies.PasswordReset, http => Partition(http, o => o.PasswordReset)); + limiter.OnRejected = OnRejected; + }); + + return services; + } + + /// Mora se pozvati pre autentifikacije i mapiranja endpointa. + public static WebApplication UseIdentityRateLimiting(this WebApplication app) + { + app.UseForwardedHeaders(); // prvo: RemoteIpAddress postaje stvarna adresa klijenta + app.UseRouting(); // limiter mora da vidi endpoint da bi znao koja politika važi + app.UseRateLimiter(); + return app; + } + + /// + /// Ključ particije. IPv6 se svodi na /64 jer jedan korisnik obično dobija ceo /64 blok + /// i mogao bi da menja adrese unutar njega. + /// + public static string ClientPartitionKey(IPAddress? address) + { + if (address is null) return "unknown"; + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); + if (address.AddressFamily != AddressFamily.InterNetworkV6) return address.ToString(); + + var bytes = address.GetAddressBytes(); + Array.Clear(bytes, 8, 8); + return new IPAddress(bytes) + "/64"; + } + + private static RateLimitPartition Partition(HttpContext http, Func select) + { + var limit = select(http.RequestServices.GetRequiredService>().Value); + // Sliding window: nema "duple ture" na granici prozora kao kod fixed window. + return RateLimitPartition.GetSlidingWindowLimiter( + ClientPartitionKey(http.Connection.RemoteIpAddress), + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = limit.PermitLimit, + Window = TimeSpan.FromSeconds(limit.WindowSeconds), + SegmentsPerWindow = SegmentsPerWindow, + QueueLimit = 0, + AutoReplenishment = true + }); + } + + private const int SegmentsPerWindow = 6; + + private static async ValueTask OnRejected(OnRejectedContext context, CancellationToken cancellationToken) + { + var http = context.HttpContext; + // Isti identifikator koji koriste ostali delovi identity modula (AUD-04, OPS-08). + var correlation = http.TraceIdentifier; + http.Response.Headers["X-Correlation-ID"] = correlation; + http.Response.Headers.CacheControl = "no-store"; + + var policy = http.GetEndpoint()?.Metadata.GetMetadata()?.PolicyName ?? "unknown"; + http.Response.Headers.RetryAfter = RetryAfterSeconds(context.Lease, http, policy) + .ToString(CultureInfo.InvariantCulture); + + // Bez emaila, lozinke ili tela zahteva u logu (AUD-03). + http.RequestServices.GetRequiredService().CreateLogger(LogCategory).LogWarning( + "Rate limit exceeded. Policy={Policy} Client={Client} CorrelationId={CorrelationId}", + policy, ClientPartitionKey(http.Connection.RemoteIpAddress), correlation); + + // Isti odgovor bez obzira na nalog, da se ne bi otkrilo da li postoji (IDN-04, OPS-07). + await http.Response.WriteAsJsonAsync(new + { + code = "rate_limited", + message = "Previse pokusaja. Pokusajte ponovo kasnije." + }, cancellationToken); + } + + /// + /// Sliding window ne vraća uvek RetryAfter u metapodacima. Tada se vraća trajanje celog prozora: + /// to je jedina vrednost za koju je sigurno da posle nje zahtev prolazi. Kraće vreme (npr. jedan + /// segment) moglo bi da bude premalo ako su svi zahtevi stigli u poslednjem segmentu. + /// + private static int RetryAfterSeconds(RateLimitLease lease, HttpContext http, string policy) + { + if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + return Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)); + + var options = http.RequestServices.GetRequiredService>().Value; + var window = policy == RateLimitPolicies.PasswordReset ? options.PasswordReset : options.Login; + return Math.Max(1, window.WindowSeconds); + } +} diff --git a/backend/Platform.Api/Modules/Identity/RateLimiting/README.md b/backend/Platform.Api/Modules/Identity/RateLimiting/README.md new file mode 100644 index 0000000..1bea94a --- /dev/null +++ b/backend/Platform.Api/Modules/Identity/RateLimiting/README.md @@ -0,0 +1,52 @@ +# IDN-05: Ograničenje broja pokušaja (rate limiting) + +Limit po IP adresi klijenta za prijavu i reset lozinke. Prekoračenje vraća `429 Too Many Requests` +sa `Retry-After` (sekunde) i generičkim telom `{ "code": "rate_limited", ... }` koje ne otkriva da li nalog postoji. + +## Kako se koristi (IDN-02 / IDN-04) + +Kontroler (npr. `LoginController`): + +```csharp +[HttpPost("login")] +[EnableRateLimiting(RateLimitPolicies.Login)] +public async Task Login(...) +``` + +Minimal API: + +```csharp +app.MapPost("/api/auth/password-reset", ...).RequireRateLimiting(RateLimitPolicies.PasswordReset); +``` + +## Konfiguracija (ADM-04) + +| Ključ (env varijabla) | Podrazumevano | +|---|---| +| `RateLimiting__Login__PermitLimit` | 30 | +| `RateLimiting__Login__WindowSeconds` | 60 | +| `RateLimiting__PasswordReset__PermitLimit` | 10 | +| `RateLimiting__PasswordReset__WindowSeconds` | 900 | +| `RateLimiting__KnownProxies__0`, `__1`, ... | prazno | +| `RateLimiting__KnownNetworks__0`, ... (CIDR) | prazno | + +Neispravna vrednost (npr. `PermitLimit=0`, loša IP adresa) zaustavlja pokretanje aplikacije. + +## Iza reverse proxy-ja + +`X-Forwarded-For` se prihvata samo kada zahtev stiže sa adrese iz `KnownProxies`/`KnownNetworks`, +i to samo poslednja adresa (ona koju je dopisao naš proxy). Ako proxy nije podešen, limit se računa +po adresi proxy-ja, pa svi korisnici dele isti brojač. Zato ga obavezno podesiti u produkciji, npr.: + +``` +RateLimiting__KnownProxies__0=10.0.0.5 +``` + +IPv6 adrese se grupišu po /64 bloku. + +## Odluke i poznata ograničenja + +- **Sliding window** (6 segmenata): nema dvostrukog naleta zahteva na granici prozora. +- **Brojači su u memoriji**: restart ih resetuje, a više instanci API-ja bi brojalo odvojeno. + Za više instanci bio bi potreban deljeni store (npr. Redis). +- **Distribuirani napad** sa mnogo IP adresa na jedan nalog ne zaustavlja se po IP-u, već po nalogu (IDN-02). diff --git a/backend/Platform.Api/Modules/Identity/RateLimiting/RateLimitPolicies.cs b/backend/Platform.Api/Modules/Identity/RateLimiting/RateLimitPolicies.cs new file mode 100644 index 0000000..8c3c933 --- /dev/null +++ b/backend/Platform.Api/Modules/Identity/RateLimiting/RateLimitPolicies.cs @@ -0,0 +1,13 @@ +namespace Platform.Api.Modules.Identity.RateLimiting; + +/// +/// Imena politika za ograničenje broja pokušaja (IDN-05, SEC-04). +/// Endpoint za prijavu (IDN-02) i reset lozinke (IDN-04) se samo označe: +/// kontroler: [EnableRateLimiting(RateLimitPolicies.Login)] na akciji; +/// minimal API: .RequireRateLimiting(RateLimitPolicies.Login). +/// +public static class RateLimitPolicies +{ + public const string Login = "identity-login"; + public const string PasswordReset = "identity-password-reset"; +} diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index b5ea3df..3175866 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -10,6 +10,7 @@ using Platform.Api.Domain.Identity; using Platform.Api.Middlewares; +using Platform.Api.Modules.Identity.RateLimiting; using Platform.Api.Services.Identity; using Platform.Audit.Composition; @@ -36,6 +37,7 @@ // Servisi sa main grane builder.Services.AddAudit(); +builder.Services.AddIdentityRateLimiting(builder.Configuration); builder.Services.AddControllers(); builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(TimeProvider.System); @@ -62,6 +64,7 @@ } var app = builder.Build(); +app.UseIdentityRateLimiting(); // IDN-01: politike i endpoint za kreiranje naloga app.Services.RegisterIdentityAccess(); diff --git a/backend/Platform.RateLimiting.Tests/Platform.RateLimiting.Tests.csproj b/backend/Platform.RateLimiting.Tests/Platform.RateLimiting.Tests.csproj new file mode 100644 index 0000000..c3c7dba --- /dev/null +++ b/backend/Platform.RateLimiting.Tests/Platform.RateLimiting.Tests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + false + + + + + + + + + diff --git a/backend/Platform.RateLimiting.Tests/RateLimitingHttpTests.cs b/backend/Platform.RateLimiting.Tests/RateLimitingHttpTests.cs new file mode 100644 index 0000000..a7a0b30 --- /dev/null +++ b/backend/Platform.RateLimiting.Tests/RateLimitingHttpTests.cs @@ -0,0 +1,371 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Platform.Api.Modules.Identity.RateLimiting; + +namespace Platform.RateLimiting.Tests; + +/// +/// Mala aplikacija sa istim podešavanjem kao Program.cs (AddIdentityRateLimiting / UseIdentityRateLimiting) +/// i probnim endpointima koji postoje samo u testu, jer IDN-02/IDN-04 još nisu implementirani. +/// +internal sealed class RateLimitHost : IAsyncDisposable +{ + public const string LoginPath = "/test/login"; + public const string ResetPath = "/test/password-reset"; + public const string OpenPath = "/test/open"; + public const string ControllerLoginPath = "/test/controller-login"; + + private readonly WebApplication _app; + private RateLimitHost(WebApplication app) => _app = app; + + public static async Task StartAsync(IDictionary settings, ILoggerProvider? logs = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + if (logs is not null) builder.Logging.AddProvider(logs); + builder.Configuration.AddInMemoryCollection(settings); + builder.Services.AddIdentityRateLimiting(builder.Configuration); + // Login (IDN-02) je MVC kontroler, pa se proverava i atribut [EnableRateLimiting]. + builder.Services.AddControllers().AddApplicationPart(typeof(RateLimitHost).Assembly); + + var app = builder.Build(); + app.UseIdentityRateLimiting(); + app.MapPost(LoginPath, () => Results.Ok()).RequireRateLimiting(RateLimitPolicies.Login); + app.MapPost(ResetPath, () => Results.Ok()).RequireRateLimiting(RateLimitPolicies.PasswordReset); + app.MapPost(OpenPath, () => Results.Ok()); + app.MapControllers(); + try { await app.StartAsync(); } + catch { await app.DisposeAsync(); throw; } + return new RateLimitHost(app); + } + + public Task Post(string path, string remoteIp, string? forwardedFor = null, string? jsonBody = null) => + ((TestServer)_app.Services.GetRequiredService()).SendAsync(ctx => + { + ctx.Request.Method = HttpMethods.Post; + ctx.Request.Path = path; + // TestServer ne postavlja IP sam; bez ovoga bi svi zahtevi delili particiju "unknown". + ctx.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp); + if (forwardedFor is not null) ctx.Request.Headers["X-Forwarded-For"] = forwardedFor; + if (jsonBody is not null) + { + var bytes = Encoding.UTF8.GetBytes(jsonBody); + ctx.Request.ContentType = "application/json"; + ctx.Request.ContentLength = bytes.Length; + ctx.Request.Body = new MemoryStream(bytes); + } + }); + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} + +/// Probni kontroler: proverava da limiter radi i sa atributom, kao na LoginController-u. +[ApiController] +[Route("test/controller-login")] +public sealed class TestLoginController : ControllerBase +{ + [HttpPost] + [EnableRateLimiting(RateLimitPolicies.Login)] + public IActionResult Post() => Ok(); +} + +/// Hvata sve log poruke da bi test proverio šta se zaista upisuje (AUD-03). +internal sealed class CapturingLoggerProvider : ILoggerProvider +{ + public ConcurrentQueue Messages { get; } = new(); + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages); + public void Dispose() { } + + private sealed class CapturingLogger(ConcurrentQueue messages) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => messages.Enqueue(formatter(state, exception)); + } +} + +public sealed class RateLimitingHttpTests +{ + private const string Client = "203.0.113.10"; + private const string OtherClient = "203.0.113.20"; + private const string Proxy = "10.0.0.1"; + + private static Dictionary Settings(int login = 2, int reset = 2, params (string Key, string Value)[] extra) + { + var settings = new Dictionary + { + ["RateLimiting:Login:PermitLimit"] = login.ToString(), + ["RateLimiting:Login:WindowSeconds"] = "60", + ["RateLimiting:PasswordReset:PermitLimit"] = reset.ToString(), + ["RateLimiting:PasswordReset:WindowSeconds"] = "300" + }; + foreach (var (key, value) in extra) settings[key] = value; + return settings; + } + + private static async Task Exhaust(RateLimitHost host, string path, int count, string ip, Func? forwardedFor = null) + { + for (var i = 0; i < count; i++) + { + var ctx = await host.Post(path, ip, forwardedFor?.Invoke(i)); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(200), $"Zahtev {i + 1} od {count} je trebalo da prodje."); + } + } + + private static async Task Body(HttpContext ctx) + { + using var reader = new StreamReader(ctx.Response.Body); + return await reader.ReadToEndAsync(); + } + + [Test] public async Task RequestsAboveLimitGet429WithRetryAfter() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 2)); + await Exhaust(host, RateLimitHost.LoginPath, 2, Client); + + var rejected = await host.Post(RateLimitHost.LoginPath, Client); + Assert.Multiple(() => + { + Assert.That(rejected.Response.StatusCode, Is.EqualTo(StatusCodes.Status429TooManyRequests)); + Assert.That(int.TryParse(rejected.Response.Headers.RetryAfter, out var seconds), Is.True, "Retry-After mora biti broj sekundi."); + Assert.That(seconds, Is.InRange(1, 60)); + Assert.That(rejected.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store")); + Assert.That(rejected.Response.Headers.ContainsKey("X-Correlation-ID"), Is.True); + }); + } + + [Test] public async Task RejectionBodyIsGenericAndStable() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 1)); + await Exhaust(host, RateLimitHost.LoginPath, 1, Client); + + var first = await Body(await host.Post(RateLimitHost.LoginPath, Client)); + var second = await Body(await host.Post(RateLimitHost.LoginPath, Client)); + using var json = JsonDocument.Parse(first); + Assert.Multiple(() => + { + Assert.That(json.RootElement.GetProperty("code").GetString(), Is.EqualTo("rate_limited")); + Assert.That(second, Is.EqualTo(first), "Odgovor ne sme da zavisi od zahteva (nema enumeracije naloga)."); + }); + } + + [Test] public async Task DifferentClientsHaveSeparateLimits() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 2)); + await Exhaust(host, RateLimitHost.LoginPath, 2, Client); + Assert.That((await host.Post(RateLimitHost.LoginPath, Client)).Response.StatusCode, Is.EqualTo(429)); + + var other = await host.Post(RateLimitHost.LoginPath, OtherClient); + Assert.That(other.Response.StatusCode, Is.EqualTo(200)); + } + + [Test] public async Task LoginAndPasswordResetHaveSeparateCounters() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 1, reset: 1)); + await Exhaust(host, RateLimitHost.LoginPath, 1, Client); + Assert.That((await host.Post(RateLimitHost.LoginPath, Client)).Response.StatusCode, Is.EqualTo(429)); + + var reset = await host.Post(RateLimitHost.ResetPath, Client); + Assert.That(reset.Response.StatusCode, Is.EqualTo(200)); + Assert.That((await host.Post(RateLimitHost.ResetPath, Client)).Response.StatusCode, Is.EqualTo(429)); + } + + [Test] public async Task EndpointsWithoutPolicyAreNotLimited() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 1)); + await Exhaust(host, RateLimitHost.OpenPath, 20, Client); + } + + [Test] public async Task SpoofedForwardedForFromUntrustedSourceIsIgnored() + { + // Nijedan proxy nije podešen: X-Forwarded-For se mora ignorisati. + await using var host = await RateLimitHost.StartAsync(Settings(login: 2)); + await Exhaust(host, RateLimitHost.LoginPath, 2, Client, i => $"198.51.100.{i + 1}"); + + var spoofed = await host.Post(RateLimitHost.LoginPath, Client, "198.51.100.99"); + Assert.That(spoofed.Response.StatusCode, Is.EqualTo(429)); + } + + [Test] public async Task ForwardedForFromUnlistedProxyIsIgnored() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 2, extra: ("RateLimiting:KnownProxies:0", Proxy))); + await Exhaust(host, RateLimitHost.LoginPath, 2, Client, i => $"198.51.100.{i + 1}"); + + var spoofed = await host.Post(RateLimitHost.LoginPath, Client, "198.51.100.99"); + Assert.That(spoofed.Response.StatusCode, Is.EqualTo(429)); + } + + [Test] public async Task TrustedProxyForwardsRealClientAddress() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 2, extra: ("RateLimiting:KnownProxies:0", Proxy))); + await Exhaust(host, RateLimitHost.LoginPath, 2, Proxy, _ => Client); + Assert.That((await host.Post(RateLimitHost.LoginPath, Proxy, Client)).Response.StatusCode, Is.EqualTo(429)); + + // Drugi klijent iza istog proxy-ja ima svoj brojač: limit nije po adresi proxy-ja. + var other = await host.Post(RateLimitHost.LoginPath, Proxy, OtherClient); + Assert.That(other.Response.StatusCode, Is.EqualTo(200)); + } + + [Test] public async Task TrustedNetworkIsConfiguredAsCidr() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 1, extra: ("RateLimiting:KnownNetworks:0", "10.0.0.0/24"))); + await Exhaust(host, RateLimitHost.LoginPath, 1, "10.0.0.7", _ => Client); + + // Isti klijent preko drugog proxy-ja iz iste mreže: i dalje isti brojač. + var sameClient = await host.Post(RateLimitHost.LoginPath, "10.0.0.8", Client); + var other = await host.Post(RateLimitHost.LoginPath, "10.0.0.8", OtherClient); + Assert.That(sameClient.Response.StatusCode, Is.EqualTo(429)); + Assert.That(other.Response.StatusCode, Is.EqualTo(200)); + } + + [Test] public async Task ClientCannotPrependFakeAddressBehindTrustedProxy() + { + // Proxy dopisuje stvarnu adresu na kraj; klijent kontroliše sve ispred nje. + await using var host = await RateLimitHost.StartAsync(Settings(login: 2, extra: ("RateLimiting:KnownProxies:0", Proxy))); + await Exhaust(host, RateLimitHost.LoginPath, 2, Proxy, i => $"198.51.100.{i + 1}, {Client}"); + + var rotated = await host.Post(RateLimitHost.LoginPath, Proxy, $"198.51.100.99, {Client}"); + Assert.That(rotated.Response.StatusCode, Is.EqualTo(429)); + } + + [Test] public async Task Ipv6AddressesInSameSlash64ShareLimit() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 1)); + await Exhaust(host, RateLimitHost.LoginPath, 1, "2001:db8:1:2::1"); + + var sameBlock = await host.Post(RateLimitHost.LoginPath, "2001:db8:1:2:ffff::42"); + var otherBlock = await host.Post(RateLimitHost.LoginPath, "2001:db8:1:3::1"); + Assert.That(sameBlock.Response.StatusCode, Is.EqualTo(429)); + Assert.That(otherBlock.Response.StatusCode, Is.EqualTo(200)); + } + + [Test] public async Task ControllerActionWithAttributeIsLimited() + { + await using var host = await RateLimitHost.StartAsync(Settings(login: 2)); + await Exhaust(host, RateLimitHost.ControllerLoginPath, 2, Client); + Assert.That((await host.Post(RateLimitHost.ControllerLoginPath, Client)).Response.StatusCode, Is.EqualTo(429)); + } + + [Test] public async Task RejectionIsLoggedWithCorrelationIdButWithoutCredentials() + { + const string email = "ana.tajna@example.com"; + const string password = "Lozinka-123!"; + var body = $$"""{"email":"{{email}}","password":"{{password}}"}"""; + var logs = new CapturingLoggerProvider(); + await using var host = await RateLimitHost.StartAsync(Settings(login: 1), logs); + await host.Post(RateLimitHost.ControllerLoginPath, Client, jsonBody: body); + + var rejected = await host.Post(RateLimitHost.ControllerLoginPath, Client, jsonBody: body); + var correlation = rejected.Response.Headers["X-Correlation-ID"].ToString(); + var all = string.Join("\n", logs.Messages); + Assert.Multiple(() => + { + Assert.That(rejected.Response.StatusCode, Is.EqualTo(429)); + Assert.That(correlation, Is.Not.Empty); + Assert.That(logs.Messages.Any(m => m.Contains("Rate limit exceeded") && m.Contains(correlation)), Is.True, + "Odbijanje mora biti u logu sa istim correlation ID-jem kao u odgovoru."); + Assert.That(all, Does.Not.Contain(email)); + Assert.That(all, Does.Not.Contain(password)); + }); + } + + [Test] public async Task RetryAfterStaysWithinThePolicyWindow() + { + await using var host = await RateLimitHost.StartAsync(Settings(reset: 1)); + await Exhaust(host, RateLimitHost.ResetPath, 1, Client); + + var rejected = await host.Post(RateLimitHost.ResetPath, Client); + Assert.That(int.Parse(rejected.Response.Headers.RetryAfter.ToString()), Is.InRange(1, 300)); + } + + [Test] public async Task DefaultLoginLimitIsThirtyPerWindow() + { + // Bez podešavanja važi podrazumevani limit od 30 prijava po prozoru. + await using var host = await RateLimitHost.StartAsync(new Dictionary()); + await Exhaust(host, RateLimitHost.LoginPath, 30, Client); + Assert.That((await host.Post(RateLimitHost.LoginPath, Client)).Response.StatusCode, Is.EqualTo(429)); + } + + [TestCase(1)] [TestCase(4)] + public async Task LimitComesFromConfiguration(int permitLimit) + { + await using var host = await RateLimitHost.StartAsync(Settings(login: permitLimit)); + await Exhaust(host, RateLimitHost.LoginPath, permitLimit, Client); + Assert.That((await host.Post(RateLimitHost.LoginPath, Client)).Response.StatusCode, Is.EqualTo(429)); + } + + [TestCase("RateLimiting:Login:PermitLimit", "0")] + [TestCase("RateLimiting:PasswordReset:WindowSeconds", "0")] + [TestCase("RateLimiting:Login:PermitLimit", "-5")] + [TestCase("RateLimiting:KnownProxies:0", "nije-ip")] + [TestCase("RateLimiting:KnownNetworks:0", "10.0.0.0/99")] + public void InvalidConfigurationStopsStartup(string key, string value) + { + var settings = Settings(); + settings[key] = value; + Assert.ThrowsAsync(async () => await RateLimitHost.StartAsync(settings)); + } +} + +/// Proverava da je limiter zaista uključen u Program.cs. +public sealed class ProgramRateLimitingTests +{ + private sealed class ApiFactory(params (string Key, string Value)[] settings) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Production"); + builder.ConfigureLogging(logging => logging.ClearProviders()); + foreach (var (key, value) in settings) builder.UseSetting(key, value); + } + } + + [Test] public async Task ApiStartsWithDefaultsAndHealthIsNotLimited() + { + using var factory = new ApiFactory(); + using var client = factory.CreateClient(); + for (var i = 0; i < 20; i++) + { + using var response = await client.GetAsync("/api/health"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + } + + [Test] public void ApiRefusesToStartWithInvalidLimit() + { + using var factory = new ApiFactory(("RateLimiting:Login:PermitLimit", "0")); + var error = Assert.Catch(() => factory.CreateClient()); + Assert.That(Flatten(error).OfType(), Is.Not.Empty); + } + + private static IEnumerable Flatten(Exception? error) + { + for (; error is not null; error = error.InnerException) + { + yield return error; + if (error is AggregateException aggregate) + foreach (var inner in aggregate.InnerExceptions.SelectMany(Flatten)) yield return inner; + } + } +} diff --git a/backend/SecurityLab.slnx b/backend/SecurityLab.slnx index e0cdbad..d5d041c 100644 --- a/backend/SecurityLab.slnx +++ b/backend/SecurityLab.slnx @@ -5,5 +5,6 @@ + diff --git a/docs/components/identity/IDN-05.md b/docs/components/identity/IDN-05.md new file mode 100644 index 0000000..343857a --- /dev/null +++ b/docs/components/identity/IDN-05.md @@ -0,0 +1,216 @@ +# Project component: IDN-05, ograničenje broja pokušaja prijave (rate limiting) + +**Team:** Tim 4 + +**Student:** Nemanja Vuksa / PR12/2023 + +**Specification requirements:** IDN-05 (J) + +Usklađeno sa (ne implementira ih ova celina, već ih poštuje ili se na njih oslanja): SEC-04, ADM-04, +AUD-03, AUD-04; zavisi od IDN-02 (prijava) i IDN-04 (reset lozinke). + +## Responsibilities and boundaries + +- **Processes and user scenarios:** + - Ograničenje broja zahteva za prijavu i reset lozinke po IP adresi klijenta. + - Prekoračenje vraća `429 Too Many Requests` sa `Retry-After` (sekunde), `X-Correlation-ID`, + `Cache-Control: no-store` i generičkim telom `{ "code": "rate_limited", ... }`. + - Utvrđivanje stvarne adrese klijenta iza reverse proxy-ja (`ForwardedHeaders`), uz prihvatanje + `X-Forwarded-For` samo od eksplicitno navedenih proxy-ja. + - Pragovi i poverljivi proxy-ji se zadaju kroz konfiguraciju/okruženje (ADM-04) i proveravaju pri startu. + - **Planirano:** brojanje neuspešnih prijava po nalogu i privremena blokada (ostatak IDN-05 iz kataloga), + kroz ugovor `ILoginAttemptLimiter` koji poziva login tok (IDN-02). +- **Contracts with other modules:** + - Komponenta izlaže dve imenovane politike u `RateLimitPolicies`. Vlasnici endpointa ih samo zakače: + - IDN-02 (prijava, kontroler): `[EnableRateLimiting(RateLimitPolicies.Login)]` na akciji + - IDN-04 (reset lozinke): `[EnableRateLimiting(RateLimitPolicies.PasswordReset)]`, odnosno + `.RequireRateLimiting(RateLimitPolicies.PasswordReset)` ako je minimal API + - Komponenta ne čita ni ne menja naloge, lozinke ni tokene. Odgovor 429 je isti bez obzira na + nalog, pa ne narušava neotkrivanje postojanja naloga (IDN-04). + - **Planirani ugovor `ILoginAttemptLimiter`** (dogovor sa IDN-02), koji `LoginAccount` poziva: + - `CheckAsync(email)` pre provere lozinke: ako je blokirano, login vraća `429` sa `Retry-After`; + - `RecordFailureAsync(email)` posle pogrešnih podataka; + - `RecordSuccessAsync(email)` posle uspešne prijave (briše brojač). + - Ključ je normalizovan email, a ne ID naloga: nepostojeći email se broji isto kao postojeći, pa + blokada ne otkriva da li nalog postoji (IDN-04). Blokada je privremena i podesiva kroz okruženje, + da napadač ne bi mogao trajno da zaključa tuđi nalog. +- **Implementation status:** + - Implementirano i testirano: politike, particionisanje po IP-u, 429 odgovor, ForwardedHeaders, + konfiguracija i validacija, uključivanje u `Program.cs`. + - `LoginController` (`POST /api/auth/login`) postoji na `main`-u; atribut `[EnableRateLimiting(RateLimitPolicies.Login)]` + na njega dodaje vlasnik IDN-02. Endpoint za reset lozinke (IDN-04) još ne postoji. + - `ILoginAttemptLimiter`: planirano (isprekidano na dijagramima). + +## Component architecture + +```mermaid +flowchart LR + C[Klijent / browser] -->|HTTPS| RP[Reverse proxy
opciono] + RP -->|X-Forwarded-For| K[Kestrel] + C -.->|direktno, bez proxy-ja| K + + subgraph API["Platform.Api"] + K --> FH["ForwardedHeaders middleware
veruje samo KnownProxies / KnownNetworks"] + FH --> RT[Routing] + RT --> RL["RateLimiter middleware
politike Login i PasswordReset"] + RL -->|dozvoljeno| EP["Endpointi drugih komponenti
IDN-02 prijava, IDN-04 reset"] + RL -->|odbijeno| REJ["OnRejected
429 + Retry-After"] + RL <--> CNT[("Brojači po IP adresi
u memoriji, sliding window")] + CFG["IdentityRateLimitOptions
+ validator"] --> RL + CFG --> FH + REJ --> LOG["ILogger
politika, klijent, correlation ID"] + EP -.->|"CheckAsync / RecordFailureAsync / RecordSuccessAsync"| LAL["ILoginAttemptLimiter
planirano"] + LAL -.-> FAIL[("Brojači neuspeha po emailu
planirano")] + CFG -.-> LAL + end + + ENV[("Konfiguracija / env
RateLimiting__*")] --> CFG +``` + +| Element | Fajl | +| --- | --- | +| Politike (ugovor prema IDN-02/IDN-04) | `backend/Platform.Api/Modules/Identity/RateLimiting/RateLimitPolicies.cs` | +| Konfiguracija i validacija | `backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitOptions.cs` | +| Registracija, middleware, particionisanje, 429 | `backend/Platform.Api/Modules/Identity/RateLimiting/IdentityRateLimitingExtensions.cs` | +| Uključivanje u aplikaciju | `backend/Platform.Api/Program.cs` | + +## Data flow diagram + +```mermaid +flowchart LR + subgraph INTERNET["Granica poverenja 1: Internet (nepoverljivo)"] + U["Korisnik / napadač"] + end + + subgraph EDGE["Granica poverenja 2: interna mreža"] + P["Reverse proxy
dopisuje stvarnu IP na kraj X-Forwarded-For"] + end + + subgraph APP["Granica poverenja 3: proces Platform.Api"] + P1(("1. Utvrdi IP klijenta")) + P2(("2. Proveri limit
za politiku i IP")) + P3(("3. Formiraj odgovor 429")) + D1[("D1 Brojači po IP
u memoriji")] + D2[("D2 Konfiguracija
pragovi, proxy-ji")] + D3[("D3 Log")] + E["Endpoint prijave / reseta
IDN-02 / IDN-04"] + P4(("4. Proveri / zabeleži
neuspeh po emailu
planirano")) + D4[("D4 Brojači neuspeha
po emailu, planirano")] + end + + U -->|"zahtev + opciono lažni X-Forwarded-For"| P + U -.->|"direktan zahtev"| P1 + P -->|"zahtev + X-Forwarded-For"| P1 + D2 -->|"KnownProxies / KnownNetworks"| P1 + P1 -->|"ključ particije: IP ili IPv6 /64"| P2 + D2 -->|"PermitLimit, WindowSeconds"| P2 + P2 <-->|"čitanje / umanjenje dozvola"| D1 + P2 -->|"dozvoljeno"| E + P2 -->|"prekoračeno"| P3 + P3 -->|"politika, IP, correlation ID
bez emaila, lozinke, tela"| D3 + P3 -->|"429 + Retry-After, generičko telo"| U + E -.->|"normalizovan email, ishod provere"| P4 + P4 <-.->|"broj neuspeha, kraj blokade"| D4 + P4 -.->|"blokirano: 429 + Retry-After"| U +``` + +Bitne odluke na granicama poverenja: + +- `X-Forwarded-For` dolazi preko granice 1 i nepoverljiv je. Prihvata se samo kada je neposredni + pošiljalac (granica 2) na listi `KnownProxies`/`KnownNetworks`, i to samo poslednja adresa + (`ForwardLimit = 1`). Bez podešenog proxy-ja obrada headera je potpuno isključena. +- Telo zahteva (email, lozinka) se ne čita i ne upisuje u D3. +- Planirani P4 dobija samo normalizovan email i ishod, nikad lozinku. D4 se ne upisuje u log. + +## Security requirements + +- **Server-side access checks:** + - Limit se primenjuje u serverskom middleware-u pre izvršavanja endpointa. Klijent ne može da ga + isključi ni da utiče na ključ particije, osim stvarnom IP adresom. + - Podrazumevano: prijava 30 zahteva / 60 s, reset 10 / 900 s, + sliding window sa 6 segmenata. +- **Input validation and sensitive data:** + - Konfiguracija se validira pri startu (`ValidateOnStart`). Limit ili prozor van opsega i + neispravna IP adresa ili CIDR zaustavljaju pokretanje aplikacije. + - Pragovi i proxy-ji su u okruženju, ne u kodu, i nisu tajne (ADM-04). + - Log odbijanja sadrži samo politiku, IP/blok i correlation ID (AUD-03). Correlation ID je + `HttpContext.TraceIdentifier`, isti koji koristi login tok, pa se zahtev može pratiti kroz module (AUD-04). + - IPv6 adrese se grupišu po /64 da se limit ne bi zaobilazio promenom adrese unutar bloka. +- **Negative scenarios** (svaki ima automatski test u `RateLimitingHttpTests.cs`): + +| Pretnja / zloupotreba | Kontrola | Test | Dokaz | +| --- | --- | --- | --- | +| Pogađanje lozinke velikim brojem zahteva | Limit po IP-u, 429 | `RequestsAboveLimitGet429WithRetryAfter` | Prošao, 1. 11. 2026 | +| Enumeracija naloga kroz odgovor limitera | Generičko, stabilno telo | `RejectionBodyIsGenericAndStable` | Prošao, 1. 11. 2026 | +| Zaobilaženje limita lažnim `X-Forwarded-For` | Header samo od poznatih proxy-ja | `SpoofedForwardedForFromUntrustedSourceIsIgnored`, `ForwardedForFromUnlistedProxyIsIgnored` | Prošao, 1. 11. 2026 | +| Lažna adresa ispred prave iza proxy-ja | `ForwardLimit = 1` | `ClientCannotPrependFakeAddressBehindTrustedProxy` | Prošao, 1. 11. 2026 | +| Rotacija IPv6 adresa unutar bloka | Grupisanje po /64 | `Ipv6AddressesInSameSlash64ShareLimit` | Prošao, 1. 11. 2026 | +| Blokada drugih korisnika (pogrešna particija) | Odvojeni brojači po IP-u i politici | `DifferentClientsHaveSeparateLimits`, `LoginAndPasswordResetHaveSeparateCounters`, `TrustedProxyForwardsRealClientAddress` | Prošao, 1. 11. 2026 | +| Tihi kvar zbog loše konfiguracije | Validacija pri startu | `InvalidConfigurationStopsStartup`, `ApiRefusesToStartWithInvalidLimit` | Prošao, 1. 11. 2026 | +| Email ili lozinka u logovima (AUD-03) | Log sadrži samo politiku, IP i correlation ID | `RejectionIsLoggedWithCorrelationIdButWithoutCredentials` | Prošao, 1. 11. 2026 | +| Limiter ne radi na MVC kontroleru (login) | Atribut `[EnableRateLimiting]` | `ControllerActionWithAttributeIsLimited` | Prošao, 1. 11. 2026 | + +Verzija koda za kolonu "Dokaz": commit `ef107a5` ([PR #28](https://github.com/owlCoder/security-lab/pull/28)). Rezultat: 24/24 testa prolazi +(`dotnet test backend/Platform.RateLimiting.Tests` i Visual Studio Test Explorer). + +### Test scenariji (struktura iz poglavlja 8.1) + +| Requirement ID | Preduslov | Akcija | Očekivano | Dokaz | +| --- | --- | --- | --- | --- | +| IDN-05 | Limit prijave 2 zahteva / 60 s; klijent `203.0.113.10`. | Klijent šalje 3 zahteva za prijavu zaredom. | Prva dva prolaze. Treći dobija `429` sa `Retry-After` između 1 i 60 s, `Cache-Control: no-store` i `X-Correlation-ID`. | `RequestsAboveLimitGet429WithRetryAfter`, 1. 11. 2026, commit `ef107a5`, prošao | +| IDN-05 | Nijedan proxy nije podešen; limit 2. | Napadač šalje 3 zahteva, svaki sa drugom lažnom adresom u `X-Forwarded-For`. | Header se ignoriše, svi zahtevi se broje na stvarnu adresu; treći dobija `429`. | `SpoofedForwardedForFromUntrustedSourceIsIgnored`, 1. 11. 2026, commit `ef107a5`, prošao | +| IDN-05 | Limit 1; klijent je već potrošio limit. | Dva nova zahteva za prijavu sa iste adrese. | Oba odgovora imaju identično telo sa kodom `rate_limited`, bez podataka o nalogu (ne otkriva se postojanje naloga). | `RejectionBodyIsGenericAndStable`, 1. 11. 2026, commit `ef107a5`, prošao | +| IDN-05 | Limit 1; login je MVC kontroler sa `[EnableRateLimiting]`; zahtev nosi email i lozinku u telu. | Dve prijave sa iste adrese. | Druga dobija `429`; odbijanje je u logu sa istim correlation ID-jem kao u odgovoru; ni email ni lozinka se ne pojavljuju ni u jednoj log poruci. | `RejectionIsLoggedWithCorrelationIdButWithoutCredentials`, 1. 11. 2026, commit `ef107a5`, prošao | +| IDN-05 | Konfiguracija `RateLimiting:Login:PermitLimit = 0`. | Pokretanje aplikacije. | Aplikacija se ne pokreće (`OptionsValidationException`); limiter nikad ne radi sa neispravnim pragom. | `ApiRefusesToStartWithInvalidLimit`, `InvalidConfigurationStopsStartup`, 1. 11. 2026, commit `ef107a5`, prošao | + +**Poznata ograničenja:** + +- Brojači su u memoriji: restart ih resetuje, a više instanci API-ja bi brojalo odvojeno + (rešenje: deljeni store, npr. Redis). +- Distribuirani napad sa mnogo IP adresa na jedan nalog se ne zaustavlja po IP-u; to pokriva + planirani `ILoginAttemptLimiter`. +- IP adresa u logu je lični podatak; zadržavanje logova prati politiku ADM-05. + +## Evidence of work + +- **Changed files / commit / pull request:** + - Grana: `feature/IDN-05-rate-limiting` + - Novi fajlovi: `backend/Platform.Api/Modules/Identity/RateLimiting/*` (3 `.cs` + `README.md`), + `backend/Platform.RateLimiting.Tests/*`, `docs/components/identity/IDN-05.md` + - Izmenjeni fajlovi: `backend/Platform.Api/Program.cs` (3 linije) + - `backend/SecurityLab.slnx`: dodat `Platform.RateLimiting.Tests` (1 linija). Projekat je ranije nosio ime + `Platform.Identity.Tests`, ali je to ime preuzeo IDN-01 (PR #40) za testove modula `Platform.Identity`. + - Commit / PR: `ef107a5`, [PR #28](https://github.com/owlCoder/security-lab/pull/28) +- **Run instructions:** + + ```powershell + dotnet test backend/Platform.RateLimiting.Tests + ``` + + Primer konfiguracije iza proxy-ja: + + ```powershell + $env:RateLimiting__Login__PermitLimit = "30" + $env:RateLimiting__KnownProxies__0 = "10.0.0.5" + dotnet run --project backend/Platform.Api + ``` + + Ručna provera, kada IDN-02 login endpoint postoji: više od `PermitLimit` prijava u prozoru sa + iste adrese vraća 429 sa `Retry-After`. +- **Tests and results:** + - `Platform.RateLimiting.Tests`: 24 testa (22 nad izolovanim hostom sa istim podešavanjem kao + `Program.cs`, uključujući probni MVC kontroler, i 2 nad celim `Program.cs` preko `WebApplicationFactory`). + - Lokalno, Visual Studio Test Explorer i `dotnet test`, 1. 11. 2026: 24/24 testova prolazi. + CI: projekat je uključen u `backend/SecurityLab.slnx`, pa ga GitHub Actions pokreće u koraku + "Test backend solution" (`dotnet test backend/SecurityLab.slnx`, `.github/workflows/ci.yml`). + - Prvo pokretanje je otkrilo propust (prazna lista proxy-ja u ASP.NET-u znači "veruj svima"). + Ispravljeno je isključivanjem obrade headera kada proxy nije podešen. +- **Next steps:** + - Implementirati `ILoginAttemptLimiter` sa implementacijom u memoriji i testovima (blokada posle N + neuspeha, isto ponašanje za nepostojeći email, istek blokade, reset posle uspeha). + - Zavisnost od IDN-02: poziv iz `LoginAccount.Execute` pre provere lozinke i atribut + `[EnableRateLimiting(RateLimitPolicies.Login)]` na `LoginController`. Revizorski zapis za blokiranu + prijavu piše login tok (AUD-01). + - Zavisnost od IDN-04: atribut `PasswordReset` na endpointu za reset lozinke. + - Kasnije: brojače neuspeha čuvati u bazi identity modula umesto u memoriji. + - Podesiti `KnownProxies` u deployment konfiguraciji kada se zna adresa reverse proxy-ja.