#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.
145 lines
6.4 KiB
C#
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";
|
|
}
|