fix(auth): the wiki-index BasicAuth credentials read the API again
#14 put the read API behind a session or an API key. External agents reach the wiki through wiki-index.renner.uno with BasicAuth only, and since #14 they got 401: a regression. The service now checks `Authorization: Basic` itself against the same htpasswd lines Traefik's wiki-api-basic-auth middleware uses (Auth:Basic:UsersPath: a file, or a mounted Secret directory where every visible key is read, as Traefik reads every key of an Opaque Secret). This is a credential check in the service, not trust in a proxy: the header proves nothing unless the password matches the hash. - Hash formats Traefik accepts: bcrypt ($2a$/$2b$/$2y$, BCrypt.Net-Next 4.2.1), Apache MD5 ($apr1$, implemented here and checked against the Traefik docs example and `openssl passwd -apr1`), SHA1 ({SHA}). Any other format authenticates nobody. - The identity is a machine named after the BasicAuth user. - Policy "read" accepts it; "write" stays API-key only, and the UI stays OIDC only. - A verified (user, password, hash) triple is cached in memory, so bcrypt costs once per process. - With no UsersPath configured the scheme authenticates nobody, so this image is safe before the deployment mounts the Secret. Tests: all three hash formats read the API; a wrong password, an unknown user, an empty password and a user found only in a hidden Secret entry are 401; BasicAuth cannot write and does not open the UI.
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`).
|
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
|
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.
|
answer without either.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public sealed class ApiKeyAuthenticationHandler(
|
|||||||
|
|
||||||
public static class Policies
|
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";
|
public const string Read = "read";
|
||||||
/// <summary>An API key only, so a write carries a named machine identity.</summary>
|
/// <summary>An API key only, so a write carries a named machine identity.</summary>
|
||||||
public const string Write = "write";
|
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>
|
/// <summary>
|
||||||
/// Humans sign in with OpenID Connect against Authentik (public client, PKCE): the username comes
|
/// 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
|
/// 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.
|
/// network path or request header can assert an identity. Machines present an API key or the
|
||||||
/// Every endpoint needs one of the two unless it opts out (/health, /ready, the HMAC webhook).
|
/// 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>
|
/// </summary>
|
||||||
public static class WikiAuthentication
|
public static class WikiAuthentication
|
||||||
{
|
{
|
||||||
@@ -74,7 +75,8 @@ public static class WikiAuthentication
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationHandler.SchemeName, null);
|
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationHandler.SchemeName, null)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(BasicAuthenticationHandler.SchemeName, null);
|
||||||
|
|
||||||
services.AddAuthorization(options =>
|
services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
@@ -84,7 +86,7 @@ public static class WikiAuthentication
|
|||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.Build();
|
.Build();
|
||||||
options.AddPolicy(Policies.Read, p => p
|
options.AddPolicy(Policies.Read, p => p
|
||||||
.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationHandler.SchemeName)
|
.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, ApiKeyAuthenticationHandler.SchemeName, BasicAuthenticationHandler.SchemeName)
|
||||||
.RequireAuthenticatedUser());
|
.RequireAuthenticatedUser());
|
||||||
options.AddPolicy(Policies.Write, p => p
|
options.AddPolicy(Policies.Write, p => p
|
||||||
.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName)
|
.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.2.1" />
|
||||||
<PackageReference Include="Markdig" Version="1.4.0" />
|
<PackageReference Include="Markdig" Version="1.4.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
|
||||||
<PackageReference Include="MudBlazor" Version="9.10.0" />
|
<PackageReference Include="MudBlazor" Version="9.10.0" />
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"Oidc": { "Authority": "", "ClientId": "" },
|
"Oidc": { "Authority": "", "ClientId": "" },
|
||||||
|
"Basic": { "UsersPath": "" },
|
||||||
"ApiKeys": {}
|
"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:ApiKeys:tester", ApiKey);
|
||||||
builder.UseSetting("Auth:Oidc:Authority", FakeIdp.Issuer);
|
builder.UseSetting("Auth:Oidc:Authority", FakeIdp.Issuer);
|
||||||
builder.UseSetting("Auth:Oidc:ClientId", FakeIdp.ClientId);
|
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.UseSetting("Wiki:Index:File:Directory", Path.Combine(Path.GetTempPath(), "wiki-host-tests", Guid.NewGuid().ToString("N")));
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user