Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 198 additions & 24 deletions DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions DocAnalytics.Api/Configuration/RateLimitOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public sealed class RateLimitOptions
public RateLimitPolicySettings Reads { get; set; } = new() { PermitLimit = 100, WindowSeconds = 60 };
/// <summary>Limits for export endpoints (tight, per user).</summary>
public RateLimitPolicySettings Export { get; set; } = new() { PermitLimit = 3, WindowSeconds = 60 };
/// <summary>Limits for 2FA code-verification endpoints (per authenticated user, else per IP).</summary>
public RateLimitPolicySettings Mfa { get; set; } = new() { PermitLimit = 5, WindowSeconds = 300 };
}

/// <summary>Fixed-window limit settings for a single rate-limit policy.</summary>
Expand Down
15 changes: 15 additions & 0 deletions DocAnalytics.Api/Configuration/RateLimitingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public static class RateLimitingExtensions
public const string ReadsPolicy = "reads";
/// <summary>Policy name for export endpoints (tight, partitioned by user).</summary>
public const string ExportPolicy = "export";
/// <summary>Policy name for 2FA verification endpoints (partitioned by user, else IP).</summary>
public const string MfaPolicy = "mfa";


/// <summary>Configures the rate limiter, its policies, and the 429 rejection response.</summary>
/// <param name="services">The service collection.</param>
Expand Down Expand Up @@ -61,6 +64,18 @@ public static IServiceCollection AddRateLimitingFeature(
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));

// mfa: 2FA code attempts — per authenticated user where available, else per IP
// (UserKey already falls back to IP, exactly what "partitioned by user/IP" means here).
options.AddPolicy(MfaPolicy, httpContext =>
RateLimitPartition.GetFixedWindowLimiter(UserKey(httpContext), _ => new FixedWindowRateLimiterOptions
{
PermitLimit = opts.Mfa.PermitLimit,
Window = TimeSpan.FromSeconds(opts.Mfa.WindowSeconds),
QueueLimit = opts.Mfa.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));


// 429 body in the standard ApiResponse envelope + Retry-After (your R1 code, generic message)
options.OnRejected = async (context, token) =>
{
Expand Down
103 changes: 100 additions & 3 deletions DocAnalytics.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,23 @@ public async Task<IActionResult> Login([FromBody] LoginRequest req, Cancellation

await _lockout.ResetAsync(email, ct);

if (result.RequiresTwoFactor)
{
// No refresh cookie yet — the real session starts only after /auth/login/2fa succeeds.
return Ok(ApiResponse<TwoFactorChallengeResponse>.Ok(
new TwoFactorChallengeResponse(true, result.ChallengeToken!)));
}

// Refresh token now lives ONLY in an HttpOnly cookie — never in the JSON body.
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.User.Id, clientIp, ct);
var userAgent = Request.Headers["User-Agent"].ToString();
var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.Login!.User.Id, clientIp, userAgent, ct);
SetRefreshCookie(raw, refreshExpiresAt);

return Ok(ApiResponse<LoginResponse>.Ok(result));
return Ok(ApiResponse<LoginResponse>.Ok(result.Login!));
}


/// <summary>Exchanges the refresh-token cookie for a fresh access token and rotates the cookie.</summary>
[AllowAnonymous]
[HttpPost("refresh")]
Expand All @@ -84,7 +93,9 @@ public async Task<IActionResult> Refresh(CancellationToken ct)
"INVALID_REFRESH_TOKEN", "Refresh token is missing."));

var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var rotated = await _refresh.ValidateAndRotateAsync(presented, ip, ct);
var userAgent = Request.Headers["User-Agent"].ToString();
var rotated = await _refresh.ValidateAndRotateAsync(presented, ip, userAgent, ct);

if (rotated is null)
{
DeleteRefreshCookie(); // clear the bad cookie
Expand Down Expand Up @@ -160,6 +171,92 @@ public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest r
return Ok(ApiResponse<object>.Ok(new { reset = true }));
}

/// <summary>Completes a 2FA-gated login: exchanges the challenge token + a TOTP/recovery code for a real session.</summary>
[AllowAnonymous]
[HttpPost("login/2fa")]
[EnableRateLimiting("mfa")]
public async Task<IActionResult> LoginTwoFactor([FromBody] TwoFactorLoginRequest req, CancellationToken ct)
{
var result = await _auth.LoginWithTwoFactorAsync(req, ct);
if (result is null)
return Unauthorized(ApiResponse<object>.Fail("INVALID_2FA_CODE", "That code is invalid or expired."));

var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = Request.Headers["User-Agent"].ToString();
var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.User.Id, clientIp, userAgent, ct);
SetRefreshCookie(raw, refreshExpiresAt);

return Ok(ApiResponse<LoginResponse>.Ok(result));
}

