feat(auth): PasswordService + AdminBootstrap seeds hash from ADMIN_PASSWORD

This commit is contained in:
Claude
2026-08-28 11:43:15 +00:00
parent e3bc9061c0
commit c8ec5638c0
4 changed files with 85 additions and 0 deletions

View 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);
}
}

View 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);
}

View File

@@ -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();

View 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!));
}
}