diff --git a/src/Budget.Api/Controllers/BudgetsController.cs b/src/Budget.Api/Controllers/BudgetsController.cs index 805bd48..e180ea6 100644 --- a/src/Budget.Api/Controllers/BudgetsController.cs +++ b/src/Budget.Api/Controllers/BudgetsController.cs @@ -4,6 +4,7 @@ using Budget.Api.Models; using Budget.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace Budget.Api.Controllers; @@ -34,6 +35,7 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz } [HttpPost] + [EnableRateLimiting("writes")] public async Task Create([FromBody] CreateBudgetRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -62,6 +64,7 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz } [HttpPut("{id:guid}")] + [EnableRateLimiting("writes")] public async Task Update(Guid id, [FromBody] UpdateBudgetRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -75,6 +78,7 @@ public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz } [HttpDelete("{id:guid}")] + [EnableRateLimiting("writes")] public async Task Delete(Guid id) { if (TryGetUserId(out var userId) is { } err) return err; diff --git a/src/Budget.Api/Controllers/IncomesController.cs b/src/Budget.Api/Controllers/IncomesController.cs index bc6962b..401f1fa 100644 --- a/src/Budget.Api/Controllers/IncomesController.cs +++ b/src/Budget.Api/Controllers/IncomesController.cs @@ -4,6 +4,7 @@ using Budget.Api.Models; using Budget.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace Budget.Api.Controllers; @@ -40,6 +41,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz } [HttpPost] + [EnableRateLimiting("writes")] public async Task Create(Guid budgetId, [FromBody] CreateIncomeRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -60,6 +62,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz } [HttpPut("{incomeId:guid}")] + [EnableRateLimiting("writes")] public async Task Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -74,6 +77,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz } [HttpDelete("{incomeId:guid}")] + [EnableRateLimiting("writes")] public async Task Delete(Guid budgetId, Guid incomeId) { if (TryGetUserId(out var userId) is { } err) return err; @@ -86,6 +90,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz } [HttpPut("order")] + [EnableRateLimiting("writes")] public async Task Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req) { if (TryGetUserId(out var userId) is { } err) return err; diff --git a/src/Budget.Api/Controllers/OutgosController.cs b/src/Budget.Api/Controllers/OutgosController.cs index 1129c72..0c74b7b 100644 --- a/src/Budget.Api/Controllers/OutgosController.cs +++ b/src/Budget.Api/Controllers/OutgosController.cs @@ -4,6 +4,7 @@ using Budget.Api.Models; using Budget.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace Budget.Api.Controllers; @@ -48,6 +49,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) } [HttpPost] + [EnableRateLimiting("writes")] public async Task Create(Guid budgetId, [FromBody] CreateOutgoRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -73,6 +75,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) } [HttpPut("{outgoId:guid}")] + [EnableRateLimiting("writes")] public async Task Update(Guid budgetId, Guid outgoId, [FromBody] UpdateOutgoRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -92,6 +95,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) } [HttpDelete("{outgoId:guid}")] + [EnableRateLimiting("writes")] public async Task Delete(Guid budgetId, Guid outgoId) { if (TryGetUserId(out var userId) is { } err) return err; @@ -104,6 +108,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) } [HttpPut("order")] + [EnableRateLimiting("writes")] public async Task Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req) { if (TryGetUserId(out var userId) is { } err) return err; diff --git a/src/Budget.Api/Controllers/SharesController.cs b/src/Budget.Api/Controllers/SharesController.cs index 002b3b3..08c6d5d 100644 --- a/src/Budget.Api/Controllers/SharesController.cs +++ b/src/Budget.Api/Controllers/SharesController.cs @@ -4,6 +4,7 @@ using Budget.Api.Models; using Budget.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace Budget.Api.Controllers; @@ -34,6 +35,7 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz) } [HttpPost] + [EnableRateLimiting("writes")] public async Task Add(Guid budgetId, [FromBody] CreateShareRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -60,6 +62,7 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz) } [HttpPut("{shareId:guid}")] + [EnableRateLimiting("writes")] public async Task Update(Guid budgetId, Guid shareId, [FromBody] UpdateShareRequest req) { if (TryGetUserId(out var userId) is { } err) return err; @@ -72,6 +75,7 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz) } [HttpDelete("{shareId:guid}")] + [EnableRateLimiting("writes")] public async Task Revoke(Guid budgetId, Guid shareId) { if (TryGetUserId(out var userId) is { } err) return err; diff --git a/src/Budget.Api/Controllers/SummaryController.cs b/src/Budget.Api/Controllers/SummaryController.cs index 7914567..dd1e449 100644 --- a/src/Budget.Api/Controllers/SummaryController.cs +++ b/src/Budget.Api/Controllers/SummaryController.cs @@ -4,6 +4,7 @@ using Budget.Api.Models; using Budget.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace Budget.Api.Controllers; @@ -75,6 +76,7 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz } [HttpPut("tax-rate")] + [EnableRateLimiting("writes")] public async Task UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req) { if (TryGetUserId(out var userId) is { } err) return err; diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index 8c97441..b07d8bc 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -1,8 +1,10 @@ +using System.Threading.RateLimiting; using Budget.Api.Data; using Budget.Api.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; @@ -49,6 +51,39 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }; }); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.GlobalLimiter = PartitionedRateLimiter.Create(ctx => + { + var key = ctx.User.FindFirst("sub")?.Value + ?? ctx.Connection.RemoteIpAddress?.ToString() + ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + }); + }); + + options.AddPolicy("writes", ctx => + { + var key = ctx.User.FindFirst("sub")?.Value + ?? ctx.Connection.RemoteIpAddress?.ToString() + ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter("writes:" + key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + }); + }); +}); + builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddControllers(); @@ -99,6 +134,8 @@ app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); +app.UseRateLimiter(); + app.MapControllers(); app.MapHealthChecks("/healthz", new HealthCheckOptions {