Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Net;
using Microsoft.Extensions.Options;

namespace Platform.Api.Modules.Identity.RateLimiting;

/// <summary>
/// Pragovi i poverljivi proxy-ji. Čitaju se iz konfiguracije/okruženja (ADM-04), npr.
/// <c>RateLimiting__Login__PermitLimit=10</c> ili <c>RateLimiting__KnownProxies__0=10.0.0.5</c>.
/// </summary>
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 };

/// <summary>Tačne IP adrese reverse proxy-ja čiji se X-Forwarded-For prihvata.</summary>
public string[] KnownProxies { get; set; } = [];

/// <summary>Mreže reverse proxy-ja u CIDR obliku, npr. "10.0.0.0/8".</summary>
public string[] KnownNetworks { get; set; } = [];
}

public sealed class WindowLimit
{
public int PermitLimit { get; set; }
public int WindowSeconds { get; set; }
}

/// <summary>Neispravna konfiguracija zaustavlja pokretanje umesto da limiter tiho ne radi.</summary>
public sealed class IdentityRateLimitOptionsValidator : IValidateOptions<IdentityRateLimitOptions>
{
public const int MaxPermitLimit = 10_000;
public const int MaxWindowSeconds = 86_400;

public ValidateOptionsResult Validate(string? name, IdentityRateLimitOptions options)
{
var errors = new List<string>();
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<string> 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}.");
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// IDN-05: ograničenje broja pokušaja prijave i reseta lozinke po IP adresi klijenta.
/// </summary>
public static class IdentityRateLimitingExtensions
{
private const string LogCategory = "Platform.Api.Modules.Identity.RateLimiting";

public static IServiceCollection AddIdentityRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<IdentityRateLimitOptions>()
.Bind(configuration.GetSection(IdentityRateLimitOptions.Section))
.ValidateOnStart();
services.AddSingleton<IValidateOptions<IdentityRateLimitOptions>, 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<ForwardedHeadersOptions>()
.Configure<IOptions<IdentityRateLimitOptions>>((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;
}

/// <summary>Mora se pozvati pre autentifikacije i mapiranja endpointa.</summary>
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;
}

/// <summary>
/// Ključ particije. IPv6 se svodi na /64 jer jedan korisnik obično dobija ceo /64 blok
/// i mogao bi da menja adrese unutar njega.
/// </summary>
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<string> Partition(HttpContext http, Func<IdentityRateLimitOptions, WindowLimit> select)
{
var limit = select(http.RequestServices.GetRequiredService<IOptions<IdentityRateLimitOptions>>().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<EnableRateLimitingAttribute>()?.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<ILoggerFactory>().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);
}

/// <summary>
/// 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.
/// </summary>
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<IOptions<IdentityRateLimitOptions>>().Value;
var window = policy == RateLimitPolicies.PasswordReset ? options.PasswordReset : options.Login;
return Math.Max(1, window.WindowSeconds);
}
}
52 changes: 52 additions & 0 deletions backend/Platform.Api/Modules/Identity/RateLimiting/README.md
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Platform.Api.Modules.Identity.RateLimiting;

/// <summary>
/// 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: <c>[EnableRateLimiting(RateLimitPolicies.Login)]</c> na akciji;
/// minimal API: <c>.RequireRateLimiting(RateLimitPolicies.Login)</c>.
/// </summary>
public static class RateLimitPolicies
{
public const string Login = "identity-login";
public const string PasswordReset = "identity-password-reset";
}
3 changes: 3 additions & 0 deletions backend/Platform.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -62,6 +64,7 @@
}

var app = builder.Build();
app.UseIdentityRateLimiting();

// IDN-01: politike i endpoint za kreiranje naloga
app.Services.RegisterIdentityAccess();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
<ProjectReference Include="../Platform.Api/Platform.Api.csproj" />
</ItemGroup>
</Project>
Loading
Loading