Files
Wiki/src/Wiki.Host/Auth/BasicAuthenticationHandler.cs
T
hermes-bot 50fb31b950
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
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.
2026-10-19 14:06:55 +00:00

145 lines
6.4 KiB
C#

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";
}