Die Weboberfläche — im Zuschnitt von Plex, vom Server selbst ausgeliefert
Einrichtung, Anmeldung, Mediathek, Detailseite mit Abspielen und Herunterladen, Zuordnung korrigieren, Verwaltung. Die Oberfläche aus der Jellyfin-Zeit ist dabei vollständig ersetzt: `types.ts`, `prefs.ts`, `contrast.ts`, `views/cards.ts`, `views/player.ts` und `views/settings.ts` sprachen Jellyfins Schnittstelle und hatten hier nichts mehr zu suchen. Seitenleiste links, waagerechte Reihen auf der Startseite, Plakate im Verhältnis 2:3, unscharfer Hintergrund hinter dem Kopf der Detailseite — das ist der Zuschnitt von Plex, weil er gefällt. **Die Farbe bleibt Flaros Orchidee**, nicht Plex' Orange; der Aufbau ist das Vorbild, nicht das Aussehen. Welche Bibliothek gezeigt wird, entscheidet die Seitenleiste. Eine zweite Umschaltung innerhalb der Seite wäre dieselbe Entscheidung an zwei Orten. --- **Der Zugang liegt jetzt auch in einem Cookie.** Ein `<img>`, ein `<video>` und ein Download-Link senden keine eigenen Kopfzeilen — eine App kann ihr Token in jede Anfrage legen, ein Browser nicht. Das Token stattdessen in die Adresse zu schreiben wäre der naheliegende Ausweg und stünde danach in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link. `HttpOnly`, damit kein eingeschlichenes Skript herankommt. `SameSite=Strict` gegen Anfragen von fremden Seiten. `Secure` nur über https — sonst liesse sich die Oberfläche im Heimnetz über http gar nicht benutzen. Die Apps benutzen weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein zweites Verfahren. --- **Der Fehler, den erst der echte Container zeigte:** eine weisse Seite, ohne Hinweis worauf. Die Rückfallregel verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und `/assets/index.js` hat keinen. Die statischen Dateien standen unter `UseAuthorization` — ein neuer Besucher bekam 401 auf genau das Skript, das ihm die Anmeldemaske zeichnen sollte. `/` kam durch, weil der SPA-Rückfall `AllowAnonymous` trägt; alles mit Dateiendung nicht. Jetzt liegen sie davor. Mit Prüfung. Beim Bau desselben Images der zweite: seit der Kontext die Projektwurzel ist, überschrieb `COPY server/` die `obj/`-Verzeichnisse aus dem Container mit denen vom Arbeitsrechner — die zeigen auf osx-arm64, gebaut wird linux-arm64, `dotnet publish` brach mit NETSDK1047 ab. Dafür gibt es jetzt eine `.dockerignore` in der Wurzel; die unter `server/` sucht Docker seit dem Umzug nicht mehr. --- Vier Prüfungen schlugen danach fehl, und zwar zu Recht: sie hiessen „ohne Anmeldung gibt es nichts", benutzten aber den Client, der nach dem Anmelden ein Cookie hält. Sie prüften also den Angemeldeten. Jetzt sagen sie ausdrücklich, dass sie ohne Zugang fragen. 313 Tests grün. Im Container nachgewiesen: Anmelden, Plakate, Abspielen, Staffeln und Folgen, Verwaltung — und ein Neuladen auf `/verwaltung`, das nicht in einem 404 endet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7cf6c5def
commit
91eb934048
@@ -1,6 +0,0 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/*.user
|
||||
data/
|
||||
cache/
|
||||
transcode/
|
||||
+15
-4
@@ -1,5 +1,15 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Die Oberfläche zuerst, in einem eigenen Abschnitt: sie braucht Node, der
|
||||
# Server nicht. So landet Node nie im fertigen Image.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /web
|
||||
COPY web/app/package.json web/app/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/app/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Gebaut wird auf der Architektur des Bauwirts, übersetzt wird für die des
|
||||
# Ziels — `dotnet publish -a` kann das. Ohne diese Trennung liefe der Bau für
|
||||
# arm64 unter Emulation und dauerte ein Vielfaches.
|
||||
@@ -13,11 +23,11 @@ WORKDIR /src
|
||||
# `--parents` behält die Verzeichnisse bei. **Ein Muster statt einer Liste**,
|
||||
# weil eine Liste bei jedem neuen Projekt vergessen wird — und dann scheitert
|
||||
# erst der Bau im Container, nicht der auf dem Arbeitsrechner.
|
||||
COPY --parents src/*/*.csproj ./
|
||||
RUN dotnet restore src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
|
||||
COPY --parents server/src/*/*.csproj ./
|
||||
RUN dotnet restore server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
|
||||
COPY server/ ./server/
|
||||
RUN dotnet publish server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
|
||||
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
@@ -65,6 +75,7 @@ ENV FLARO_DATA=/config \
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
COPY --from=web /web/dist ./wwwroot
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/config", "/cache", "/transcode"]
|
||||
|
||||
+33
-3
@@ -65,10 +65,34 @@ in Software; das ist langsamer, aber es läuft. **Zum blossen Umpacken wird gar
|
||||
keine Hardware gebraucht**, und das ist der Weg, der den meisten Beständen
|
||||
hilft.
|
||||
|
||||
## Weboberfläche
|
||||
|
||||
Der Server liefert sie selbst aus, unter derselben Adresse wie die
|
||||
Schnittstelle. Kein Proxy davor, kein CORS, kein Mischmasch aus http und
|
||||
https. Gebaut wird sie im Image mit; wer nur an ihr arbeitet, nimmt Vite:
|
||||
|
||||
```bash
|
||||
cd ../web/app
|
||||
npm install
|
||||
FLARO_SERVER=http://localhost:18080 npm run dev # auf http://localhost:5173
|
||||
```
|
||||
|
||||
Vite reicht `/api` an den Server weiter, damit die Herkunft dieselbe bleibt
|
||||
wie im Betrieb.
|
||||
|
||||
**Der Zugang liegt in einem Cookie, nicht nur in der Kopfzeile.** Ein
|
||||
`<img>`, ein `<video>` und ein Download-Link senden keine eigenen
|
||||
Kopfzeilen; das Token stattdessen in die Adresse zu schreiben hiesse, es in
|
||||
jedes Zugriffsprotokoll und jeden weitergegebenen Link zu setzen. Das Cookie
|
||||
ist `HttpOnly` und `SameSite=Strict`, `Secure` nur über https — sonst liesse
|
||||
sich die Oberfläche im Heimnetz gar nicht benutzen. Die Apps benutzen
|
||||
weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein zweites
|
||||
Verfahren.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
dotnet test # 293 Tests
|
||||
dotnet test # 313 Tests
|
||||
dotnet run --project src/Flaro.Api # auf http://localhost:5000
|
||||
```
|
||||
|
||||
@@ -76,8 +100,14 @@ Ohne gesetzte Variablen legt der Server `data/`, `cache/` und `transcode/`
|
||||
neben dem Programm an.
|
||||
|
||||
```bash
|
||||
# Image bauen, beide Architekturen
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t flaro:dev .
|
||||
# Image bauen, beide Architekturen.
|
||||
# **Aus der Wurzel des Projekts**, nicht aus server/: der Bau braucht
|
||||
# web/app für die Oberfläche.
|
||||
cd .. && docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-f server/Dockerfile -t flaro:dev .
|
||||
|
||||
# Ein lokaler Server mit Beispielmedien zum Ausprobieren
|
||||
Tools/demo-medien.sh && docker compose -f server/docker-compose.local.yml up -d --build
|
||||
|
||||
# Eine Migration nach einer Modelländerung
|
||||
dotnet ef migrations add <Name> --project src/Flaro.Data --startup-project src/Flaro.Api --output-dir Migrations
|
||||
|
||||
@@ -32,16 +32,41 @@ public sealed class TokenAuthenticationHandler(
|
||||
public const string SchemeName = "FlaroToken";
|
||||
public const string DeviceTokenIdClaim = "flaro:device";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
/// <summary>Derselbe Zugang, nur für den Browser.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Weil <c><img></c>, <c><video></c> und ein Download-Link
|
||||
/// keine Kopfzeile setzen können.</b> Ohne Cookie müsste die Oberfläche
|
||||
/// jedes Plakat und jeden Film über <c>fetch</c> holen und im
|
||||
/// Arbeitsspeicher halten — bei einem Film ist das keine Lösung, und ein
|
||||
/// Token in der Adresse stünde in jedem Zugriffsprotokoll.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>HttpOnly</c>, damit kein Skript herankommt, und
|
||||
/// <c>SameSite=Strict</c> gegen Anfragen von fremden Seiten. Apps benutzen
|
||||
/// weiterhin die Kopfzeile; das Cookie ist ein zweiter Weg zum selben
|
||||
/// Token, kein zweites Verfahren.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string CookieName = "flaro_session";
|
||||
|
||||
private string? AusKopfzeile()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return AuthenticateResult.NoResult();
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return null;
|
||||
|
||||
var roh = kopfzeile.ToString();
|
||||
const string vorsatz = "Bearer ";
|
||||
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return AuthenticateResult.NoResult();
|
||||
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var token = roh[vorsatz.Length..].Trim();
|
||||
if (token.Length == 0) return AuthenticateResult.NoResult();
|
||||
return token.Length == 0 ? null : token;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
|
||||
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
|
||||
|
||||
var fingerabdruck = Secrets.Fingerprint(token);
|
||||
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class AuthEndpoints
|
||||
public static void MapAuth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/v1/auth/login", async (
|
||||
LoginRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
||||
LoginRequest anfrage, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
|
||||
{
|
||||
var name = anfrage.UserName.Trim();
|
||||
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
|
||||
@@ -45,11 +45,14 @@ public static class AuthEndpoints
|
||||
|
||||
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
|
||||
await db.SaveChangesAsync();
|
||||
CookieSetzen(zusammenhang, antwort.Token);
|
||||
return Results.Ok(antwort);
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
|
||||
app.MapPost("/api/v1/auth/logout", async (ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
app.MapPost("/api/v1/auth/logout", async (
|
||||
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
|
||||
{
|
||||
zusammenhang.Response.Cookies.Delete(FlaroTokenCookie);
|
||||
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
|
||||
return Results.Unauthorized();
|
||||
|
||||
@@ -178,6 +181,27 @@ public static class AuthEndpoints
|
||||
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
|
||||
}
|
||||
|
||||
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
|
||||
|
||||
/// <summary>Legt dasselbe Token als Cookie ab — für den Browser.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>HttpOnly</c>: kein Skript kommt heran, also auch keines, das sich
|
||||
/// über eine Lücke in der Oberfläche eingeschlichen hat. <c>Strict</c>:
|
||||
/// eine fremde Seite kann damit nichts auslösen. <c>Secure</c> nur bei
|
||||
/// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar
|
||||
/// nicht benutzen.
|
||||
/// </remarks>
|
||||
private static void CookieSetzen(HttpContext zusammenhang, string token) =>
|
||||
zusammenhang.Response.Cookies.Append(FlaroTokenCookie, token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Secure = zusammenhang.Request.IsHttps,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromDays(365),
|
||||
});
|
||||
|
||||
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
|
||||
private static IResult Abgelehnt() =>
|
||||
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
|
||||
@@ -106,6 +106,27 @@ using (var bereich = app.Services.CreateScope())
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.UseRateLimiter();
|
||||
|
||||
// --- Die Oberfläche -------------------------------------------------------
|
||||
//
|
||||
// **Vom Server selbst ausgeliefert, nicht von einem Proxy davor.** Damit
|
||||
// liegen Seite und Schnittstelle unter derselben Adresse: kein CORS, kein
|
||||
// Mischmasch aus http und https, und der Zugang bleibt beim Server statt im
|
||||
// Browser. Der Node-Proxy aus der Jellyfin-Zeit wird dadurch überflüssig.
|
||||
//
|
||||
// **Vor der Anmeldung, und das ist kein Versehen.** Die Rückfallregel weiter
|
||||
// oben verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten
|
||||
// Benutzer — und `/assets/index.js` hat keinen. Stünde das hier unter der
|
||||
// Anmeldung, bekäme ein neuer Besucher 401 auf genau das Skript, das ihm die
|
||||
// Anmeldemaske zeichnen soll. Die Seite selbst ist kein Geheimnis; sie zeigt
|
||||
// ohne gültigen Zugang nichts als ein Anmeldefeld.
|
||||
var oberfläche = Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
if (Directory.Exists(oberfläche))
|
||||
{
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -118,6 +139,14 @@ app.MapImages();
|
||||
app.MapDelivery();
|
||||
app.MapMatching();
|
||||
|
||||
// Alles, was keine Schnittstelle ist, bekommt die Seite — der Router läuft
|
||||
// im Browser. Ohne das endet ein Neuladen auf /admin in einem 404.
|
||||
if (Directory.Exists(oberfläche))
|
||||
{
|
||||
app.MapFallback(() => Results.File(Path.Combine(oberfläche, "index.html"), "text/html"))
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
||||
|
||||
@@ -104,7 +104,10 @@ public class CodeSignInTests
|
||||
await server.SetUpAndSignIn();
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
|
||||
var versuch = await server.Client.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||
// den Angemeldeten — und bestünde nie wieder.
|
||||
var versuch = await server.OhneGedaechtnis().PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
|
||||
var danach = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
|
||||
@@ -186,7 +186,10 @@ public class DeliveryTests
|
||||
Film("Geschuetzt (2020)", "mp4");
|
||||
var (_, datei) = await Eingelesen();
|
||||
|
||||
var antwort = await _server!.Client.GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||
// den Angemeldeten — und bestünde nie wieder.
|
||||
var antwort = await _server!.OhneGedaechtnis().GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -24,7 +25,12 @@ public sealed class FlaroServer : IDisposable
|
||||
|
||||
public HttpClient Client { get; }
|
||||
|
||||
public FlaroServer(Action<IServiceCollection>? dienste = null)
|
||||
/// <param name="inhaltswurzel">
|
||||
/// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test
|
||||
/// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter
|
||||
/// <c>wwwroot</c> neben sich.
|
||||
/// </param>
|
||||
public FlaroServer(Action<IServiceCollection>? dienste = null, string? inhaltswurzel = null)
|
||||
{
|
||||
_verzeichnis = Path.Combine(Path.GetTempPath(), "flaro-test-" + Guid.CreateVersion7().ToString("N"));
|
||||
Environment.SetEnvironmentVariable("FLARO_DATA", Path.Combine(_verzeichnis, "config"));
|
||||
@@ -36,27 +42,32 @@ public sealed class FlaroServer : IDisposable
|
||||
Environment.SetEnvironmentVariable("FLARO_RATELIMIT_SIGNIN", "10000");
|
||||
|
||||
_grundlage = new WebApplicationFactory<Program>();
|
||||
_fabrik = _grundlage.WithWebHostBuilder(b => b.ConfigureServices(sammlung =>
|
||||
_fabrik = _grundlage.WithWebHostBuilder(b =>
|
||||
{
|
||||
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
|
||||
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
|
||||
// bei einem Testserver je Test summiert sich das auf mehr Zeit,
|
||||
// als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts,
|
||||
// worauf zu warten wäre.
|
||||
sammlung.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50));
|
||||
if (inhaltswurzel is not null) b.UseContentRoot(inhaltswurzel);
|
||||
|
||||
// **Keine Anfragen nach draussen, solange ein Test es nicht
|
||||
// ausdrücklich will.** Seit ein Schlüssel mitgeliefert wird, würde
|
||||
// jeder Scan sonst wirklich bei TMDB nachfragen: die Tests hingen
|
||||
// am WLAN, und ihre Erwartungen an fremden Daten. „Der Untergang"
|
||||
// hiess prompt „Downfall", weil die Voreinstellung Englisch ist.
|
||||
//
|
||||
// Vor `dienste`, damit ein Test seinen eigenen Anbieter
|
||||
// nachschieben kann — die spätere Eintragung gewinnt.
|
||||
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
|
||||
b.ConfigureServices(sammlung =>
|
||||
{
|
||||
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
|
||||
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
|
||||
// bei einem Testserver je Test summiert sich das auf mehr Zeit,
|
||||
// als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts,
|
||||
// worauf zu warten wäre.
|
||||
sammlung.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50));
|
||||
|
||||
dienste?.Invoke(sammlung);
|
||||
}));
|
||||
// **Keine Anfragen nach draussen, solange ein Test es nicht
|
||||
// ausdrücklich will.** Seit ein Schlüssel mitgeliefert wird, würde
|
||||
// jeder Scan sonst wirklich bei TMDB nachfragen: die Tests hingen
|
||||
// am WLAN, und ihre Erwartungen an fremden Daten. „Der Untergang"
|
||||
// hiess prompt „Downfall", weil die Voreinstellung Englisch ist.
|
||||
//
|
||||
// Vor `dienste`, damit ein Test seinen eigenen Anbieter
|
||||
// nachschieben kann — die spätere Eintragung gewinnt.
|
||||
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
|
||||
|
||||
dienste?.Invoke(sammlung);
|
||||
});
|
||||
});
|
||||
Client = _fabrik.CreateClient();
|
||||
}
|
||||
|
||||
@@ -84,6 +95,16 @@ public sealed class FlaroServer : IDisposable
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Ein Client, der Cookies weder behält noch mitschickt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Der gewöhnliche Testclient führt im Hintergrund einen Cookie-Behälter
|
||||
/// mit. Wer prüfen will, <i>ob</i> ein Cookie wirkt, darf sich nicht
|
||||
/// darauf verlassen — sonst prüft er den Behälter, nicht den Server.
|
||||
/// </remarks>
|
||||
public HttpClient OhneGedaechtnis() =>
|
||||
_fabrik.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false });
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fabrik.Dispose();
|
||||
|
||||
@@ -383,7 +383,10 @@ public class ImageEndpointTests
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.Client.GetAsync($"/api/v1/images/{new string('a', 64)}");
|
||||
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||
// den Angemeldeten — und bestünde nie wieder.
|
||||
var antwort = await server.OhneGedaechtnis().GetAsync($"/api/v1/images/{new string('a', 64)}");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,10 @@ public class SignInTests
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.Client.GetAsync("/api/v1/auth/me");
|
||||
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
|
||||
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
|
||||
// den Angemeldeten — und bestünde nie wieder.
|
||||
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Was der Browser braucht und die Apps nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine App legt ihr Token in jede Anfrage. Ein Browser kann das nicht:
|
||||
/// <c><img></c>, <c><video></c> und ein Download-Link senden keine
|
||||
/// eigenen Kopfzeilen. Das Token in die Adresse zu schreiben wäre der
|
||||
/// naheliegende Ausweg — und stünde danach in jedem Zugriffsprotokoll, in
|
||||
/// jedem Verlauf und in jedem weitergegebenen Link. Also ein Cookie.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class WebInterfaceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Anmeldung_legt_ein_Cookie_ab()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
||||
|
||||
var gesetzt = antwort.Headers.GetValues("Set-Cookie").Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(gesetzt, Does.StartWith($"{TokenAuthenticationHandler.CookieName}="));
|
||||
// Kein Skript kommt heran, auch keines, das sich eingeschlichen hat.
|
||||
Assert.That(gesetzt, Does.Contain("httponly").IgnoreCase);
|
||||
// Eine fremde Seite kann damit nichts auslösen.
|
||||
Assert.That(gesetzt, Does.Contain("samesite=strict").IgnoreCase);
|
||||
// Über http wäre „secure" ein Cookie, das der Browser nie schickt —
|
||||
// und damit eine Oberfläche, die sich im Heimnetz nicht bedienen lässt.
|
||||
Assert.That(gesetzt, Does.Not.Contain("secure").IgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cookie_allein_genuegt_fuer_eine_Anfrage()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var client = server.OhneGedaechtnis();
|
||||
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
||||
var keks = Keks(angemeldet);
|
||||
|
||||
// Ohne Kopfzeile — genau so, wie ein <img> fragt.
|
||||
var anfrage = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
|
||||
anfrage.Headers.Add("Cookie", keks);
|
||||
var antwort = await client.SendAsync(anfrage);
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ohne_Cookie_und_ohne_Kopfzeile_bleibt_es_bei_401()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/movies");
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Abmelden_entwertet_das_Cookie()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var client = server.OhneGedaechtnis();
|
||||
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
|
||||
var keks = Keks(angemeldet);
|
||||
|
||||
var abmelden = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
|
||||
abmelden.Headers.Add("Cookie", keks);
|
||||
(await client.SendAsync(abmelden)).EnsureSuccessStatusCode();
|
||||
|
||||
// **Das Token muss serverseitig weg sein, nicht nur das Cookie im
|
||||
// Browser.** Ein gelöschtes Cookie schützt niemanden, der den Wert
|
||||
// vorher mitgeschrieben hat.
|
||||
var danach = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
|
||||
danach.Headers.Add("Cookie", keks);
|
||||
|
||||
Assert.That((await client.SendAsync(danach)).StatusCode,
|
||||
Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Die Oberfläche kommt heraus, bevor jemand angemeldet ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Fehler, den dieser Test festhält.</b> Die Rückfallregel verlangt
|
||||
/// für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und
|
||||
/// <c>/assets/index.js</c> hat keinen. Standen die statischen Dateien unter
|
||||
/// <c>UseAuthorization</c>, bekam ein neuer Besucher 401 auf genau das
|
||||
/// Skript, das ihm die Anmeldemaske zeichnen sollte: eine weisse Seite,
|
||||
/// ohne Hinweis worauf.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Die_Oberflaeche_braucht_keine_Anmeldung()
|
||||
{
|
||||
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
|
||||
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
|
||||
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "app.js"), "export const da = true;");
|
||||
|
||||
try
|
||||
{
|
||||
using var server = new FlaroServer(inhaltswurzel: wurzel);
|
||||
var client = server.OhneGedaechtnis();
|
||||
|
||||
var seite = await client.GetAsync("/");
|
||||
var skript = await client.GetAsync("/assets/app.js");
|
||||
// Der Router läuft im Browser: ein Neuladen auf einer Unterseite
|
||||
// darf nicht in einem 404 enden.
|
||||
var unterseite = await client.GetAsync("/verwaltung");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(seite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(skript.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(unterseite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
});
|
||||
|
||||
Assert.That(await skript.Content.ReadAsStringAsync(), Is.EqualTo("export const da = true;"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary>
|
||||
private static string Keks(HttpResponseMessage antwort) =>
|
||||
antwort.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
||||
}
|
||||
Reference in New Issue
Block a user