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
51 changes: 47 additions & 4 deletions DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,30 @@
using DocAnalytics.Api.Controllers;
using DocAnalytics.Domain.Common;
using DocAnalytics.Service.Auth;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;

namespace DocAnalytics.Api.Tests.Controllers;

public class AuthControllerTests
{
// Login touches HttpContext.Connection / Response — give the controller a real context.
private static AuthController NewController(
IAuthService auth, ICurrentUser user, ILoginLockoutService lockout)
=> new(auth, user, lockout)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};

[Fact]
public async Task Login_returns_200_with_envelope_on_success()
{
var response = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List<SiteDto>(), false);
var auth = new Mock<IAuthService>();
auth.Setup(a => a.LoginAsync(It.IsAny<LoginRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync(response);

var result = await new AuthController(auth.Object, Mock.Of<ICurrentUser>())
var result = await NewController(auth.Object, Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>())
.Login(new LoginRequest("a@org.com", "pw"), default);

var ok = Assert.IsType<OkObjectResult>(result);
Expand All @@ -30,14 +39,48 @@ public async Task Login_returns_401_on_invalid_credentials()
var auth = new Mock<IAuthService>();
auth.Setup(a => a.LoginAsync(It.IsAny<LoginRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync((LoginResponse?)null);

var result = await new AuthController(auth.Object, Mock.Of<ICurrentUser>())
var result = await NewController(auth.Object, Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>())
.Login(new LoginRequest("a@org.com", "bad"), default);

var unauth = Assert.IsType<UnauthorizedObjectResult>(result);
var body = Assert.IsType<ApiResponse<object>>(unauth.Value);
Assert.Equal("INVALID_CREDENTIALS", body.Error!.Code);
}

[Fact]
public async Task Login_returns_429_when_account_locked()
{
var auth = new Mock<IAuthService>();
var lockout = new Mock<ILoginLockoutService>();
lockout.Setup(l => l.IsLockedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((true, 120));

var result = await NewController(auth.Object, Mock.Of<ICurrentUser>(), lockout.Object)
.Login(new LoginRequest("a@org.com", "pw"), default);

var obj = Assert.IsType<ObjectResult>(result);
Assert.Equal(429, obj.StatusCode);
var body = Assert.IsType<ApiResponse<object>>(obj.Value);
Assert.Equal("RATE_LIMITED", body.Error!.Code);
// Locked out BEFORE credentials are ever checked.
auth.Verify(a => a.LoginAsync(It.IsAny<LoginRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task Login_registers_failure_on_bad_password()
{
var auth = new Mock<IAuthService>();
auth.Setup(a => a.LoginAsync(It.IsAny<LoginRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync((LoginResponse?)null);
var lockout = new Mock<ILoginLockoutService>();
lockout.Setup(l => l.IsLockedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((false, 0));

var result = await NewController(auth.Object, Mock.Of<ICurrentUser>(), lockout.Object)
.Login(new LoginRequest("a@org.com", "bad"), default);

Assert.IsType<UnauthorizedObjectResult>(result);
lockout.Verify(l => l.RegisterFailureAsync("a@org.com", It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task Me_returns_401_when_user_not_found()
{
Expand All @@ -46,7 +89,7 @@ public async Task Me_returns_401_when_user_not_found()
var auth = new Mock<IAuthService>();
auth.Setup(a => a.GetMeAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())).ReturnsAsync((MeResponse?)null);

var result = await new AuthController(auth.Object, currentUser.Object).Me(default);
var result = await NewController(auth.Object, currentUser.Object, Mock.Of<ILoginLockoutService>()).Me(default);

Assert.IsType<UnauthorizedResult>(result);
}
Expand All @@ -61,7 +104,7 @@ public async Task Me_returns_200_with_user_and_sites()
var auth = new Mock<IAuthService>();
auth.Setup(a => a.GetMeAsync(userId, It.IsAny<CancellationToken>())).ReturnsAsync(me);

var result = await new AuthController(auth.Object, currentUser.Object).Me(default);
var result = await NewController(auth.Object, currentUser.Object, Mock.Of<ILoginLockoutService>()).Me(default);

var ok = Assert.IsType<OkObjectResult>(result);
var body = Assert.IsType<ApiResponse<MeResponse>>(ok.Value);
Expand Down
29 changes: 25 additions & 4 deletions DocAnalytics.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
using DocAnalytics.Api.Common;
using DocAnalytics.Api.Common;
using DocAnalytics.Domain.Common;
using DocAnalytics.Service.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; // ← NEW

namespace DocAnalytics.Api.Controllers;

Expand All @@ -12,22 +13,43 @@ public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
private readonly ICurrentUser _currentUser;
private readonly ILoginLockoutService _lockout; // ← NEW

public AuthController(IAuthService auth, ICurrentUser currentUser)
public AuthController(IAuthService auth, ICurrentUser currentUser, ILoginLockoutService lockout)
{
_auth = auth;
_currentUser = currentUser;
_lockout = lockout;
}

[AllowAnonymous] // the only auth endpoint with no token
[AllowAnonymous]
[HttpPost("login")]
[EnableRateLimiting("login")] // ← IP throttle (Step 7)
public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct)
{
var email = req.Email ?? string.Empty;

// 1) Account-level lockout (survives restarts / spans multiple IPs).
var (locked, retryAfter) = await _lockout.IsLockedAsync(email, ct);
if (locked)
{
Response.Headers.RetryAfter = retryAfter.ToString();
return StatusCode(StatusCodes.Status429TooManyRequests, ApiResponse<object>.Fail(
"RATE_LIMITED", "Too many login attempts. Please try again later."));
}

// 2) Verify credentials.
var result = await _auth.LoginAsync(req, ct);
if (result is null)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
await _lockout.RegisterFailureAsync(email, ip, ct); // count the miss (unknown emails too → no enumeration)
return Unauthorized(ApiResponse<object>.Fail(
"INVALID_CREDENTIALS", "Email or password is incorrect."));
}

// 3) Success → clear the counter.
await _lockout.ResetAsync(email, ct);
return Ok(ApiResponse<LoginResponse>.Ok(result));
}

Expand All @@ -40,7 +62,6 @@ public async Task<IActionResult> Me(CancellationToken ct)
return Ok(ApiResponse<MeResponse>.Ok(result));
}

// Forced first-login reset (and general password change)
[Authorize]
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req, CancellationToken ct)
Expand Down
58 changes: 57 additions & 1 deletion DocAnalytics.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using DocAnalytics.Api.Middleware;
using DocAnalytics.Api.Realtime;
using DocAnalytics.Api.Swagger;
using DocAnalytics.Api.Auth; // JwtSettings
using DocAnalytics.Api.Common; // ApiResponse<T>
using DocAnalytics.Data;
using DocAnalytics.Data.Seeding;
using DocAnalytics.Service;
Expand All @@ -16,14 +18,34 @@
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using System.Text.Json;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;


var builder = WebApplication.CreateBuilder(args);

var conn = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(conn) ||
conn.Contains("SET_VIA_USER_SECRETS", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"ConnectionStrings:Default is not configured. Set it via user-secrets or the ConnectionStrings__Default env var.");
}


builder.Services.AddCurrentUser(); // Api
builder.Services.AddPersistence(builder.Configuration); // Data
builder.Services.AddApplicationServices(); // Service
builder.Services.AddJwtAuth(builder.Configuration); // Api

builder.Services.AddOptions<JwtSettings>()
.Bind(builder.Configuration.GetSection("Jwt"))
.Validate(s => !string.IsNullOrWhiteSpace(s.Key) && s.Key != "SET_VIA_USER_SECRETS",
"Jwt:Key is missing — set via user-secrets (dev) or env var Jwt__Key (Docker/prod).")
.Validate(s => !string.IsNullOrEmpty(s.Key) && s.Key.Length >= 32,
"Jwt:Key must be at least 32 characters (256-bit) for HMAC-SHA256.")
.ValidateOnStart();

builder.Services.AddSecurityFoundation(builder.Configuration); // 0.3
builder.Services.AddPersistedDataProtection(); // 0.4

Expand Down Expand Up @@ -60,7 +82,40 @@
builder.Services.AddInvoicePipeline(builder.Configuration);
builder.Services.AddHostedService<DocAnalytics.Api.BackgroundServices.ExtractionWorker>();


builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

options.AddPolicy("login", httpContext =>
{
// Client IP is correct behind nginx because UseForwardedHeaders() runs first (R1).
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});

options.OnRejected = async (context, token) =>
{
var res = context.HttpContext.Response;
res.StatusCode = StatusCodes.Status429TooManyRequests;
res.ContentType = "application/json";

if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
res.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();

var body = ApiResponse<object>.Fail(
"RATE_LIMITED", "Too many login attempts. Please try again later.");
var json = JsonSerializer.Serialize(body,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });

await res.WriteAsync(json, token);
};
});

builder.Services.AddControllers().AddJsonOptions(o =>
{
Expand Down Expand Up @@ -89,6 +144,7 @@

// 5) CORS (single, config-driven policy) — before auth.
app.UseCors(CorsOptions.PolicyName);
app.UseRateLimiter(); // ← NEW: throttle before auth work happens

if (app.Environment.IsDevelopment())
{
Expand Down
13 changes: 12 additions & 1 deletion DocAnalytics.Data/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using DocAnalytics.Domain.Common;
using DocAnalytics.Domain.Common;
using DocAnalytics.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
Expand Down Expand Up @@ -33,6 +33,8 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUser current

public virtual DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

public virtual DbSet<LoginAttempt> LoginAttempts => Set<LoginAttempt>();




Expand Down Expand Up @@ -138,6 +140,15 @@ protected override void OnModelCreating(ModelBuilder b)
"ck_users_role", "role IN ('Developer','Admin','Viewer')"));
});

// ---- login brute-force tracker (pre-auth: NOT tenant-scoped) ----
b.Entity<LoginAttempt>(e =>
{
e.ToTable("login_attempts");
e.HasKey(x => x.Id);
e.Property(x => x.Email).HasMaxLength(400).IsRequired();
e.Property(x => x.Ip).HasMaxLength(64);
e.HasIndex(x => x.Email).IsUnique(); // one running counter per account
});

// ---- GLOBAL TENANT/SITE FILTER (every ITenantScoped entity) ----
foreach (var et in b.Model.GetEntityTypes())
Expand Down
Loading
Loading