/// <summary>Begins 2FA setup for the current user: returns the secret + otpauth URI for client-side QR rendering.</summary>
[Authorize]
[HttpPost("2fa/setup")]
public async Task<IActionResult> SetupTwoFactor(CancellationToken ct)
{
var result = await _auth.SetupTwoFactorAsync(_currentUser.UserId, ct);
return Ok(ApiResponse<TwoFactorSetupResponse>.Ok(result));
}

/// <summary>Confirms 2FA setup with a valid code: enables 2FA, returns one-time recovery codes.</summary>
[Authorize]
[HttpPost("2fa/confirm")]
[EnableRateLimiting("mfa")]
public async Task<IActionResult> ConfirmTwoFactor([FromBody] TwoFactorConfirmRequest req, CancellationToken ct)
{
var (error, resultBody) = await _auth.ConfirmTwoFactorAsync(_currentUser.UserId, req.Code, ct);
if (error is not null)
return BadRequest(ApiResponse<object>.Fail("INVALID_2FA_CODE", error));

return Ok(ApiResponse<TwoFactorConfirmResponse>.Ok(resultBody!));
}

/// <summary>Disables 2FA after re-verifying the password.</summary>
[Authorize]
[HttpPost("2fa/disable")]
public async Task<IActionResult> DisableTwoFactor([FromBody] TwoFactorDisableRequest req, CancellationToken ct)
{
var error = await _auth.DisableTwoFactorAsync(_currentUser.UserId, req.Password, ct);
if (error is not null)
return BadRequest(ApiResponse<object>.Fail("INVALID_PASSWORD", error));

return Ok(ApiResponse<object>.Ok(new { disabled = true }));
}

/// <summary>Lists this user's active sessions/devices.</summary>
[Authorize]
[HttpGet("sessions")]
public async Task<IActionResult> GetSessions(CancellationToken ct)
{
var currentRaw = Request.Cookies["refresh_token"];
var sessions = await _refresh.ListActiveSessionsAsync(_currentUser.UserId, currentRaw, ct);
return Ok(ApiResponse<IReadOnlyList<SessionDto>>.Ok(sessions));
}

/// <summary>Revokes one session (log out that device).</summary>
[Authorize]
[HttpDelete("sessions/{id:guid}")]
public async Task<IActionResult> RevokeSession(Guid id, CancellationToken ct)
{
var ok = await _refresh.RevokeSessionAsync(_currentUser.UserId, id, ct);
if (!ok) return NotFound(ApiResponse<object>.Fail("SESSION_NOT_FOUND", "Session not found."));
return Ok(ApiResponse<object>.Ok(new { revoked = true }));
}

/// <summary>Logs out every OTHER device (keeps the current session active).</summary>
[Authorize]
[HttpPost("sessions/revoke-others")]
public async Task<IActionResult> RevokeOtherSessions(CancellationToken ct)
{
var currentRaw = Request.Cookies["refresh_token"];
if (string.IsNullOrEmpty(currentRaw))
return Unauthorized(ApiResponse<object>.Fail("INVALID_REFRESH_TOKEN", "No active session."));

var count = await _refresh.RevokeAllOtherSessionsAsync(_currentUser.UserId, currentRaw, ct);
return Ok(ApiResponse<object>.Ok(new { revoked_count = count }));
}



// ── refresh-token cookie helpers ────────────────────────────────────────
private void SetRefreshCookie(string rawToken, DateTime expiresAt)
Expand Down
14 changes: 14 additions & 0 deletions DocAnalytics.Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUser current
public virtual DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();

public virtual DbSet<InvoiceHeader> InvoiceHeaders => Set<InvoiceHeader>();
public virtual DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();



protected override void OnModelCreating(ModelBuilder b)
Expand Down Expand Up @@ -157,11 +159,14 @@ protected override void OnModelCreating(ModelBuilder b)
e.Property(x => x.TokenHash).HasMaxLength(88).IsRequired(); // base64 SHA-256 = 44 chars; pad room
e.Property(x => x.CreatedByIp).HasMaxLength(64);
e.Property(x => x.ReplacedByTokenHash).HasMaxLength(88);
e.Property(x => x.UserAgent).HasMaxLength(500); // NEW
e.Property(x => x.IpAddress).HasMaxLength(64); // NEW
e.HasIndex(x => x.TokenHash).IsUnique(); // fast lookup on refresh
e.HasIndex(x => x.UserId); // revoke-all-for-user
e.Ignore(x => x.IsActive);
});


b.Entity<PasswordResetToken>(e =>
{
e.ToTable("password_reset_tokens");
Expand All @@ -173,6 +178,15 @@ protected override void OnModelCreating(ModelBuilder b)
e.Ignore(x => x.IsActive);
});

b.Entity<TwoFactorRecoveryCode>(e =>
{
e.ToTable("two_factor_recovery_codes");
e.HasKey(x => x.Id);
e.Property(x => x.CodeHash).HasMaxLength(100).IsRequired();
e.HasIndex(x => x.UserId);
});




// ---- GLOBAL TENANT/SITE FILTER (every ITenantScoped entity) ----
Expand Down
Loading
Loading