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.