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
10 changes: 8 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
version: 2
updates:
# ---- Backend: NuGet (.NET) ----
- package-ecosystem: "nuget"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
dotnet:
patterns: [ "*" ]

# ---- Frontend: npm (Angular app) ----
- package-ecosystem: "npm"
directory: "/docanalytics-web"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
61 changes: 45 additions & 16 deletions DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ 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 refresh = new Mock<IRefreshTokenService>();
refresh.Setup(r => r.IssueAsync(It.IsAny<Guid>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7))); // ← valid expiry, no MinValue blow-up

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

var ok = Assert.IsType<OkObjectResult>(result);
var body = Assert.IsType<ApiResponse<LoginResponse>>(ok.Value);
Assert.Equal("jwt", body.Data!.Token);
}


[Fact]
public async Task Login_returns_401_on_invalid_credentials()
{
Expand Down Expand Up @@ -87,7 +91,7 @@ public async Task Login_registers_failure_on_bad_password()
}

[Fact]
public async Task Login_issues_refresh_token_on_success()
public async Task Login_sets_refresh_token_cookie_on_success()
{
var response = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List<SiteDto>(), false);
var auth = new Mock<IAuthService>();
Expand All @@ -96,16 +100,19 @@ public async Task Login_issues_refresh_token_on_success()
refresh.Setup(r => r.IssueAsync(It.IsAny<Guid>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7)));

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

var ok = Assert.IsType<OkObjectResult>(result);
var body = Assert.IsType<ApiResponse<LoginResponse>>(ok.Value);
Assert.Equal("raw-refresh", body.Data!.RefreshToken);
Assert.IsType<OkObjectResult>(result);
// refresh token is now in the HttpOnly cookie, NOT the body
var setCookie = controller.Response.Headers["Set-Cookie"].ToString();
Assert.Contains("refresh_token=raw-refresh", setCookie);
Assert.Contains("httponly", setCookie.ToLowerInvariant());
}


[Fact]
public async Task Refresh_returns_200_with_rotated_tokens()
public async Task Refresh_returns_200_and_rotates_cookie()
{
var user = new User { Id = Guid.NewGuid(), Email = "a@org.com", Role = "Viewer" };
var refresh = new Mock<IRefreshTokenService>();
Expand All @@ -114,42 +121,54 @@ public async Task Refresh_returns_200_with_rotated_tokens()
var jwt = new Mock<IJwtTokenService>();
jwt.Setup(j => j.CreateToken(user)).Returns("new-access");

var result = await NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object, jwt.Object)
.Refresh(new RefreshRequest("old-refresh"), default);
var controller = NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object, jwt.Object);
controller.HttpContext.Request.Headers["Cookie"] = "refresh_token=old-refresh";

var result = await controller.Refresh(default);

var ok = Assert.IsType<OkObjectResult>(result);
var body = Assert.IsType<ApiResponse<RefreshResponse>>(ok.Value);
Assert.Equal("new-access", body.Data!.Token);
Assert.Equal("new-refresh", body.Data!.RefreshToken);
Assert.Equal("new-access", body.Data!.Token); // only the access token is in the body now

var setCookie = controller.Response.Headers["Set-Cookie"].ToString();
Assert.Contains("refresh_token=new-refresh", setCookie); // rotated cookie
refresh.Verify(r => r.ValidateAndRotateAsync("old-refresh", It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Once);
}


[Fact]
public async Task Refresh_returns_401_when_token_invalid()
{
var refresh = new Mock<IRefreshTokenService>();
refresh.Setup(r => r.ValidateAndRotateAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(((User, string, DateTime)?)null);

var result = await NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object)
.Refresh(new RefreshRequest("bad"), default);
var controller = NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object);
controller.HttpContext.Request.Headers["Cookie"] = "refresh_token=bad";

var result = await controller.Refresh(default);

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


[Fact]
public async Task Logout_revokes_token_and_returns_200()
{
var refresh = new Mock<IRefreshTokenService>();

var result = await NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object)
.Logout(new LogoutRequest("some-token"), default);
var controller = NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object);
controller.HttpContext.Request.Headers["Cookie"] = "refresh_token=some-token";

var result = await controller.Logout(default);

Assert.IsType<OkObjectResult>(result);
refresh.Verify(r => r.RevokeAsync("some-token", It.IsAny<CancellationToken>()), Times.Once);
}


[Fact]
public async Task Me_returns_401_when_user_not_found()
{
Expand All @@ -163,6 +182,16 @@ public async Task Me_returns_401_when_user_not_found()
Assert.IsType<UnauthorizedResult>(result);
}

