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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user