Merge pull request 'fix(auth): the wiki-index BasicAuth credentials read the API again' (#15) from feat/basic-auth-machines into main
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -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://<request host>/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://<request host>/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
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class ApiKeyAuthenticationHandler(
|
||||
|
||||
public static class Policies
|
||||
{
|
||||
/// <summary>A signed-in human (OIDC cookie) or any API key.</summary>
|
||||
/// <summary>A signed-in human (OIDC cookie), any API key or valid BasicAuth credentials.</summary>
|
||||
public const string Read = "read";
|
||||
/// <summary>An API key only, so a write carries a named machine identity.</summary>
|
||||
public const string Write = "write";
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class BasicAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IConfiguration configuration)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(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<string, bool> Verified = new();
|
||||
|
||||
protected override async Task<AuthenticateResult> 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");
|
||||
}
|
||||
|
||||
/// <summary>All lines of the file, or of every visible file in the directory (a Secret mount's
|
||||
/// ..data and ..timestamp entries are skipped).</summary>
|
||||
private static async Task<List<string>> ReadUsersAsync(string? path, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return [];
|
||||
IEnumerable<string> files = File.Exists(path) ? [path]
|
||||
: Directory.Exists(path) ? Directory.EnumerateFiles(path).Where(f => !Path.GetFileName(f).StartsWith('.')).Order()
|
||||
: [];
|
||||
var lines = new List<string>();
|
||||
foreach (var file in files) lines.AddRange(await File.ReadAllLinesAsync(file, ct));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies a password against one htpasswd hash.</summary>
|
||||
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));
|
||||
|
||||
/// <summary>Apache's MD5-crypt variant, as written by `htpasswd -m`.</summary>
|
||||
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<byte>([.. 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<byte>();
|
||||
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";
|
||||
}
|
||||
@@ -10,8 +10,9 @@ namespace Wiki.Host.Auth;
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static class WikiAuthentication
|
||||
{
|
||||
@@ -74,7 +75,8 @@ public static class WikiAuthentication
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationHandler.SchemeName, null);
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationHandler.SchemeName, null)
|
||||
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(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)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.2.1" />
|
||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
|
||||
<PackageReference Include="MudBlazor" Version="9.10.0" />
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"Auth": {
|
||||
"Oidc": { "Authority": "", "ClientId": "" },
|
||||
"Basic": { "UsersPath": "" },
|
||||
"ApiKeys": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<WikiHostFactory>
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed class WikiHostFactory : WebApplicationFactory<Program>
|
||||
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 =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user