[Fact]
public async Task Refresh_returns_401_when_cookie_missing()
{
var controller = NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>());
var result = await controller.Refresh(default); // no cookie set
var unauth = Assert.IsType<UnauthorizedObjectResult>(result);
Assert.Equal("INVALID_REFRESH_TOKEN", Assert.IsType<ApiResponse<object>>(unauth.Value).Error!.Code);
}


[Fact]
public async Task Me_returns_200_with_user_and_sites()
{
Expand Down
14 changes: 11 additions & 3 deletions DocAnalytics.Api.Tests/RateLimiting/RateLimitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
Expand Down Expand Up @@ -37,11 +38,18 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)

builder.ConfigureTestServices(services =>
{
// swap Npgsql AppDbContext → in-memory (fast, no real DB)
var dbOpts = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (dbOpts is not null) services.Remove(dbOpts);
// swap Npgsql AppDbContext → in-memory (fast, no real DB).
// EF Core 10: must also remove IDbContextOptionsConfiguration<AppDbContext>,
// else the Npgsql provider callback still applies alongside InMemory → dual-provider error.
var efDescriptors = services.Where(d =>
d.ServiceType == typeof(DbContextOptions<AppDbContext>) ||
d.ServiceType == typeof(IDbContextOptionsConfiguration<AppDbContext>) ||
d.ServiceType == typeof(AppDbContext)).ToList();
foreach (var d in efDescriptors) services.Remove(d);

services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("ratelimit-tests"));


// drop background workers so they don't hit the DB during the test
foreach (var d in services.Where(d =>
d.ImplementationType == typeof(AlertEvaluationBackgroundService) ||
Expand Down
104 changes: 54 additions & 50 deletions DocAnalytics.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,10 @@ public class AuthController : ControllerBase
private readonly IAuthService _auth;
private readonly ICurrentUser _currentUser;
private readonly ILoginLockoutService _lockout;
private readonly IRefreshTokenService _refresh; // ← NEW (R4)
private readonly IJwtTokenService _jwt; // ← NEW (R4)
private readonly IRefreshTokenService _refresh;
private readonly IJwtTokenService _jwt;

/// <summary>Creates a new <see cref="AuthController"/>.</summary>
/// <param name="auth">Authentication service.</param>
/// <param name="currentUser">The current authenticated user.</param>
/// <param name="lockout">Login lockout (brute-force) service.</param>
/// <param name="refresh">Refresh-token service.</param>
/// <param name="jwt">JWT access-token service.</param>
public AuthController(
IAuthService auth,
ICurrentUser currentUser,
Expand All @@ -40,13 +35,7 @@ public AuthController(
_jwt = jwt;
}

/// <summary>Authenticates a user and issues a JWT access token plus a rotating refresh token.</summary>
/// <param name="req">Login request with email and password.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The access token, refresh token, and the user's authorized sites.</returns>
/// <response code="200">Login succeeded.</response>
/// <response code="401">Email or password is incorrect.</response>
/// <response code="429">Too many attempts — rate limited or account locked.</response>
/// <summary>Authenticates a user and issues a JWT access token; the refresh token is set as an HttpOnly cookie.</summary>
[AllowAnonymous]
[HttpPost("login")]
[EnableRateLimiting("login")]
Expand All @@ -73,56 +62,53 @@ public async Task<IActionResult> Login([FromBody] LoginRequest req, Cancellation

await _lockout.ResetAsync(email, ct);

// NEW (R4): mint a rotating refresh token alongside the 15-min access token.
// Refresh token now lives ONLY in an HttpOnly cookie — never in the JSON body.
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var (raw, _) = await _refresh.IssueAsync(result.User.Id, clientIp, ct);
result = result with { RefreshToken = raw };
var (raw, refreshExpiresAt) = await _refresh.IssueAsync(result.User.Id, clientIp, ct);
SetRefreshCookie(raw, refreshExpiresAt);

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

/// <summary>Exchanges a valid refresh token for a fresh access token and a rotated refresh token.</summary>
/// <param name="req">Request containing the current refresh token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A new access token and rotated refresh token.</returns>
/// <response code="200">New tokens issued.</response>
/// <response code="401">Refresh token is invalid or expired.</response>
// NEW (R4): exchange a valid refresh token for a fresh access token + rotated refresh token.
/// <summary>Exchanges the refresh-token cookie for a fresh access token and rotates the cookie.</summary>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req, CancellationToken ct)
public async Task<IActionResult> Refresh(CancellationToken ct)
{
var presented = Request.Cookies["refresh_token"];
if (string.IsNullOrEmpty(presented))
return Unauthorized(ApiResponse<object>.Fail(
"INVALID_REFRESH_TOKEN", "Refresh token is missing."));

var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var rotated = await _refresh.ValidateAndRotateAsync(req.RefreshToken, ip, ct);
var rotated = await _refresh.ValidateAndRotateAsync(presented, ip, ct);
if (rotated is null)
{
DeleteRefreshCookie(); // clear the bad cookie
return Unauthorized(ApiResponse<object>.Fail(
"INVALID_REFRESH_TOKEN", "Refresh token is invalid or expired."));
}

var (user, newRaw, _) = rotated.Value;
var (user, newRaw, newExpiresAt) = rotated.Value;
SetRefreshCookie(newRaw, newExpiresAt); // rotate the cookie
var accessToken = _jwt.CreateToken(user);
return Ok(ApiResponse<RefreshResponse>.Ok(new RefreshResponse(accessToken, newRaw)));
return Ok(ApiResponse<RefreshResponse>.Ok(new RefreshResponse(accessToken)));
}

/// <summary>Revokes a refresh token (logout). Anonymous so an expired access token can't block cleanup.</summary>
/// <param name="req">Request containing the refresh token to revoke.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Confirmation that the token was revoked (idempotent).</returns>
/// <response code="200">Token revoked.</response>
// NEW (R4): revoke a refresh token (logout). AllowAnonymous so an expired
// access token doesn't block the client from cleanly revoking.
/// <summary>Revokes the refresh token (logout) and clears the cookie.</summary>
[AllowAnonymous]
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] LogoutRequest req, CancellationToken ct)
public async Task<IActionResult> Logout(CancellationToken ct)
{
await _refresh.RevokeAsync(req.RefreshToken, ct);
var raw = Request.Cookies["refresh_token"];
if (!string.IsNullOrEmpty(raw))
await _refresh.RevokeAsync(raw, ct);

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

/// <summary>Returns the current authenticated user's profile and authorized sites (session rehydration).</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>The current user's profile, or 401 if no valid session.</returns>
/// <response code="200">Profile returned.</response>
/// <response code="401">No valid session.</response>
[Authorize]
[HttpGet("me")]
public async Task<IActionResult> Me(CancellationToken ct)
Expand All @@ -133,19 +119,37 @@ public async Task<IActionResult> Me(CancellationToken ct)
}

/// <summary>Changes the current user's password.</summary>
/// <param name="req">Current and new password.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Confirmation, or a validation error.</returns>
/// <response code="200">Password changed.</response>
/// <response code="400">Current password is incorrect.</response>
[Authorize]
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req, CancellationToken ct)
{
var ok = await _auth.ChangePasswordAsync(_currentUser.UserId, req, ct);
if (!ok)
return BadRequest(ApiResponse<object>.Fail(
"INVALID_PASSWORD", "Current password is incorrect."));
var error = await _auth.ChangePasswordAsync(_currentUser.UserId, req, ct);
if (error is not null)
return BadRequest(ApiResponse<object>.Fail("INVALID_PASSWORD", error));
return Ok(ApiResponse<object>.Ok(new { changed = true }));
}

// ── refresh-token cookie helpers ────────────────────────────────────────
private void SetRefreshCookie(string rawToken, DateTime expiresAt)
{
Response.Cookies.Append("refresh_token", rawToken, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = expiresAt,
Path = "/api/v1/auth"
});
}

private void DeleteRefreshCookie()
{
Response.Cookies.Delete("refresh_token", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Path = "/api/v1/auth"
});
}
}
5 changes: 5 additions & 0 deletions DocAnalytics.Api/Middleware/SecurityHeadersMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public async Task Invoke(HttpContext ctx)
// Trim server fingerprinting where we can.
h.Remove("X-Powered-By");

// API returns JSON only — lock everything down, block framing entirely.
ctx.Response.Headers["Content-Security-Policy"] =
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";


await _next(ctx);
}
}
Expand Down
17 changes: 15 additions & 2 deletions DocAnalytics.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using DocAnalytics.Service.Health;
using DocAnalytics.Service.Invoices;
using DocAnalytics.Service.Realtime;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;


Expand Down Expand Up @@ -113,10 +114,22 @@
{
app.UseSwagger();
app.UseSwaggerUI();
using var scope = app.Services.CreateScope();
await DbSeeder.SeedAsync(scope.ServiceProvider.GetRequiredService<AppDbContext>());
}

// Seeding: reference catalogs seed in EVERY environment; demo users/data are Development-only.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

if (app.Environment.IsDevelopment())
await db.Database.MigrateAsync(); // prod migrations run in the deploy pipeline

await DbSeeder.SeedCatalogsAsync(db); // always
if (app.Environment.IsDevelopment())
await DbSeeder.SeedDemoDataAsync(db); // dev only — no credentials in prod
}


app.UseAuthentication();
app.UseRateLimiter(); // ← NEW: throttle before auth work happens
app.UseAuthorization();
Expand Down
Loading
Loading