feat(stability): unify readiness and recovery
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped

This commit is contained in:
AzuTear
2026-08-01 01:21:33 +02:00
parent 38282e4f7f
commit cd8c78d165
67 changed files with 2616 additions and 601 deletions
+40 -20
View File
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.RateLimiting;
using Nexus.Api.Security;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -14,19 +15,10 @@ namespace Nexus.Api.Controllers;
[Route("api/v1/auth")]
public class AuthController(
IAuthService authService,
IAntiforgery antiforgery,
IConfiguration config,
IHostEnvironment env,
LoginAttemptTracker attemptTracker) : ControllerBase
{
[HttpGet("csrf")]
[AllowAnonymous]
public IActionResult GetCsrfToken()
{
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
return Ok(new { token = tokens.RequestToken });
}
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
@@ -51,14 +43,16 @@ public class AuthController(
HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
// Return a structured body so the frontend can display remaining attempts
return Results.Json(new
{
error = "invalid_credentials",
message = "Invalid email or password.",
remaining,
retryAfterSeconds
}, statusCode: 401);
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication failed",
detail: "Invalid email or password.",
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Unauthenticated,
["remaining"] = remaining,
["retryAfterSeconds"] = retryAfterSeconds
});
}
// Success — reset attempt counter
@@ -75,14 +69,17 @@ public class AuthController(
[EnableRateLimiting("auth")]
public async Task<IResult> Refresh(CancellationToken ct)
{
if (!BrowserRequestOriginGuard.IsAllowed(Request))
return CrossSiteRequestRejected();
if (!Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
return Results.Unauthorized();
return Unauthenticated("No active refresh session was provided.");
var session = await authService.RefreshAsync(refreshToken!, ct);
if (session is null)
{
ClearRefreshCookie(Response);
return Results.Unauthorized();
return Unauthenticated("The refresh session is invalid or expired.");
}
SetRefreshCookie(Response, session.RefreshToken);
@@ -96,6 +93,9 @@ public class AuthController(
[AllowAnonymous]
public async Task<IResult> Logout(CancellationToken ct)
{
if (!BrowserRequestOriginGuard.IsAllowed(Request))
return CrossSiteRequestRejected();
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
await authService.RevokeAsync(refreshToken!, ct);
@@ -168,6 +168,26 @@ public class AuthController(
User = session.User
};
private static IResult Unauthenticated(string detail)
=> Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication required",
detail: detail,
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Unauthenticated
});
private static IResult CrossSiteRequestRejected()
=> Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Cross-site request rejected",
detail: "Refresh and logout requests must originate from the Nexus origin.",
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Forbidden
});
private void SetRefreshCookie(HttpResponse response, string token)
{
var days = config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;