Skip to content

Commit edc98d4

Browse files
authored
Merge pull request #113 from Akash29g/harden/prod-security
security: production hardening (seeding, CORS/HSTS, CSP, password policy, HttpOnly refresh cookie, dependabot)
2 parents c897a47 + 5ba7448 commit edc98d4

22 files changed

Lines changed: 409 additions & 333 deletions

File tree

.github/dependabot.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
version: 2
22
updates:
3-
# ---- Backend: NuGet (.NET) ----
43
- package-ecosystem: "nuget"
54
directory: "/"
65
schedule:
76
interval: "weekly"
87
open-pull-requests-limit: 10
8+
groups:
9+
dotnet:
10+
patterns: [ "*" ]
911

10-
# ---- Frontend: npm (Angular app) ----
1112
- package-ecosystem: "npm"
1213
directory: "/docanalytics-web"
1314
schedule:
1415
interval: "weekly"
1516
open-pull-requests-limit: 10
17+
18+
- package-ecosystem: "github-actions"
19+
directory: "/"
20+
schedule:
21+
interval: "weekly"

DocAnalytics.Api.Tests/Controllers/AuthControllerTests.cs

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,19 @@ public async Task Login_returns_200_with_envelope_on_success()
2929
var response = new LoginResponse("jwt", new UserDto(Guid.NewGuid(), "a@org.com", "Viewer"), new List<SiteDto>(), false);
3030
var auth = new Mock<IAuthService>();
3131
auth.Setup(a => a.LoginAsync(It.IsAny<LoginRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync(response);
32+
var refresh = new Mock<IRefreshTokenService>();
33+
refresh.Setup(r => r.IssueAsync(It.IsAny<Guid>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
34+
.ReturnsAsync(("raw-refresh", DateTime.UtcNow.AddDays(7))); // ← valid expiry, no MinValue blow-up
3235

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

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

44+
4145
[Fact]
4246
public async Task Login_returns_401_on_invalid_credentials()
4347
{
@@ -87,7 +91,7 @@ public async Task Login_registers_failure_on_bad_password()
8791
}
8892

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

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

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

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

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

120129
var ok = Assert.IsType<OkObjectResult>(result);
121130
var body = Assert.IsType<ApiResponse<RefreshResponse>>(ok.Value);
122-
Assert.Equal("new-access", body.Data!.Token);
123-
Assert.Equal("new-refresh", body.Data!.RefreshToken);
131+
Assert.Equal("new-access", body.Data!.Token); // only the access token is in the body now
132+
133+
var setCookie = controller.Response.Headers["Set-Cookie"].ToString();
134+
Assert.Contains("refresh_token=new-refresh", setCookie); // rotated cookie
135+
refresh.Verify(r => r.ValidateAndRotateAsync("old-refresh", It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Once);
124136
}
125137

138+
126139
[Fact]
127140
public async Task Refresh_returns_401_when_token_invalid()
128141
{
129142
var refresh = new Mock<IRefreshTokenService>();
130143
refresh.Setup(r => r.ValidateAndRotateAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
131144
.ReturnsAsync(((User, string, DateTime)?)null);
132145

133-
var result = await NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object)
134-
.Refresh(new RefreshRequest("bad"), default);
146+
var controller = NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>(), refresh.Object);
147+
controller.HttpContext.Request.Headers["Cookie"] = "refresh_token=bad";
148+
149+
var result = await controller.Refresh(default);
135150

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

156+
141157
[Fact]
142158
public async Task Logout_revokes_token_and_returns_200()
143159
{
144160
var refresh = new Mock<IRefreshTokenService>();
145161

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

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

171+
153172
[Fact]
154173
public async Task Me_returns_401_when_user_not_found()
155174
{
@@ -163,6 +182,16 @@ public async Task Me_returns_401_when_user_not_found()
163182
Assert.IsType<UnauthorizedResult>(result);
164183
}
165184

185+
[Fact]
186+
public async Task Refresh_returns_401_when_cookie_missing()
187+
{
188+
var controller = NewController(Mock.Of<IAuthService>(), Mock.Of<ICurrentUser>(), Mock.Of<ILoginLockoutService>());
189+
var result = await controller.Refresh(default); // no cookie set
190+
var unauth = Assert.IsType<UnauthorizedObjectResult>(result);
191+
Assert.Equal("INVALID_REFRESH_TOKEN", Assert.IsType<ApiResponse<object>>(unauth.Value).Error!.Code);
192+
}
193+
194+
166195
[Fact]
167196
public async Task Me_returns_200_with_user_and_sites()
168197
{

DocAnalytics.Api.Tests/RateLimiting/RateLimitTests.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Microsoft.AspNetCore.Mvc.Testing;
88
using Microsoft.AspNetCore.TestHost;
99
using Microsoft.EntityFrameworkCore;
10+
using Microsoft.EntityFrameworkCore.Infrastructure;
1011
using Microsoft.Extensions.Configuration;
1112
using Microsoft.Extensions.DependencyInjection;
1213
using Microsoft.Extensions.Hosting;
@@ -37,11 +38,18 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
3738

3839
builder.ConfigureTestServices(services =>
3940
{
40-
// swap Npgsql AppDbContext → in-memory (fast, no real DB)
41-
var dbOpts = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
42-
if (dbOpts is not null) services.Remove(dbOpts);
41+
// swap Npgsql AppDbContext → in-memory (fast, no real DB).
42+
// EF Core 10: must also remove IDbContextOptionsConfiguration<AppDbContext>,
43+
// else the Npgsql provider callback still applies alongside InMemory → dual-provider error.
44+
var efDescriptors = services.Where(d =>
45+
d.ServiceType == typeof(DbContextOptions<AppDbContext>) ||
46+
d.ServiceType == typeof(IDbContextOptionsConfiguration<AppDbContext>) ||
47+
d.ServiceType == typeof(AppDbContext)).ToList();
48+
foreach (var d in efDescriptors) services.Remove(d);
49+
4350
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("ratelimit-tests"));
4451

52+
4553
// drop background workers so they don't hit the DB during the test
4654
foreach (var d in services.Where(d =>
4755
d.ImplementationType == typeof(AlertEvaluationBackgroundService) ||

DocAnalytics.Api/Controllers/AuthController.cs

Lines changed: 54 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,10 @@ public class AuthController : ControllerBase
1717
private readonly IAuthService _auth;
1818
private readonly ICurrentUser _currentUser;
1919
private readonly ILoginLockoutService _lockout;
20-
private readonly IRefreshTokenService _refresh; // ← NEW (R4)
21-
private readonly IJwtTokenService _jwt; // ← NEW (R4)
20+
private readonly IRefreshTokenService _refresh;
21+
private readonly IJwtTokenService _jwt;
2222

2323
/// <summary>Creates a new <see cref="AuthController"/>.</summary>
24-
/// <param name="auth">Authentication service.</param>
25-
/// <param name="currentUser">The current authenticated user.</param>
26-
/// <param name="lockout">Login lockout (brute-force) service.</param>
27-
/// <param name="refresh">Refresh-token service.</param>
28-
/// <param name="jwt">JWT access-token service.</param>
2924
public AuthController(
3025
IAuthService auth,
3126
ICurrentUser currentUser,
@@ -40,13 +35,7 @@ public AuthController(
4035
_jwt = jwt;
4136
}
4237

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

7463
await _lockout.ResetAsync(email, ct);
7564

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

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

84-
/// <summary>Exchanges a valid refresh token for a fresh access token and a rotated refresh token.</summary>
85-
/// <param name="req">Request containing the current refresh token.</param>
86-
/// <param name="ct">Cancellation token.</param>
87-
/// <returns>A new access token and rotated refresh token.</returns>
88-
/// <response code="200">New tokens issued.</response>
89-
/// <response code="401">Refresh token is invalid or expired.</response>
90-
// NEW (R4): exchange a valid refresh token for a fresh access token + rotated refresh token.
73+
/// <summary>Exchanges the refresh-token cookie for a fresh access token and rotates the cookie.</summary>
9174
[AllowAnonymous]
9275
[HttpPost("refresh")]
93-
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req, CancellationToken ct)
76+
public async Task<IActionResult> Refresh(CancellationToken ct)
9477
{
78+
var presented = Request.Cookies["refresh_token"];
79+
if (string.IsNullOrEmpty(presented))
80+
return Unauthorized(ApiResponse<object>.Fail(
81+
"INVALID_REFRESH_TOKEN", "Refresh token is missing."));
82+
9583
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
96-
var rotated = await _refresh.ValidateAndRotateAsync(req.RefreshToken, ip, ct);
84+
var rotated = await _refresh.ValidateAndRotateAsync(presented, ip, ct);
9785
if (rotated is null)
86+
{
87+
DeleteRefreshCookie(); // clear the bad cookie
9888
return Unauthorized(ApiResponse<object>.Fail(
9989
"INVALID_REFRESH_TOKEN", "Refresh token is invalid or expired."));
90+
}
10091

101-
var (user, newRaw, _) = rotated.Value;
92+
var (user, newRaw, newExpiresAt) = rotated.Value;
93+
SetRefreshCookie(newRaw, newExpiresAt); // rotate the cookie
10294
var accessToken = _jwt.CreateToken(user);
103-
return Ok(ApiResponse<RefreshResponse>.Ok(new RefreshResponse(accessToken, newRaw)));
95+
return Ok(ApiResponse<RefreshResponse>.Ok(new RefreshResponse(accessToken)));
10496
}
10597

106-
/// <summary>Revokes a refresh token (logout). Anonymous so an expired access token can't block cleanup.</summary>
107-
/// <param name="req">Request containing the refresh token to revoke.</param>
108-
/// <param name="ct">Cancellation token.</param>
109-
/// <returns>Confirmation that the token was revoked (idempotent).</returns>
110-
/// <response code="200">Token revoked.</response>
111-
// NEW (R4): revoke a refresh token (logout). AllowAnonymous so an expired
112-
// access token doesn't block the client from cleanly revoking.
98+
/// <summary>Revokes the refresh token (logout) and clears the cookie.</summary>
11399
[AllowAnonymous]
114100
[HttpPost("logout")]
115-
public async Task<IActionResult> Logout([FromBody] LogoutRequest req, CancellationToken ct)
101+
public async Task<IActionResult> Logout(CancellationToken ct)
116102
{
117-
await _refresh.RevokeAsync(req.RefreshToken, ct);
103+
var raw = Request.Cookies["refresh_token"];
104+
if (!string.IsNullOrEmpty(raw))
105+
await _refresh.RevokeAsync(raw, ct);
106+
107+
DeleteRefreshCookie();
118108
return Ok(ApiResponse<object>.Ok(new { logged_out = true }));
119109
}
120110

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

135121
/// <summary>Changes the current user's password.</summary>
136-
/// <param name="req">Current and new password.</param>
137-
/// <param name="ct">Cancellation token.</param>
138-
/// <returns>Confirmation, or a validation error.</returns>
139-
/// <response code="200">Password changed.</response>
140-
/// <response code="400">Current password is incorrect.</response>
141122
[Authorize]
142123
[HttpPost("change-password")]
143124
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req, CancellationToken ct)
144125
{
145-
var ok = await _auth.ChangePasswordAsync(_currentUser.UserId, req, ct);
146-
if (!ok)
147-
return BadRequest(ApiResponse<object>.Fail(
148-
"INVALID_PASSWORD", "Current password is incorrect."));
126+
var error = await _auth.ChangePasswordAsync(_currentUser.UserId, req, ct);
127+
if (error is not null)
128+
return BadRequest(ApiResponse<object>.Fail("INVALID_PASSWORD", error));
149129
return Ok(ApiResponse<object>.Ok(new { changed = true }));
150130
}
131+
132+
// ── refresh-token cookie helpers ────────────────────────────────────────
133+
private void SetRefreshCookie(string rawToken, DateTime expiresAt)
134+
{
135+
Response.Cookies.Append("refresh_token", rawToken, new CookieOptions
136+
{
137+
HttpOnly = true,
138+
Secure = true,
139+
SameSite = SameSiteMode.Strict,
140+
Expires = expiresAt,
141+
Path = "/api/v1/auth"
142+
});
143+
}
144+
145+
private void DeleteRefreshCookie()
146+
{
147+
Response.Cookies.Delete("refresh_token", new CookieOptions
148+
{
149+
HttpOnly = true,
150+
Secure = true,
151+
SameSite = SameSiteMode.Strict,
152+
Path = "/api/v1/auth"
153+
});
154+
}
151155
}

DocAnalytics.Api/Middleware/SecurityHeadersMiddleware.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ public async Task Invoke(HttpContext ctx)
3030
// Trim server fingerprinting where we can.
3131
h.Remove("X-Powered-By");
3232

33+
// API returns JSON only — lock everything down, block framing entirely.
34+
ctx.Response.Headers["Content-Security-Policy"] =
35+
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
36+
37+
3338
await _next(ctx);
3439
}
3540
}

DocAnalytics.Api/Program.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using DocAnalytics.Service.Health;
1515
using DocAnalytics.Service.Invoices;
1616
using DocAnalytics.Service.Realtime;
17+
using Microsoft.EntityFrameworkCore;
1718
using Microsoft.Extensions.Options;
1819

1920

@@ -113,10 +114,22 @@
113114
{
114115
app.UseSwagger();
115116
app.UseSwaggerUI();
116-
using var scope = app.Services.CreateScope();
117-
await DbSeeder.SeedAsync(scope.ServiceProvider.GetRequiredService<AppDbContext>());
118117
}
119118

119+
// Seeding: reference catalogs seed in EVERY environment; demo users/data are Development-only.
120+
using (var scope = app.Services.CreateScope())
121+
{
122+
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
123+
124+
if (app.Environment.IsDevelopment())
125+
await db.Database.MigrateAsync(); // prod migrations run in the deploy pipeline
126+
127+
await DbSeeder.SeedCatalogsAsync(db); // always
128+
if (app.Environment.IsDevelopment())
129+
await DbSeeder.SeedDemoDataAsync(db); // dev only — no credentials in prod
130+
}
131+
132+
120133
app.UseAuthentication();
121134
app.UseRateLimiter(); // ← NEW: throttle before auth work happens
122135
app.UseAuthorization();

0 commit comments

Comments
 (0)