diff --git a/README.md b/README.md index 4bca33b..2e46850 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ variables (`Wiki__NoteStore__Gitea__Token`, `Wiki__Webhook__Secret`, `Auth__ApiK answer-model entry also provides the fact drafter of the same name (`/facts`). Humans sign in with OpenID Connect (public client, PKCE): `Auth__Oidc__Authority` and -`Auth__Oidc__ClientId` are required; the redirect URI is `https:///signin-oidc`. Machines send `X-Api-Key`. Only `/health`, `/ready` and the HMAC-signed `/webhook` +`Auth__Oidc__ClientId` are required; the redirect URI is `https:///signin-oidc`. Machines send `X-Api-Key`, or BasicAuth +checked against the htpasswd lines in `Auth__Basic__UsersPath`, a file or a mounted Secret directory +(bcrypt, apr1 or SHA1; read API only). Only `/health`, `/ready` and the HMAC-signed `/webhook` answer without either. ## Run diff --git a/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs b/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs index ae770af..5756bfd 100644 --- a/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs +++ b/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs @@ -50,7 +50,7 @@ public sealed class ApiKeyAuthenticationHandler( public static class Policies { - /// A signed-in human (OIDC cookie) or any API key. + /// A signed-in human (OIDC cookie), any API key or valid BasicAuth credentials. public const string Read = "read"; /// An API key only, so a write carries a named machine identity. public const string Write = "write"; diff --git a/src/Wiki.Host/Auth/BasicAuthenticationHandler.cs b/src/Wiki.Host/Auth/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..aee01ba --- /dev/null +++ b/src/Wiki.Host/Auth/BasicAuthenticationHandler.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Wiki.Host.Auth; + +/// +/// Machine callers with the wiki-index BasicAuth credentials: the service checks +/// `Authorization: Basic` itself against the htpasswd lines in Auth:Basic:UsersPath, a file or a +/// mounted Secret directory (every key is read, as Traefik's middleware reads the same Secret), so +/// nothing depends on the request having passed a proxy. +/// Hashes: bcrypt ($2a$, $2b$, $2y$), Apache MD5 ($apr1$), SHA1 ({SHA}), the formats Traefik +/// accepts. Fail closed: no users, no matching user or an unknown hash format authenticates nobody. +/// +public sealed class BasicAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "Basic"; + + // bcrypt costs ~100 ms per check; a verified (user, password, hash) triple is remembered. + private static readonly ConcurrentDictionary Verified = new(); + + protected override async Task HandleAuthenticateAsync() + { + var authorization = Request.Headers.Authorization.FirstOrDefault(); + if (authorization?.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase) != true) + return AuthenticateResult.NoResult(); + + string user, password; + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authorization["Basic ".Length..].Trim())); + var colon = decoded.IndexOf(':'); + if (colon <= 0) return AuthenticateResult.Fail("malformed Basic credentials"); + (user, password) = (decoded[..colon], decoded[(colon + 1)..]); + } + catch (FormatException) + { + return AuthenticateResult.Fail("malformed Basic credentials"); + } + + var lines = await ReadUsersAsync(configuration["Auth:Basic:UsersPath"], Context.RequestAborted); + if (lines.Count == 0) + return AuthenticateResult.Fail("no BasicAuth users configured"); + + foreach (var line in lines) + { + var colon = line.IndexOf(':'); + if (colon <= 0 || line[..colon] != user) continue; + var hash = line[(colon + 1)..].Trim(); + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{user}\n{password}\n{hash}"))); + if (Verified.GetOrAdd(key, _ => Htpasswd.Verify(password, hash))) + { + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user), new Claim(Claims.Kind, "machine")], Scheme.Name); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)); + } + break; + } + return AuthenticateResult.Fail("invalid Basic credentials"); + } + + /// All lines of the file, or of every visible file in the directory (a Secret mount's + /// ..data and ..timestamp entries are skipped). + private static async Task> ReadUsersAsync(string? path, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(path)) return []; + IEnumerable files = File.Exists(path) ? [path] + : Directory.Exists(path) ? Directory.EnumerateFiles(path).Where(f => !Path.GetFileName(f).StartsWith('.')).Order() + : []; + var lines = new List(); + foreach (var file in files) lines.AddRange(await File.ReadAllLinesAsync(file, ct)); + return lines; + } +} + +/// Verifies a password against one htpasswd hash. +public static class Htpasswd +{ + public static bool Verify(string password, string hash) + { + if (hash.StartsWith("$2a$") || hash.StartsWith("$2b$") || hash.StartsWith("$2y$")) + { + try { return BCrypt.Net.BCrypt.Verify(password, hash); } + catch (BCrypt.Net.SaltParseException) { return false; } + } + if (hash.StartsWith("$apr1$")) + { + var salt = hash["$apr1$".Length..].Split('$')[0]; + return FixedEquals(Apr1(password, salt), hash); + } + if (hash.StartsWith("{SHA}")) + return FixedEquals("{SHA}" + Convert.ToBase64String(SHA1.HashData(Encoding.UTF8.GetBytes(password))), hash); + return false; + } + + private static bool FixedEquals(string a, string b) => + CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b)); + + /// Apache's MD5-crypt variant, as written by `htpasswd -m`. + public static string Apr1(string password, string salt) + { + const string magic = "$apr1$"; + if (salt.Length > 8) salt = salt[..8]; + var pw = Encoding.UTF8.GetBytes(password); + var sl = Encoding.UTF8.GetBytes(salt); + var mg = Encoding.ASCII.GetBytes(magic); + + var alt = MD5.HashData([.. pw, .. sl, .. pw]); + var ctx = new List([.. pw, .. mg, .. sl]); + for (var left = pw.Length; left > 0; left -= 16) ctx.AddRange(alt.Take(Math.Min(16, left))); + for (var i = pw.Length; i > 0; i >>= 1) ctx.Add((i & 1) != 0 ? (byte)0 : pw.Length > 0 ? pw[0] : (byte)0); + var final = MD5.HashData(ctx.ToArray()); + + for (var i = 0; i < 1000; i++) + { + var round = new List(); + round.AddRange((i & 1) != 0 ? pw : final); + if (i % 3 != 0) round.AddRange(sl); + if (i % 7 != 0) round.AddRange(pw); + round.AddRange((i & 1) != 0 ? final : pw); + final = MD5.HashData(round.ToArray()); + } + + var sb = new StringBuilder(magic).Append(salt).Append('$'); + void To64(int v, int n) { for (; n > 0; n--, v >>= 6) sb.Append(Itoa64[v & 0x3f]); } + To64((final[0] << 16) | (final[6] << 8) | final[12], 4); + To64((final[1] << 16) | (final[7] << 8) | final[13], 4); + To64((final[2] << 16) | (final[8] << 8) | final[14], 4); + To64((final[3] << 16) | (final[9] << 8) | final[15], 4); + To64((final[4] << 16) | (final[10] << 8) | final[5], 4); + To64(final[11], 2); + return sb.ToString(); + } + + private const string Itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +} diff --git a/src/Wiki.Host/Auth/WikiAuthentication.cs b/src/Wiki.Host/Auth/WikiAuthentication.cs index fa7d943..03a2e53 100644 --- a/src/Wiki.Host/Auth/WikiAuthentication.cs +++ b/src/Wiki.Host/Auth/WikiAuthentication.cs @@ -10,8 +10,9 @@ namespace Wiki.Host.Auth; /// /// Humans sign in with OpenID Connect against Authentik (public client, PKCE): the username comes /// from the ID token, whose signature the handler checks against the provider's JWKS, so no -/// network path or request header can assert an identity. Machines present an API key. -/// Every endpoint needs one of the two unless it opts out (/health, /ready, the HMAC webhook). +/// network path or request header can assert an identity. Machines present an API key or the +/// BasicAuth credentials of the wiki-index machine door, which the service checks itself. +/// Every endpoint needs one of these unless it opts out (/health, /ready, the HMAC webhook). /// public static class WikiAuthentication { @@ -74,7 +75,8 @@ public static class WikiAuthentication return Task.CompletedTask; }; }) - .AddScheme(ApiKeyAuthenticationHandler.SchemeName, null); + .AddScheme(ApiKeyAuthenticationHandler.SchemeName, null) + .AddScheme(BasicAuthenticationHandler.SchemeName, null); services.AddAuthorization(options => { @@ -84,7 +86,7 @@ public static class WikiAuthentication .RequireAuthenticatedUser() .Build(); options.AddPolicy(Policies.Read, p => p - .AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationHandler.SchemeName) + .AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationHandler.SchemeName, BasicAuthenticationHandler.SchemeName) .RequireAuthenticatedUser()); options.AddPolicy(Policies.Write, p => p .AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName) diff --git a/src/Wiki.Host/Wiki.Host.csproj b/src/Wiki.Host/Wiki.Host.csproj index 5392846..97d35f0 100644 --- a/src/Wiki.Host/Wiki.Host.csproj +++ b/src/Wiki.Host/Wiki.Host.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json index 8bb5015..2b58895 100644 --- a/src/Wiki.Host/appsettings.json +++ b/src/Wiki.Host/appsettings.json @@ -35,6 +35,7 @@ }, "Auth": { "Oidc": { "Authority": "", "ClientId": "" }, + "Basic": { "UsersPath": "" }, "ApiKeys": {} } } diff --git a/test/Wiki.Host.Tests/BasicAuthTests.cs b/test/Wiki.Host.Tests/BasicAuthTests.cs new file mode 100644 index 0000000..147e98e --- /dev/null +++ b/test/Wiki.Host.Tests/BasicAuthTests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; +using Wiki.Host.Auth; + +namespace Wiki.Host.Tests; + +/// A Secret-like directory: one user per hash format Traefik accepts, split over two keys, plus a +/// hidden entry that must be skipped. Every password is the user name. +public static class BasicUsers +{ + // From the Traefik BasicAuth docs (htpasswd -m, password "test"); openssl passwd -apr1 agrees. + public const string Apr1Line = "test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"; + + public static readonly string Directory = Write(); + + private static string Write() + { + var dir = Path.Combine(Path.GetTempPath(), "wiki-host-tests", $"htpasswd-{Guid.NewGuid():N}"); + System.IO.Directory.CreateDirectory(dir); + var bcrypt = BCrypt.Net.BCrypt.HashPassword("agent", 4).Replace("$2a$", "$2y$"); + var sha = "{SHA}" + Convert.ToBase64String(System.Security.Cryptography.SHA1.HashData(Encoding.UTF8.GetBytes("legacy"))); + File.WriteAllLines(Path.Combine(dir, "users"), [Apr1Line, $"agent:{bcrypt}"]); + File.WriteAllLines(Path.Combine(dir, "more"), [$"legacy:{sha}"]); + File.WriteAllLines(Path.Combine(dir, "..hidden"), ["ghost:{SHA}" + Convert.ToBase64String(System.Security.Cryptography.SHA1.HashData(Encoding.UTF8.GetBytes("ghost")))]); + return dir; + } +} + +public class BasicAuthTests(WikiHostFactory factory) : IClassFixture +{ + private HttpClient As(string user, string password) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}"))); + return client; + } + + [Fact] + public void Apr1MatchesApacheHtpasswd() => + Assert.Equal("$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/", Htpasswd.Apr1("test", "H6uskkkW")); + + [Theory] + [InlineData("test", "test")] // apr1 + [InlineData("agent", "agent")] // bcrypt $2y$ + [InlineData("legacy", "legacy")] // {SHA} + public async Task TheMachineDoorCredentialsReadTheApi(string user, string password) + { + var client = As(user, password); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/tags")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/rank", new { query = " " })).StatusCode); + } + + [Theory] + [InlineData("test", "wrong")] + [InlineData("nobody", "test")] + [InlineData("agent", "")] + [InlineData("ghost", "ghost")] // only in a hidden entry + public async Task WrongCredentialsAre401(string user, string password) => + Assert.Equal(HttpStatusCode.Unauthorized, (await As(user, password).GetAsync("/api/v1/tags")).StatusCode); + + [Fact] + public async Task BasicCredentialsCannotWrite() + { + var r = await As("test", "test").PostAsJsonAsync("/api/v1/facts", new { title = "t", source = "s", kind = "fact", content = new string('x', 50) }); + Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode); + } + + [Fact] + public async Task BasicCredentialsDoNotOpenTheUi() => + Assert.Equal(HttpStatusCode.Redirect, (await As("test", "test").GetAsync("/facts")).StatusCode); +} diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index fba1357..ca5be66 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -29,6 +29,7 @@ public sealed class WikiHostFactory : WebApplicationFactory builder.UseSetting("Auth:ApiKeys:tester", ApiKey); builder.UseSetting("Auth:Oidc:Authority", FakeIdp.Issuer); builder.UseSetting("Auth:Oidc:ClientId", FakeIdp.ClientId); + builder.UseSetting("Auth:Basic:UsersPath", BasicUsers.Directory); builder.UseSetting("Wiki:Index:File:Directory", Path.Combine(Path.GetTempPath(), "wiki-host-tests", Guid.NewGuid().ToString("N"))); builder.ConfigureServices(services => {