From 6d1bc2ce2cc1136cb5821b1df550bd2e1dc6798c Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:00:33 -0500 Subject: [PATCH] Security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove OwnerUserId from BudgetDto: OIDC sub of the budget owner was being returned to all collaborators (including View-only users) - Remove SharedWithUserId from ShareDto: other users' internal OIDC subs were visible to anyone with read access to a budget - Delete MeController: scaffolding endpoint that returned sub to the browser; no legitimate frontend use case - Restrict /healthz to require authorization: prevents unauthenticated probing of database connectivity - Add input validation annotations to all request DTOs: [Required], [MaxLength], [Range(0,0.9999)] on EffectiveTaxRate, [EmailAddress] on share email — [ApiController] now returns 400 instead of 500 for invalid input hitting DB constraints - Replace User.FindFirst("sub")!.Value with GetUserId() extension across all controllers: returns 401 instead of NullReferenceException (500) if a token lacks a sub claim Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/BudgetsController.cs | 31 ++++++++++++------- .../Controllers/IncomesController.cs | 23 ++++++++++---- src/Budget.Api/Controllers/MeController.cs | 18 ----------- .../Controllers/OutgosController.cs | 29 ++++++++++++----- .../Controllers/SharesController.cs | 26 +++++++++++----- .../Controllers/SummaryController.cs | 14 +++++++-- src/Budget.Api/DTOs/BudgetDtos.cs | 8 +++-- src/Budget.Api/DTOs/IncomeDtos.cs | 13 ++++++-- src/Budget.Api/DTOs/OutgoDtos.cs | 23 +++++++------- src/Budget.Api/DTOs/ShareDtos.cs | 5 +-- src/Budget.Api/DTOs/SummaryDtos.cs | 4 ++- src/Budget.Api/Program.cs | 2 +- .../Services/ClaimsPrincipalExtensions.cs | 10 ++++++ src/Budget.Client/src/types/index.ts | 2 -- 14 files changed, 131 insertions(+), 77 deletions(-) delete mode 100644 src/Budget.Api/Controllers/MeController.cs create mode 100644 src/Budget.Api/Services/ClaimsPrincipalExtensions.cs diff --git a/src/Budget.Api/Controllers/BudgetsController.cs b/src/Budget.Api/Controllers/BudgetsController.cs index 77c8b3f..805bd48 100644 --- a/src/Budget.Api/Controllers/BudgetsController.cs +++ b/src/Budget.Api/Controllers/BudgetsController.cs @@ -13,63 +13,72 @@ namespace Budget.Api.Controllers; [Authorize] public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { - private string UserId => User.FindFirst("sub")!.Value; + private IActionResult? TryGetUserId(out string userId) + { + var id = User.GetUserId(); + if (id is null) { userId = string.Empty; return Unauthorized(); } + userId = id; + return null; + } [HttpGet] public async Task List() { - var userId = UserId; + if (TryGetUserId(out var userId) is { } err) return err; var budgets = await db.Budgets .Where(b => b.OwnerUserId == userId || b.Shares.Any(s => s.SharedWithUserId == userId && !s.IsPending)) - .Select(b => new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)) + .Select(b => new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)) .ToListAsync(); - return Ok(budgets); } [HttpPost] public async Task Create([FromBody] CreateBudgetRequest req) { + if (TryGetUserId(out var userId) is { } err) return err; var budget = new Models.Budget { Id = Guid.NewGuid(), Name = req.Name, - OwnerUserId = UserId, + OwnerUserId = userId, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, }; db.Budgets.Add(budget); await db.SaveChangesAsync(); return CreatedAtAction(nameof(Get), new { id = budget.Id }, - new BudgetDto(budget.Id, budget.Name, budget.OwnerUserId, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt)); + new BudgetDto(budget.Id, budget.Name, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt)); } [HttpGet("{id:guid}")] public async Task Get(Guid id) { - if (!await authz.CanReadAsync(id, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(id, userId)) return Forbid(); var b = await db.Budgets.FindAsync(id); if (b is null) return NotFound(); - return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); + return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); } [HttpPut("{id:guid}")] public async Task Update(Guid id, [FromBody] UpdateBudgetRequest req) { - if (!await authz.CanWriteAsync(id, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(id, userId)) return Forbid(); var b = await db.Budgets.FindAsync(id); if (b is null) return NotFound(); b.Name = req.Name; b.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); - return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); + return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id) { - if (!await authz.IsOwnerAsync(id, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.IsOwnerAsync(id, userId)) return Forbid(); var b = await db.Budgets.FindAsync(id); if (b is null) return NotFound(); db.Budgets.Remove(b); diff --git a/src/Budget.Api/Controllers/IncomesController.cs b/src/Budget.Api/Controllers/IncomesController.cs index 066ae2c..bc6962b 100644 --- a/src/Budget.Api/Controllers/IncomesController.cs +++ b/src/Budget.Api/Controllers/IncomesController.cs @@ -13,7 +13,13 @@ namespace Budget.Api.Controllers; [Authorize] public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { - private string UserId => User.FindFirst("sub")!.Value; + private IActionResult? TryGetUserId(out string userId) + { + var id = User.GetUserId(); + if (id is null) { userId = string.Empty; return Unauthorized(); } + userId = id; + return null; + } private static IncomeDto ToDto(Income i) => new( i.Id, i.BudgetId, i.Name, i.Frequency, i.Amount, @@ -24,7 +30,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz [HttpGet] public async Task List(Guid budgetId) { - if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(budgetId, userId)) return Forbid(); var incomes = await db.Incomes .Where(i => i.BudgetId == budgetId) .OrderBy(i => i.SortOrder) @@ -35,7 +42,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz [HttpPost] public async Task Create(Guid budgetId, [FromBody] CreateIncomeRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var maxOrder = await db.Incomes.Where(i => i.BudgetId == budgetId).MaxAsync(i => (int?)i.SortOrder) ?? -1; var income = new Income { @@ -54,7 +62,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz [HttpPut("{incomeId:guid}")] public async Task Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); if (income is null) return NotFound(); income.Name = req.Name; @@ -67,7 +76,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz [HttpDelete("{incomeId:guid}")] public async Task Delete(Guid budgetId, Guid incomeId) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); if (income is null) return NotFound(); db.Incomes.Remove(income); @@ -78,7 +88,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz [HttpPut("order")] public async Task Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync(); var lookup = incomes.ToDictionary(i => i.Id); for (int idx = 0; idx < req.OrderedIds.Count; idx++) diff --git a/src/Budget.Api/Controllers/MeController.cs b/src/Budget.Api/Controllers/MeController.cs deleted file mode 100644 index 65ebcba..0000000 --- a/src/Budget.Api/Controllers/MeController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Budget.Api.Controllers; - -[ApiController] -[Route("api/[controller]")] -[Authorize] -public class MeController : ControllerBase -{ - [HttpGet] - public IActionResult Get() - { - var sub = User.FindFirst("sub")?.Value; - var email = User.FindFirst("email")?.Value; - return Ok(new { sub, email }); - } -} diff --git a/src/Budget.Api/Controllers/OutgosController.cs b/src/Budget.Api/Controllers/OutgosController.cs index 133f977..1129c72 100644 --- a/src/Budget.Api/Controllers/OutgosController.cs +++ b/src/Budget.Api/Controllers/OutgosController.cs @@ -13,7 +13,13 @@ namespace Budget.Api.Controllers; [Authorize] public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { - private string UserId => User.FindFirst("sub")!.Value; + private IActionResult? TryGetUserId(out string userId) + { + var id = User.GetUserId(); + if (id is null) { userId = string.Empty; return Unauthorized(); } + userId = id; + return null; + } private static OutgoDto ToDto(Outgo o, decimal totalMonthlyIncome) => new( o.Id, o.BudgetId, o.Name, o.Category, o.Type, o.Frequency, o.Amount, @@ -34,7 +40,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpGet] public async Task List(Guid budgetId) { - if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(budgetId, userId)) return Forbid(); var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync(); var monthlyIncome = await GetMonthlyIncomeAsync(budgetId); return Ok(outgos.Select(o => ToDto(o, monthlyIncome))); @@ -43,7 +50,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpPost] public async Task Create(Guid budgetId, [FromBody] CreateOutgoRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var maxOrder = await db.Outgos.Where(o => o.BudgetId == budgetId).MaxAsync(o => (int?)o.SortOrder) ?? -1; var outgo = new Outgo { @@ -67,7 +75,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpPut("{outgoId:guid}")] public async Task Update(Guid budgetId, Guid outgoId, [FromBody] UpdateOutgoRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId); if (outgo is null) return NotFound(); outgo.Name = req.Name; @@ -85,7 +94,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpDelete("{outgoId:guid}")] public async Task Delete(Guid budgetId, Guid outgoId) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId); if (outgo is null) return NotFound(); db.Outgos.Remove(outgo); @@ -96,7 +106,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpPut("order")] public async Task Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync(); var lookup = outgos.ToDictionary(o => o.Id); for (int idx = 0; idx < req.OrderedIds.Count; idx++) @@ -111,7 +122,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpGet("categories")] public async Task Categories(Guid budgetId) { - if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(budgetId, userId)) return Forbid(); var cats = await db.Outgos .Where(o => o.BudgetId == budgetId && o.Category != null) .Select(o => o.Category!) @@ -124,7 +136,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) [HttpGet("payment-sources")] public async Task PaymentSources(Guid budgetId) { - if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(budgetId, userId)) return Forbid(); var sources = await db.Outgos .Where(o => o.BudgetId == budgetId && o.PaymentSource != null) .Select(o => o.PaymentSource!) diff --git a/src/Budget.Api/Controllers/SharesController.cs b/src/Budget.Api/Controllers/SharesController.cs index e15f8cb..002b3b3 100644 --- a/src/Budget.Api/Controllers/SharesController.cs +++ b/src/Budget.Api/Controllers/SharesController.cs @@ -13,15 +13,22 @@ namespace Budget.Api.Controllers; [Authorize] public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { - private string UserId => User.FindFirst("sub")!.Value; + private IActionResult? TryGetUserId(out string userId) + { + var id = User.GetUserId(); + if (id is null) { userId = string.Empty; return Unauthorized(); } + userId = id; + return null; + } [HttpGet] public async Task List(Guid budgetId) { - if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(budgetId, userId)) return Forbid(); var shares = await db.BudgetShares .Where(s => s.BudgetId == budgetId) - .Select(s => new ShareDto(s.Id, s.SharedWithUserId, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt)) + .Select(s => new ShareDto(s.Id, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt)) .ToListAsync(); return Ok(shares); } @@ -29,7 +36,8 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz) [HttpPost] public async Task Add(Guid budgetId, [FromBody] CreateShareRequest req) { - if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid(); var existing = await db.BudgetShares .FirstOrDefaultAsync(s => s.BudgetId == budgetId && s.SharedWithEmail == req.Email); @@ -48,24 +56,26 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz) }; db.BudgetShares.Add(share); await db.SaveChangesAsync(); - return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); + return Ok(new ShareDto(share.Id, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); } [HttpPut("{shareId:guid}")] public async Task Update(Guid budgetId, Guid shareId, [FromBody] UpdateShareRequest req) { - if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid(); var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); if (share is null) return NotFound(); share.Permission = req.Permission; await db.SaveChangesAsync(); - return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); + return Ok(new ShareDto(share.Id, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); } [HttpDelete("{shareId:guid}")] public async Task Revoke(Guid budgetId, Guid shareId) { - if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid(); var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); if (share is null) return NotFound(); db.BudgetShares.Remove(share); diff --git a/src/Budget.Api/Controllers/SummaryController.cs b/src/Budget.Api/Controllers/SummaryController.cs index 0442ff3..7914567 100644 --- a/src/Budget.Api/Controllers/SummaryController.cs +++ b/src/Budget.Api/Controllers/SummaryController.cs @@ -13,12 +13,19 @@ namespace Budget.Api.Controllers; [Authorize] public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase { - private string UserId => User.FindFirst("sub")!.Value; + private IActionResult? TryGetUserId(out string userId) + { + var id = User.GetUserId(); + if (id is null) { userId = string.Empty; return Unauthorized(); } + userId = id; + return null; + } [HttpGet] public async Task Get(Guid budgetId) { - if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanReadAsync(budgetId, userId)) return Forbid(); var budget = await db.Budgets.FindAsync(budgetId); if (budget is null) return NotFound(); @@ -70,7 +77,8 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz [HttpPut("tax-rate")] public async Task UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req) { - if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + if (TryGetUserId(out var userId) is { } err) return err; + if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid(); var budget = await db.Budgets.FindAsync(budgetId); if (budget is null) return NotFound(); budget.EffectiveTaxRate = req.EffectiveTaxRate; diff --git a/src/Budget.Api/DTOs/BudgetDtos.cs b/src/Budget.Api/DTOs/BudgetDtos.cs index f9ac5b7..e47a699 100644 --- a/src/Budget.Api/DTOs/BudgetDtos.cs +++ b/src/Budget.Api/DTOs/BudgetDtos.cs @@ -1,7 +1,9 @@ +using System.ComponentModel.DataAnnotations; + namespace Budget.Api.DTOs; -public record BudgetDto(Guid Id, string Name, string OwnerUserId, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); +public record BudgetDto(Guid Id, string Name, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); -public record CreateBudgetRequest(string Name); +public record CreateBudgetRequest([Required][MaxLength(200)] string Name); -public record UpdateBudgetRequest(string Name); +public record UpdateBudgetRequest([Required][MaxLength(200)] string Name); diff --git a/src/Budget.Api/DTOs/IncomeDtos.cs b/src/Budget.Api/DTOs/IncomeDtos.cs index e9eb3b8..0bed93f 100644 --- a/src/Budget.Api/DTOs/IncomeDtos.cs +++ b/src/Budget.Api/DTOs/IncomeDtos.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using Budget.Api.Models; namespace Budget.Api.DTOs; @@ -12,8 +13,14 @@ public record IncomeDto( decimal Annually, int SortOrder); -public record CreateIncomeRequest(string Name, Frequency Frequency, decimal Amount); +public record CreateIncomeRequest( + [Required][MaxLength(200)] string Name, + Frequency Frequency, + [Range(0, 9_999_999_999_999_999.99)] decimal Amount); -public record UpdateIncomeRequest(string Name, Frequency Frequency, decimal Amount); +public record UpdateIncomeRequest( + [Required][MaxLength(200)] string Name, + Frequency Frequency, + [Range(0, 9_999_999_999_999_999.99)] decimal Amount); -public record ReorderIncomesRequest(List OrderedIds); +public record ReorderIncomesRequest([Required] List OrderedIds); diff --git a/src/Budget.Api/DTOs/OutgoDtos.cs b/src/Budget.Api/DTOs/OutgoDtos.cs index 73f1cfe..81e8811 100644 --- a/src/Budget.Api/DTOs/OutgoDtos.cs +++ b/src/Budget.Api/DTOs/OutgoDtos.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using Budget.Api.Models; namespace Budget.Api.DTOs; @@ -18,21 +19,21 @@ public record OutgoDto( int SortOrder); public record CreateOutgoRequest( - string Name, - string? Category, + [Required][MaxLength(200)] string Name, + [MaxLength(100)] string? Category, OutgoType Type, Frequency Frequency, - decimal Amount, - string? PaymentSource, - string? Notes); + [Range(0, 9_999_999_999_999_999.99)] decimal Amount, + [MaxLength(100)] string? PaymentSource, + [MaxLength(1000)] string? Notes); public record UpdateOutgoRequest( - string Name, - string? Category, + [Required][MaxLength(200)] string Name, + [MaxLength(100)] string? Category, OutgoType Type, Frequency Frequency, - decimal Amount, - string? PaymentSource, - string? Notes); + [Range(0, 9_999_999_999_999_999.99)] decimal Amount, + [MaxLength(100)] string? PaymentSource, + [MaxLength(1000)] string? Notes); -public record ReorderOutgosRequest(List OrderedIds); +public record ReorderOutgosRequest([Required] List OrderedIds); diff --git a/src/Budget.Api/DTOs/ShareDtos.cs b/src/Budget.Api/DTOs/ShareDtos.cs index 2d79461..3e3f1de 100644 --- a/src/Budget.Api/DTOs/ShareDtos.cs +++ b/src/Budget.Api/DTOs/ShareDtos.cs @@ -1,9 +1,10 @@ +using System.ComponentModel.DataAnnotations; using Budget.Api.Models; namespace Budget.Api.DTOs; -public record ShareDto(Guid Id, string? SharedWithUserId, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt); +public record ShareDto(Guid Id, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt); -public record CreateShareRequest(string Email, SharePermission Permission); +public record CreateShareRequest([Required][MaxLength(200)][EmailAddress] string Email, SharePermission Permission); public record UpdateShareRequest(SharePermission Permission); diff --git a/src/Budget.Api/DTOs/SummaryDtos.cs b/src/Budget.Api/DTOs/SummaryDtos.cs index dfb1e9d..36ca441 100644 --- a/src/Budget.Api/DTOs/SummaryDtos.cs +++ b/src/Budget.Api/DTOs/SummaryDtos.cs @@ -17,4 +17,6 @@ public record SummaryDto( List Breakdown, PreTaxIncomeDto PreTaxIncome); -public record UpdateTaxRateRequest(decimal EffectiveTaxRate); +public record UpdateTaxRateRequest( + [System.ComponentModel.DataAnnotations.Range(0.0, 0.9999, ErrorMessage = "Effective tax rate must be between 0 and 0.9999.")] + decimal EffectiveTaxRate); diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index 4b2e42e..39635cd 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -62,7 +62,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions [HealthStatus.Degraded] = StatusCodes.Status200OK, [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable, } -}); +}).RequireAuthorization(); app.MapFallbackToFile("index.html"); diff --git a/src/Budget.Api/Services/ClaimsPrincipalExtensions.cs b/src/Budget.Api/Services/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..ade2742 --- /dev/null +++ b/src/Budget.Api/Services/ClaimsPrincipalExtensions.cs @@ -0,0 +1,10 @@ +using System.Security.Claims; + +namespace Budget.Api.Services; + +public static class ClaimsPrincipalExtensions +{ + /// Returns the sub claim, or null if absent. + public static string? GetUserId(this ClaimsPrincipal user) => + user.FindFirst("sub")?.Value; +} diff --git a/src/Budget.Client/src/types/index.ts b/src/Budget.Client/src/types/index.ts index 09ebc4e..186dbe3 100644 --- a/src/Budget.Client/src/types/index.ts +++ b/src/Budget.Client/src/types/index.ts @@ -43,7 +43,6 @@ export interface OutgoDto { export interface BudgetDto { id: string; name: string; - ownerUserId: string; effectiveTaxRate: number; createdAt: string; updatedAt: string; @@ -51,7 +50,6 @@ export interface BudgetDto { export interface ShareDto { id: string; - sharedWithUserId: string | null; sharedWithEmail: string; permission: SharePermission; isPending: boolean;