feat(auth): cookie session endpoints + WebApplicationFactory harness
This commit is contained in:
4
src/VolcanicCore.Api/Contracts/SessionDtos.cs
Normal file
4
src/VolcanicCore.Api/Contracts/SessionDtos.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace VolcanicCore.Api.Contracts;
|
||||
|
||||
public sealed record LoginRequest(string Password);
|
||||
public sealed record MeResponse(bool Authenticated, string? Username);
|
||||
42
src/VolcanicCore.Api/Endpoints/SessionEndpoints.cs
Normal file
42
src/VolcanicCore.Api/Endpoints/SessionEndpoints.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using VolcanicCore.Api.Auth;
|
||||
using VolcanicCore.Api.Contracts;
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
|
||||
namespace VolcanicCore.Api.Endpoints;
|
||||
|
||||
public static class SessionEndpoints
|
||||
{
|
||||
public const string AdminUser = "admin";
|
||||
|
||||
public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/session", async (LoginRequest req, HttpContext ctx,
|
||||
AdminConfigRepository repo, PasswordService pw) =>
|
||||
{
|
||||
var hash = repo.Get(AdminBootstrap.PasswordHashKey);
|
||||
if (hash is null || !pw.Verify(req.Password ?? "", hash))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var claims = new[] { new Claim(ClaimTypes.Name, AdminUser) };
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapDelete("/api/session", async (HttpContext ctx) =>
|
||||
{
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/api/session/me", (HttpContext ctx) =>
|
||||
ctx.User.Identity?.IsAuthenticated == true
|
||||
? Results.Ok(new MeResponse(true, ctx.User.Identity.Name))
|
||||
: Results.Unauthorized())
|
||||
.RequireAuthorization();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using VolcanicCore.Api;
|
||||
using VolcanicCore.Api.Auth;
|
||||
using VolcanicCore.Api.Endpoints;
|
||||
using VolcanicCore.Persistence;
|
||||
using VolcanicCore.Persistence.Migrations;
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
@@ -37,6 +39,26 @@ builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
|
||||
}));
|
||||
builder.Services.AddSingleton<PasswordService>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "volcaniccore.sid";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
o.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
o.SlidingExpiration = true;
|
||||
o.Events.OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = 401; return Task.CompletedTask;
|
||||
};
|
||||
o.Events.OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = 403; return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Apply migrations at boot.
|
||||
@@ -56,6 +78,10 @@ using (var scope = app.Services.CreateScope())
|
||||
AdminBootstrap.Seed(repo, pw, settings.AdminPassword);
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapSessionEndpoints();
|
||||
|
||||
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
|
||||
app.Run();
|
||||
|
||||
|
||||
52
tests/VolcanicCore.Tests/AuthTests.cs
Normal file
52
tests/VolcanicCore.Tests/AuthTests.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace VolcanicCore.Tests;
|
||||
|
||||
public class AuthTests : IClassFixture<WebFactory>
|
||||
{
|
||||
private readonly WebFactory _factory;
|
||||
public AuthTests(WebFactory f) => _factory = f;
|
||||
|
||||
private HttpClient Fresh() => _factory.CreateClient(new()
|
||||
{
|
||||
HandleCookies = true,
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task Me_without_cookie_is_401()
|
||||
{
|
||||
var res = await Fresh().GetAsync("/api/session/me");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bad_password_is_401()
|
||||
{
|
||||
var res = await Fresh().PostAsJsonAsync("/api/session",
|
||||
new { password = "nope" });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Good_password_sets_cookie_and_me_returns_200()
|
||||
{
|
||||
var c = Fresh();
|
||||
var login = await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
|
||||
Assert.Equal(HttpStatusCode.NoContent, login.StatusCode);
|
||||
var me = await c.GetAsync("/api/session/me");
|
||||
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logout_clears_the_session()
|
||||
{
|
||||
var c = Fresh();
|
||||
await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
|
||||
var logout = await c.DeleteAsync("/api/session");
|
||||
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
|
||||
var me = await c.GetAsync("/api/session/me");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
|
||||
}
|
||||
}
|
||||
31
tests/VolcanicCore.Tests/WebFactory.cs
Normal file
31
tests/VolcanicCore.Tests/WebFactory.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
// Disable test parallelization across the assembly. Multiple WebFactory instances
|
||||
// share the same process, and pre-Bind config wiring reads process-wide state;
|
||||
// serial execution avoids interleaving flakes for a small suite like ours.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace VolcanicCore.Tests;
|
||||
|
||||
public sealed class WebFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
public string DataDir { get; } = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}");
|
||||
public string AdminPassword { get; } = "hunter2";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
// UseSetting writes into the configuration chain synchronously so
|
||||
// Program.cs's Configuration.Bind picks these up without env vars.
|
||||
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.DataDirectory), DataDir);
|
||||
builder.UseSetting(nameof(VolcanicCore.Api.AppSettings.AdminPassword), AdminPassword);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
try { Directory.Delete(DataDir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user