diff --git a/Distribution/Features/Ride/RideController.cs b/Distribution/Features/Ride/RideController.cs index f936c39..008d8ee 100644 --- a/Distribution/Features/Ride/RideController.cs +++ b/Distribution/Features/Ride/RideController.cs @@ -40,7 +40,7 @@ public class RideController(RideService service, ICurrentUserService currentUser { try { - var result = await service.AssignDriverAsync(id, request.FK_DriverProfileId, ct); + var result = await service.AssignDriverAsync(id, request.DriverProfileId, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } diff --git a/Distribution/Features/Ride/RideDTOs.cs b/Distribution/Features/Ride/RideDTOs.cs index ab1f25d..d43b3c3 100644 --- a/Distribution/Features/Ride/RideDTOs.cs +++ b/Distribution/Features/Ride/RideDTOs.cs @@ -25,12 +25,12 @@ public record RideResponse( public record CreateRideRequest( RideType RideType, - Guid FK_VehicleId, - Guid FK_DriverProfileId, - Guid FK_RouteId, - Guid? FK_SourceWarehouseId); + Guid VehicleId, + Guid DriverProfileId, + Guid RouteId, + Guid? SourceWarehouseId); -public record AssignRideDriverRequest(Guid FK_DriverProfileId); +public record AssignRideDriverRequest(Guid DriverProfileId); public record ActivateRideRequest(int Meterstart); diff --git a/Distribution/Features/Ride/RideService.cs b/Distribution/Features/Ride/RideService.cs index df20d44..384b860 100644 --- a/Distribution/Features/Ride/RideService.cs +++ b/Distribution/Features/Ride/RideService.cs @@ -36,19 +36,19 @@ public class RideService( public async Task CreateAsync(CreateRideRequest request, CancellationToken ct = default) { - var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId) + var vehicle = await vehicleRepo.GetByIdAsync(request.VehicleId) ?? throw new InvalidOperationException("Vehicle not found"); - var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.FK_SourceWarehouseId; + var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.SourceWarehouseId; if (!sourceWarehouseId.HasValue) throw new InvalidOperationException("Source warehouse is required for this vehicle"); var ride = new DeliveryRun { RideType = request.RideType, - FK_VehicleId = request.FK_VehicleId, - FK_DriverProfileId = request.FK_DriverProfileId, - FK_RouteId = request.FK_RouteId, + FK_VehicleId = request.VehicleId, + FK_DriverProfileId = request.DriverProfileId, + FK_RouteId = request.RouteId, FK_SourceWarehouseId = sourceWarehouseId.Value, Status = RideStatus.Draft }; diff --git a/Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs b/Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs deleted file mode 100644 index 0715e3f..0000000 --- a/Distribution/Infra/Authorization/Middleware/AuthMiddleware.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using Distribution.Features.User; -using Distribution.Infra.Authorization; -using Microsoft.IdentityModel.Tokens; - -namespace Distribution.Infra.Authorization.Middleware; - -/// -/// Ported from OSMCore's System/Authentication/AuthMiddleware.cs (global path-skip-based -/// middleware). NOT registered in Program.cs — this project's actual token validation is the -/// standard AddJwtBearer handler (see Program.cs), same as OSMCore's own recommendation. Kept here -/// unregistered, exactly like OSMCore's own copy, for reference/reuse if a project needs a -/// middleware-based (rather than AddJwtBearer-based) validation entry point. -/// -/// Two adaptations from the OSMCore original, both required for this to actually work against -/// this project's authorization system rather than just compiling: -/// 1. RSA key parsing goes through instead of RSA.FromXmlString, -/// which is Windows-only in modern .NET (see docs/authentication_flow.md §2.2 Gotcha). -/// 2. After validating the token, the user id is mapped onto a local User/Role row and -/// role_id/role_level/username claims are attached — the same step Program.cs's -/// OnTokenValidated performs for the AddJwtBearer path. Without this, a request authenticated -/// through this middleware would have no role_id claim, and every -/// [Authorize(Policy = "resource.action")] check (PermissionAuthorizationHandler) would fail. -/// -public sealed class AuthMiddleware(RequestDelegate next, IConfiguration configuration, ILogger logger) -{ - public async Task InvokeAsync(HttpContext context) - { - if (ShouldSkipAuthentication(context.Request.Path)) - { - await next(context); - return; - } - - if (!TryGetToken(context, out var token)) - { - await RejectAsync(context, "Authentication token is missing."); - return; - } - - try - { - var principal = ValidateToken(token); - var userRepo = context.RequestServices.GetRequiredService(); - if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo)) - { - await RejectAsync(context, "User not found or inactive."); - return; - } - - context.User = principal; - PopulateContextItems(context, principal); - await next(context); - } - catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException) - { - logger.LogInformation(ex, "JWT validation failed for request path {Path}", context.Request.Path); - await RejectAsync(context, "Invalid or expired token."); - } - } - - private ClaimsPrincipal ValidateToken(string token) - { - var jwtOptions = configuration.GetSection("Jwt").Get() - ?? throw new InvalidOperationException("Jwt configuration is missing."); - - if (string.IsNullOrWhiteSpace(jwtOptions.PublicKey)) - { - throw new InvalidOperationException("Jwt:PublicKey configuration is missing."); - } - - using var rsa = RsaXmlKeyReader.ReadPublicKey(jwtOptions.PublicKey); - - var handler = new JwtSecurityTokenHandler(); - var parameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtOptions.Issuer, - ValidateAudience = true, - ValidAudience = jwtOptions.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new RsaSecurityKey(rsa), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - - return handler.ValidateToken(token, parameters, out _); - } - - /// Mirrors Program.cs's OnTokenValidated mapping — see class remarks. - private static async Task TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo) - { - var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (!Guid.TryParse(subClaim, out var userId)) - return false; - - var user = await userRepo.GetByIdAsync(userId); - if (user is null || !user.IsActive) - return false; - - var identity = (ClaimsIdentity)principal.Identity!; - identity.AddClaim(new Claim("username", user.Username)); - identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString())); - identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString())); - return true; - } - - private static bool TryGetToken(HttpContext context, out string token) - { - token = string.Empty; - - if (TryGetBearerToken(context.Request.Headers.Authorization, out token)) - { - return true; - } - - var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault(); - if (TryGetBearerToken(headerToken, out token)) - { - return true; - } - - var cookieToken = context.Request.Cookies["access_token"]; - if (!string.IsNullOrWhiteSpace(cookieToken)) - { - token = cookieToken.Trim(); - return true; - } - - return false; - } - - private static bool TryGetBearerToken(string? authorizationHeader, out string token) - { - token = string.Empty; - - if (string.IsNullOrWhiteSpace(authorizationHeader)) - { - return false; - } - - const string bearerPrefix = "Bearer "; - if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) - { - token = authorizationHeader.Trim(); - return !string.IsNullOrWhiteSpace(token); - } - - token = authorizationHeader[bearerPrefix.Length..].Trim(); - return !string.IsNullOrWhiteSpace(token); - } - - /// - /// Adapted from OSMCore's path list: this project has no Shopify-style webhook route, so that - /// skip is dropped; /api/auth is narrowed to just login/register (this project's /api/auth/me - /// and /api/auth/logout are meant to require auth, unlike a blanket "/api/auth" skip). - /// - private static bool ShouldSkipAuthentication(PathString path) - => path.Equals("/health", StringComparison.OrdinalIgnoreCase) - || path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase) - || path.Equals("/api/auth/register", StringComparison.OrdinalIgnoreCase) - || path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase); - - private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal) - { - context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - context.Items["Username"] = principal.FindFirst("username")?.Value; - context.Items["RoleId"] = principal.FindFirst("role_id")?.Value; - context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value; - } - - private static async Task RejectAsync(HttpContext context, string message) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new ApiResponse - { - StatusCode = StatusCodes.Status401Unauthorized, - Success = false, - Message = message, - Data = null - }); - } -} diff --git a/Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs b/Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs deleted file mode 100644 index 082e57e..0000000 --- a/Distribution/Infra/Authorization/Middleware/JwtMiddleware.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using Distribution.Features.User; -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.IdentityModel.Tokens; - -namespace Distribution.Infra.Authorization.Middleware; - -/// -/// Ported from OSMCore's System/Authentication/JwtMiddleware.cs (action-filter attribute pattern, -/// applied per controller/action as [JwtMiddleware.CustomMiddleware]). Not applied anywhere in -/// this project — the standard AddJwtBearer handler (Program.cs) is this project's actual -/// validation path. -/// -/// docs/authentication_flow.md explicitly calls out not running this alongside AuthMiddleware or -/// AddJwtBearer at the same time — all three independently re-validate the same RSA-signed token, -/// which is wasteful and error-prone (this is the same duplication OSMCore's own guide flags as a -/// Gotcha). Kept here, unregistered, only as a ported reference implementation. -/// -/// Same two adaptations as AuthMiddleware: portable RSA parsing via -/// , and local User/Role → role_id/role_level claim mapping after -/// validation (see AuthMiddleware's remarks for why this is required, not optional). -/// -public sealed class JwtMiddleware -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] - public sealed class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter - { - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - var configuration = context.HttpContext.RequestServices.GetService(); - - if (!TryGetToken(context.HttpContext, out var token)) - { - await RejectAsync(context.HttpContext, "Authentication token is missing."); - return; - } - - try - { - if (configuration is null) - { - throw new InvalidOperationException("Jwt configuration is missing."); - } - - var principal = ValidateToken(token, configuration); - - var userRepo = context.HttpContext.RequestServices.GetRequiredService(); - if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo)) - { - await RejectAsync(context.HttpContext, "User not found or inactive."); - return; - } - - context.HttpContext.User = principal; - PopulateContextItems(context.HttpContext, principal); - - await next(); - } - catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException or InvalidOperationException) - { - await RejectAsync(context.HttpContext, "Invalid or expired token."); - } - } - - private static ClaimsPrincipal ValidateToken(string token, IConfiguration configuration) - { - var xmlKey = configuration["Jwt:PublicKey"]; - - if (string.IsNullOrWhiteSpace(xmlKey)) - throw new SecurityTokenException("JWT public key not configured."); - - using var rsa = RsaXmlKeyReader.ReadPublicKey(xmlKey); - var rsaParameters = rsa.ExportParameters(false); - - var key = new RsaSecurityKey(rsaParameters) - { - KeyId = ComputeKeyId(rsaParameters) - }; - - var handler = new JwtSecurityTokenHandler(); - var parameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = configuration["Jwt:Issuer"], - ValidateAudience = true, - ValidAudience = configuration["Jwt:Audience"], - ValidateIssuerSigningKey = true, - IssuerSigningKey = key, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - - return handler.ValidateToken(token, parameters, out _); - } - - /// Mirrors Program.cs's OnTokenValidated mapping — see class remarks. - private static async Task TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo) - { - var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (!Guid.TryParse(subClaim, out var userId)) - return false; - - var user = await userRepo.GetByIdAsync(userId); - if (user is null || !user.IsActive) - return false; - - var identity = (ClaimsIdentity)principal.Identity!; - identity.AddClaim(new Claim("username", user.Username)); - identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString())); - identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString())); - return true; - } - - private static bool TryGetToken(HttpContext context, out string token) - { - token = string.Empty; - - if (TryGetBearerToken(context.Request.Headers.Authorization, out token)) - { - return true; - } - - var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault(); - if (TryGetBearerToken(headerToken, out token)) - { - return true; - } - - var cookieToken = context.Request.Cookies["access_token"]; - if (!string.IsNullOrWhiteSpace(cookieToken)) - { - token = cookieToken.Trim(); - return true; - } - - return false; - } - - private static bool TryGetBearerToken(string? authorizationHeader, out string token) - { - token = string.Empty; - - if (string.IsNullOrWhiteSpace(authorizationHeader)) - { - return false; - } - - const string bearerPrefix = "Bearer "; - if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) - { - token = authorizationHeader.Trim(); - return !string.IsNullOrWhiteSpace(token); - } - - token = authorizationHeader[bearerPrefix.Length..].Trim(); - return !string.IsNullOrWhiteSpace(token); - } - - private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal) - { - context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - context.Items["Username"] = principal.FindFirst("username")?.Value; - context.Items["RoleId"] = principal.FindFirst("role_id")?.Value; - context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value; - } - - private static async Task RejectAsync(HttpContext context, string message) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new ApiResponse - { - StatusCode = StatusCodes.Status401Unauthorized, - Success = false, - Message = message, - Data = null - }); - } - - private static string ComputeKeyId(RSAParameters publicParameters) - { - var modulus = publicParameters.Modulus ?? []; - var exponent = publicParameters.Exponent ?? []; - var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}"; - var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)); - return Base64UrlEncoder.Encode(hash); - } - } -} diff --git a/Distribution/docs/testbug.md b/Distribution/docs/testbug.md index e34c9f8..6aa8237 100644 --- a/Distribution/docs/testbug.md +++ b/Distribution/docs/testbug.md @@ -6,7 +6,6 @@ Running log of backend API failures found during testing and the corresponding f | # | Area | Backend API Failure | Frontend Change Needed | Date | |---|------|----------------------|--------------------------|------| -| 1 | Operations → Rides (Create Ride) | On create (not save) | Add a View icon to the action column in Operations → Rides table | 2026-09-01 | | 2 | Operations → Loading Sheets | | Add a View icon to the action column in Operations → Loading Sheets table | 2026-09-01 | | 3 | Sales (Create Sale) | Sale create not working | Update the Create Sale modal UI |Sale summary calculation not display | Add view icon to the action | 2026-09-01 | | 4 | Orders | | Add a View icon to the action column in Orders table | 2026-09-01 | @@ -28,12 +27,19 @@ Running log of backend API failures found during testing and the corresponding f | 20 | Network → Routes | Inactive status doesn't work properly | Check Manage Owner and Manage Stores actions; add View icon to the action column; check Cancel action too | 2026-09-02 | | 21 | Network → Vehicles | | Add a View icon to the action column; check Cancel action | 2026-09-02 | | 22 | Network → Warehouses | Delete doesn't work | Add a View icon to the action column; check Inactive status | 2026-09-02 | +| 23 | Returns & Claims Management | `ReturnController`, `ClaimController`, `SupplierClaimController` all exist and are fully implemented | No frontend integration at all — no API client, no pages/UI for order returns, claims, or supplier claims. Build the module end-to-end | 2026-09-02 | +| 24 | Procurement → Purchase Returns | `PurchaseReturn` CRUD fully implemented | No frontend API client and no UI at all for purchase returns. Build the module end-to-end | 2026-09-02 | +| 25 | Orders (CRUD gap) | Create/Update/Delete endpoints exist and work | Orders page is read-only in the frontend — only list/get are wired up; add Create/Update/Delete UI | 2026-09-02 | +| 26 | Pricing → Schemes (unused capability) | Eligibility check, spend/ROI analytics, and apply-scheme-to-order endpoints all exist | None of these are wired into the frontend — only basic scheme CRUD is; add UI for eligibility check, spend/ROI view, and applying a scheme to an order | 2026-09-02 | +| 27 | Misc missing Delete actions | Delete/soft-delete endpoints exist for Users, Stores, Price Lists, and Suppliers | No Delete UI wired up for these on their respective pages | 2026-09-02 | +| 29 | Operations → Rides | | Add a View icon to the action column in Operations → Rides table (create-ride backend bug fixed, see Resolved #1) | 2026-09-02 | +| 28 | Auth (root cause of #10 and likely others) | The actually-registered middleware is `Distribution/Middleware/AuthMiddleware.cs` (`app.UseMiddleware()` in Program.cs) — a plain, symmetric-key JWT check, NOT the RSA-based `Infra/Authorization/Middleware/AuthMiddleware.cs`/`JwtMiddleware.cs`, which despite their doc comments claiming to be "reference-only," are actually unregistered/dead. The active middleware reads `config["Jwt:Key"]`, but `appsettings.json` only defines `Jwt:PublicKey` (RSA XML, used by AuthHex-issued tokens) — so `Jwt:Key` is always null, the whole validation block is skipped, and `context.User` is never set for ANY request, valid token or not. It also never attaches `role_id`/`role_level`/`username` claims the way the dead Infra middleware does. Net effect: `ICurrentUserService.UserId`/`RoleId` are always null, so every `currentUser.UserId ?? throw new UnauthorizedAccessException()` check fails (this is the actual cause of bug #10, Procurement → Orders) and every permission/role-level check silently fails too | Decision: keep `Distribution/Middleware/AuthMiddleware.cs` (outside Infra) as the one true auth flow going forward; delete the dead unregistered duplicates `Infra/Authorization/Middleware/AuthMiddleware.cs` and `JwtMiddleware.cs`. Then fix the active middleware to validate against `Jwt:PublicKey` (RSA), reject (not silently swallow) invalid/missing tokens, and attach `role_id`/`role_level`/`username` claims from the local User/Role row after validation | 2026-09-02 | ## Resolved | # | Area | Backend API Failure | Frontend Change Needed | Fixed In | Date | |---|------|----------------------|--------------------------|----------|------| -| | | | | | | +| 1 | Operations → Rides (Create Ride) | Create Ride failed with "Vehicle not found" | — | Fixed DTO field naming mismatch | 2026-09-02 | ## Implementation Guide