feat(auth): PasswordService + AdminBootstrap seeds hash from ADMIN_PASSWORD
This commit is contained in:
25
src/VolcanicCore.Api/Auth/AdminBootstrap.cs
Normal file
25
src/VolcanicCore.Api/Auth/AdminBootstrap.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
|
||||
namespace VolcanicCore.Api.Auth;
|
||||
|
||||
public static class AdminBootstrap
|
||||
{
|
||||
public const string PasswordHashKey = "admin.password_hash";
|
||||
public const string PasswordSourceKey = "admin.password_hash_source";
|
||||
|
||||
public static void Seed(AdminConfigRepository repo, PasswordService pw, string adminPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
throw new InvalidOperationException(
|
||||
"ADMIN_PASSWORD is required. Set the env var to a non-empty value.");
|
||||
|
||||
var existingHash = repo.Get(PasswordHashKey);
|
||||
var existingSource = repo.Get(PasswordSourceKey);
|
||||
|
||||
// Re-hash only if the env value has changed since last boot.
|
||||
if (existingHash is not null && existingSource == adminPassword) return;
|
||||
|
||||
repo.Set(PasswordHashKey, pw.Hash(adminPassword));
|
||||
repo.Set(PasswordSourceKey, adminPassword);
|
||||
}
|
||||
}
|
||||
7
src/VolcanicCore.Api/Auth/PasswordService.cs
Normal file
7
src/VolcanicCore.Api/Auth/PasswordService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace VolcanicCore.Api.Auth;
|
||||
|
||||
public sealed class PasswordService
|
||||
{
|
||||
public string Hash(string plaintext) => BCrypt.Net.BCrypt.HashPassword(plaintext, workFactor: 12);
|
||||
public bool Verify(string plaintext, string hash) => BCrypt.Net.BCrypt.Verify(plaintext, hash);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using VolcanicCore.Api;
|
||||
using VolcanicCore.Api.Auth;
|
||||
using VolcanicCore.Persistence;
|
||||
using VolcanicCore.Persistence.Migrations;
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
@@ -34,6 +35,7 @@ builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
|
||||
{
|
||||
new Migration001Initial(),
|
||||
}));
|
||||
builder.Services.AddSingleton<PasswordService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -46,6 +48,14 @@ using (var scope = app.Services.CreateScope())
|
||||
mig.Apply(conn);
|
||||
}
|
||||
|
||||
// Bootstrap admin credentials at boot.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var repo = scope.ServiceProvider.GetRequiredService<AdminConfigRepository>();
|
||||
var pw = scope.ServiceProvider.GetRequiredService<PasswordService>();
|
||||
AdminBootstrap.Seed(repo, pw, settings.AdminPassword);
|
||||
}
|
||||
|
||||
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
|
||||
app.Run();
|
||||
|
||||
|
||||
43
tests/VolcanicCore.Tests/AdminBootstrapTests.cs
Normal file
43
tests/VolcanicCore.Tests/AdminBootstrapTests.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using VolcanicCore.Api.Auth;
|
||||
using VolcanicCore.Persistence;
|
||||
using VolcanicCore.Persistence.Migrations;
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
|
||||
namespace VolcanicCore.Tests;
|
||||
|
||||
public class AdminBootstrapTests
|
||||
{
|
||||
private static (SqliteConnectionFactory f, AdminConfigRepository r) Setup()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}.db");
|
||||
var f = new SqliteConnectionFactory(path);
|
||||
using (var conn = f.Open())
|
||||
new Migrator(new IMigration[] { new Migration001Initial() }).Apply(conn);
|
||||
return (f, new AdminConfigRepository(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refuses_to_boot_without_admin_password()
|
||||
{
|
||||
var (_, repo) = Setup();
|
||||
var pw = new PasswordService();
|
||||
Assert.Throws<InvalidOperationException>(() => AdminBootstrap.Seed(repo, pw, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stores_hash_on_first_boot_and_updates_it_when_password_changes()
|
||||
{
|
||||
var (_, repo) = Setup();
|
||||
var pw = new PasswordService();
|
||||
AdminBootstrap.Seed(repo, pw, "hunter2");
|
||||
var h1 = repo.Get("admin.password_hash");
|
||||
Assert.NotNull(h1);
|
||||
Assert.True(pw.Verify("hunter2", h1!));
|
||||
|
||||
AdminBootstrap.Seed(repo, pw, "correct-horse");
|
||||
var h2 = repo.Get("admin.password_hash");
|
||||
Assert.NotEqual(h1, h2);
|
||||
Assert.True(pw.Verify("correct-horse", h2!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user