#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.
76 lines
3.3 KiB
C#
76 lines
3.3 KiB
C#
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);
|
|
}
|