Fehlerseiten

Das Paket bringt gestaltete Seiten für die Statuscodes 400, 401, 403, 404 und 500 mit, dazu eine Seite für einen fehlgeschlagenen Anmeldevorgang am Identity Provider. Sie haben ein eigenes, schlankes Layout, es kommt ohne Bundles aus, damit auch dann noch etwas Lesbares erscheint, wenn die Anwendung selbst nicht mehr antwortet.

Die Einrichtung besteht aus zwei Aufrufen: AddSiwfErrorPages macht die Einstellungen verfügbar, UseSiwfErrorPages hängt die Middleware ein, die Statuscodes und unbehandelte Ausnahmen auf diese Seiten umleitet.

Die sechs Seiten

Sie kommen aus dem ErrorController des Pakets und sind erreichbar, sobald die Komponente referenziert ist – über /400, /401, /403, /404, /500 und /Error/RemoteAuthError.

@* Die Seiten laufen im eigenen Dokument statt direkt eingebettet: Sie bringen ihr eigenes Layout
   (_LayoutError) samt Inline-Styles mit, das sonst die Doku-Seite überschreiben würde. Im Rahmen
   bleibt beides isoliert, und gezeigt wird die echte Seite.

   Die Routen kommen aus dem ErrorController des Pakets und sind erreichbar, sobald die Komponente
   referenziert ist – ohne Zutun der Anwendung. *@
<div class="row g-3">
    @foreach (var seite in new[]
    {
        ("400", "Fehlerhafte Anfrage"),
        ("401", "Nicht angemeldet"),
        ("403", "Kein Zugriff"),
        ("404", "Nicht gefunden"),
        ("500", "Serverfehler"),
    })
    {
        <div class="col-lg-6">
            <p class="mb-1">
                <strong>@seite.Item1</strong> – @seite.Item2
                <a href="/@seite.Item1" target="_blank" rel="noopener" class="small ms-2">in neuem Tab öffnen</a>
            </p>
            <iframe src="/@seite.Item1"
                    title="Fehlerseite @seite.Item1"
                    class="w-100 border rounded"
                    style="height: 420px"></iframe>
        </div>
    }

    <div class="col-lg-6">
        <p class="mb-1">
            <strong>RemoteAuthError</strong> – Anmeldung am Identity Provider fehlgeschlagen
            <a href="/Error/RemoteAuthError" target="_blank" rel="noopener" class="small ms-2">in neuem Tab öffnen</a>
        </p>
        <iframe src="/Error/RemoteAuthError"
                title="Fehlerseite RemoteAuthError"
                class="w-100 border rounded"
                style="height: 420px"></iframe>
    </div>
</div>

Darstellung

400 – Fehlerhafte Anfrage in neuem Tab öffnen

401 – Nicht angemeldet in neuem Tab öffnen

403 – Kein Zugriff in neuem Tab öffnen

404 – Nicht gefunden in neuem Tab öffnen

500 – Serverfehler in neuem Tab öffnen

RemoteAuthError – Anmeldung am Identity Provider fehlgeschlagen in neuem Tab öffnen

Sechs fertige Seiten, alle mit demselben schlanken Layout (_LayoutError):

Adresse Seite Wofür
/400 Fehlerhafte Anfrage Die Anfrage war nicht verwertbar.
/401 Nicht angemeldet Es fehlt eine gültige Anmeldung.
/403 Kein Zugriff Angemeldet, aber ohne Berechtigung.
/404 Nicht gefunden Die Adresse gibt es nicht.
/500 Serverfehler Nennt zusätzlich die Trace-Id des Vorgangs.
/Error/RemoteAuthError Anmeldung fehlgeschlagen Nach einem Fehler beim Identity Provider.

Die Routen kommen aus dem ErrorController des Pakets und sind erreichbar, sobald die Komponente referenziert ist, ohne eine Zeile in der Anwendung. Genau deshalb lassen sie sich hier im Rahmen zeigen, obwohl diese Doku-Anwendung die Middleware gar nicht einhängt.

Das eigene Layout ist Absicht

_LayoutError bringt seine Gestaltung inline mit und lädt keine Bundles. Eine Fehlerseite muss auch dann noch etwas Lesbares zeigen, wenn die Anwendung schon nicht mehr richtig arbeitet, ein Layout, das erst Skripte und Stile nachlädt, wäre dafür der falsche Weg.

Die 500er-Seite nennt eine Trace-Id

Der ErrorController liest Activity.Current.TraceId, zeigt sie auf der Seite und schreibt gleichzeitig einen Log.Error-Eintrag mit derselben Id. Wer anruft, kann sie durchgeben, der Eintrag im Log ist damit ohne Suchen auffindbar. Das ist der praktische Grund, Anwender auf diese Seite zu leiten statt auf eine allgemeine Fehlermeldung.

RemoteAuthError wird nicht automatisch angesteuert

Anders als die fünf Statuscode-Seiten hat sie keine Zahlen-Route. Sie ist als Ziel für die Fehlerbehandlung der Authentifizierung gedacht (OnRemoteFailure) und braucht dort eine ausdrückliche Umleitung. Ihr Text verweist auf LoginUrl aus den Einstellungen, damit jemand mit einem veralteten Lesezeichen die richtige Adresse bekommt.

Einrichtung in der Anwendung

Zwei Hälften: AddSiwfErrorPages bindet die Section «ErrorPages» an das Setting, UseSiwfErrorPages hängt die Middleware ein. Die Tabs zeigen die Dateien dieser Anwendung im Original.

@* Kein eigenes Markup: Was hier zu zeigen ist, steht in Program.cs und appsettings.json – beide
   als Code-Tabs daneben. Die Wirkung ist in den Rahmen des ersten Beispiels zu sehen: Logo und
   Copyright-Zeile tragen die Werte aus der Section «ErrorPages».

   Diese Anwendung ruft bewusst nur AddSiwfErrorPages auf, nicht UseSiwfErrorPages – warum, steht
   im Anleitungs-Tab. *@
<p class="mb-2">Der Vergleich lässt sich direkt anstellen:</p>
<ul class="mb-0">
    <li>
        <a href="/404" target="_blank" rel="noopener">Fehlerseite 404 dieser Anwendung</a> –
        mit dem Logo aus <code>appsettings.json</code> (Breite 160&nbsp;px) und der Copyright-Zeile
        «SIWF» beziehungsweise «ISFM» je nach Sprache.
    </li>
    <li>
        Ohne die Section <code>ErrorPages</code> stünde dort das kleine SIWF-Standardlogo und
        durchgehend «SIWF» – der Rückfall, den die Views mitbringen.
    </li>
</ul>
using System;
using System.Linq;
using databinding.WebSecurity.Headers;
using databinding.WebSecurity.HtmlSanitizer.Settings;
using FMH.ICT.Basis.Web.Security.Security;
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.BenutzerPraesenzCheck;
using FMH.Komponente.Infrastruktur;
using FMH.Komponente.Infrastruktur.Bundle;
using FMH.Komponente.Infrastruktur.Mvc.Errors;
using FMH.Komponente.Serilog.Ingestion;
using FMH.Komponente.Tabelle.Erweiterungen;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;
using Serilog;
using Serilog.Sinks.SystemConsole.Themes;

namespace FMH.Komponente.WebUI
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            var configuration = builder.Configuration;

            builder.Host.UseSerilog((context, services, config) => config.Enrich.WithRequestHeader("User-Agent")
                .Enrich.WithClientIp()
                ////.Enrich.With(services.GetService<BenutzerTraceIdNet6Enricher>()) // Einkommentieren mit isAuthEnabled = true
                .ReadFrom.Configuration(configuration)
                .WriteTo.Console(theme: ConsoleTheme.None));

            // Stellt sicher, dass Static Web Assets referenzierter Razor Class Libraries (z.B. FMH.Komponente.Basis)
            // unabhängig von der Hosting-Umgebung auflösbar sind, damit WebOptimizer deren SCSS-/JS-Dateien findet.
            builder.WebHost.UseStaticWebAssets();

            // Add services to the container.
            // AdditionalMetadataProvider registrieren, damit [AdditionalMetadata(...)] die
            // ModelMetadata.AdditionalValues füllt, Voraussetzung für die Listen-EditorTemplates
            // (DropdownList/RadioList/CheckboxList/…, Schlüssel "Selection") sowie für die
            // Display-Optionen Bold/ValueColorClass und die Icons in Auswahlbox/CheckInfo.
            builder.Services.AddControllersWithViews(options =>
            {
                options.AddFmhWidgetOptions();

                // Nötig für die Tabellen-Beispiele mit AjaxDataUrl: Ohne den Binder darin kommt
                // GridDatatableParameter leer im Endpunkt an (DataTables sendet start/length/
                // order[0][dir]/search[value], nicht Skip/Take/…). AddFmhWidgetOptions kann ihn
                // nicht mitbringen, FMH.Komponente.Basis referenziert die Tabelle nicht.
                options.AddFmhTabelleOptions();
            });

            builder.Services.AddBundledAssets();

            // Links im Richtext zulassen. Die Voreinstellung des Sanitizers kennt weder das a-Tag
            // noch Attribute, ein eingefügter Link würde samt Text entfernt.
            //
            // Muss VOR der ersten Verwendung stehen: Serverseitig liest HtmlWhitelistSanitizer die
            // Werte über HtmlSanitizerRichtextDefauls.Options(), clientseitig meldet sie der
            // Endpunkt /api/widgets/HtmlSanitizer/settings an DOMPurify. Beide Seiten arbeiten damit
            // gegen dieselbe Liste.
            HtmlSanitizerRichtextDefauls.AddAllowedTags("a");
            HtmlSanitizerRichtextDefauls.AddAllowedAttributes("href", "target", "rel");

            // Die Upload-Validierung (DocumentUploadRequired) liest die Anzahl bereits
            // vorhandener Dokumente aus dem Request – dafür braucht sie den HttpContext.
            builder.Services.AddHttpContextAccessor();

            // Bindet die Section "BenutzerPraesenzPruefer" der appsettings.json an das Setting und
            // macht es über IOptions injizierbar, die Demo-Seite nutzt es genau so wie ein Projekt.
            builder.Services.AddBenutzerPraesenzPruefer(builder.Configuration);

            // Bindet die Section "ErrorPages" an das Setting. Die Fehlerseiten des Pakets
            // Infrastruktur sind über /400, /401, /403, /404 und /500 ohnehin erreichbar; erst
            // diese Zeile gibt ihnen Logo und Texte dieser Anwendung.
            //
            // Bewusst OHNE app.UseSiwfErrorPages(...): Die Middleware würde in Betrieb die
            // Umleitung auf die Fehlerseiten übernehmen und damit UseExceptionHandler ersetzen.
            // Beim Entwickeln hält sie sich über ihren Entwicklermodus selbst zurück, sie bleibt
            // hier trotzdem aussen vor, damit die Pipeline dieser Anwendung dem entspricht, was
            // die Code-Tabs der Demo-Seite «Fehlerseiten» zeigen.
            builder.Services.AddSiwfErrorPages(builder.Configuration);

            var app = builder.Build();
            app.UseSiwfLogIngestion();
            var isDev = app.Environment.IsDevelopment();

            // Configure the HTTP request pipeline.
            if (!isDev)
            {
                app.UseExceptionHandler("/Home/Error");

                // Ausnahme für die Integrationstests: Die Seiten der Area Integration zeigen auch
                // ausserhalb von Development die Entwickler-Fehlerseite. Der Test der doppelten
                // Render-Aufrufe liest dort die Meldung der Ausnahme, die allgemeine Fehlerseite
                // verschluckt sie. Liegt innerhalb von UseExceptionHandler und fängt deshalb zuerst.
                app.UseWhen(
                    context => context.Request.Path.StartsWithSegments("/Integration"),
                    integration => integration.UseDeveloperExceptionPage());

                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            var headerCollection = new HeaderCollectionBuilder().AddConnectSrcLocalhost()
                .AddUnsafeInlineScriptSrc()
                .AddBlazor()
                .Build();

            // frame-src/frame-ancestors auf 'self': Die Doku-Seite AnalyseDemo/Demo/AnalyseSeite zeigt
            // die Analyse-Seite derselben Anwendung in einem Rahmen. frame-src erlaubt das Einbetten,
            // frame-ancestors das Eingebettetwerden, beides bleibt auf die eigene Herkunft beschränkt.
            // Der HeaderCollectionBuilder kennt dafür keine Kurzform, deshalb hier am fertigen
            // HeaderCollection ergänzt, statt die Richtlinie neu aufzubauen.
            headerCollection.GetContentSecurityPolicyBuilder()
                .AddFrameSrc(direktive => direktive.Self())
                .AddFrameAncestors(direktive => direktive.Self());

            app.UseHeaderSecurity(headerCollection);

            app.UseHttpsRedirection();

            app.UseWebOptimizer();

            // Native ES-Module werden nicht gebündelt. Der Versionsstempel wirkt nur auf die jeweilige
            // Einstiegsdatei, nicht auf die transitiv importierten Module. Deshalb erzwingen wir für die
            // Modulverzeichnisse eine ETag-Revalidierung bei jedem Laden, damit Änderungen an jeder Datei
            // im Modul-Graphen sofort sichtbar werden. Die Verzeichnisse liefert der Modulkatalog, damit
            // ein neues Modul hier nicht nachgetragen werden muss.
            var modulVerzeichnisse = app.Services.GetRequiredService<ModuleScriptRegistry>()
                .GetModuleDirectories();

            // Nur für die Demo: Dateitypen ohne bekannten MIME-Typ liefert UseStaticFiles nicht aus.
            // Die Beispiele des DocumentViewers brauchen aber ein Format, das er bewusst nicht
            // darstellen kann, dort ist eine Outlook-Nachricht der realistische Fall.
            var inhaltstypen = new FileExtensionContentTypeProvider();
            inhaltstypen.Mappings[".msg"] = "application/vnd.ms-outlook";

            // Nicht nur für die Demo: pdf.js im PDFViewer lädt Zeichensatztabellen (.bcmap) und
            // Farbprofile (.icc) nach, beide kennt UseStaticFiles nicht und antwortet mit 404.
            inhaltstypen.Mappings[".bcmap"] = "application/octet-stream";
            inhaltstypen.Mappings[".icc"] = "application/vnd.iccprofile";

            app.UseStaticFiles(
                new StaticFileOptions
                {
                    ContentTypeProvider = inhaltstypen,
                    OnPrepareResponse = context =>
                    {
                        var pfad = context.Context.Request.Path.Value;

                        if (pfad != null && modulVerzeichnisse.Any(verzeichnis =>
                                pfad.StartsWith(verzeichnis, StringComparison.OrdinalIgnoreCase)))
                        {
                            context.Context.Response.Headers[HeaderNames.CacheControl] = "no-cache, must-revalidate";
                        }
                    },
                });

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute("areas", "{area:exists}/{controller=Home}/{action=Index}/{id?}");

            // Sprechende Adressen für die Anleitungen: /Anleitungen/logging/ueberblick. Beide Segmente
            // sind Pflicht, deshalb fällt /Anleitungen weiter auf die Default-Route und landet auf Index.
            app.MapControllerRoute("anleitungen", "Anleitungen/{gruppe}/{seite}", new { controller = "Anleitungen", action = "Seite" });

            app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");

            app.Run();
        }
    }
}
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "BenutzerPraesenzPruefer": {
    "IntervalInSekunden": 2,
    "Wiederholungen": 5,
    "LogoutUrl": "/BenutzerPraesenzCheck/PraesenzPruefung/Abmeldung",
    "PruefEndpunkt": "/BenutzerPraesenzCheck/PraesenzPruefung/Status",
    "IstAktiviert": true,
    "AuthCheckIntervalInSekunden": 45,
    "IstAuthCheckAktiviert": false
  },
  "ErrorPages": {
    "LogoUrl": "/_content/FMH.Komponente.Infrastruktur/images/logo_siwf.png",
    "LogoAlternativText": {
      "de": "SIWF",
      "fr": "ISFM",
      "en": "SIWF",
      "it": "SIWF"
    },
    "LogoUrlWidthInPixel": 160,
    "LoginUrl": "https://login.siwf.ch"
  },
  "Serilog": {
    "MinimumLevel": {
      "Default": "Debug",
      "Override": {
        "Microsoft": "Error"
      }
    },
    "Properties": {
      "Release": "0.0.0.0-development",
      "App": "FMH.Komponente.WebUI"
    },
    "WriteTo": [
      {
        "Name": "Seq",
        "Args": {
          "ServerUrl": "http://127.0.0.1:5341",
          "ApiKey": ""
        }
      }
    ]
  }
}

Darstellung

Der Vergleich lässt sich direkt anstellen:

  • Fehlerseite 404 dieser Anwendung – mit dem Logo aus appsettings.json (Breite 160 px) und der Copyright-Zeile «SIWF» beziehungsweise «ISFM» je nach Sprache.
  • Ohne die Section ErrorPages stünde dort das kleine SIWF-Standardlogo und durchgehend «SIWF» – der Rückfall, den die Views mitbringen.

Die Einrichtung besteht aus zwei Aufrufen. Sie zu trennen ist die häufigste Stolperstelle: Fehlt der erste, erscheinen die Seiten trotzdem, aber immer mit dem SIWF-Standardlogo.

// 1. Einstellungen verfügbar machen
builder.Services.AddSiwfErrorPages(builder.Configuration);

// 2. Middleware einhängen
app.UseSiwfErrorPages(app.Environment.IsDevelopment());

AddSiwfErrorPages bindet die Section ErrorPages an ErrorPagesSetting und protokolliert die Werte einmalig beim Start. Alle sechs Views beziehen das Setting über IOptions<ErrorPagesSetting> ohne die Registrierung greift überall der Rückfall.

Die Section in der appsettings.json

"ErrorPages": {
  "LogoUrl": "/_content/FMH.Komponente.Infrastruktur/images/logo_siwf.png",
  "LogoAlternativText": {
    "de": "SIWF",
    "fr": "ISFM",
    "en": "SIWF",
    "it": "SIWF"
  },
  "LogoUrlWidthInPixel": 160,
  "LoginUrl": "https://login.siwf.ch"
}
Eigenschaft Wirkung Ohne Angabe
LogoUrl Adresse des Logos auf der Seite. /_content/FMH.Komponente.Infrastruktur/images/logo_siwf_small.png
LogoUrlWidthInPixel Breite in Pixel, zum Abstimmen auf das eigene Logo. ohne Breitenangabe gerendert
LogoAlternativText Alternativtext je Sprache, Schlüssel als zweistelliger Iso-Code. SIWF
LoginUrl Adresse des Identity Providers, eingesetzt im Text von RemoteAuthError. leer

Das Paket liefert vier Logos mit: logo_siwf.png (306×128), logo_siwf_small.png (181×68), logo_siwf_xs.png (130×54) und logo_fmh.png (268×128).

Der Alternativtext erscheint zweimal

Er steht nicht nur am Bild, sondern auch im Seitentitel (404 – Nicht gefunden - SIWF) und in der Copyright-Zeile (© 2026 - SIWF). Deshalb gehört dort nur das Kürzel hinein, nicht „Logo des SIWF". Fehlt die aktuelle Sprache im Wörterbuch, greift der erste Eintrag; fehlt das Wörterbuch ganz, greift SIWF.

Was die Middleware tut

UseSiwfErrorPages hängt StatusCodeExceptionHandler ein. Der:

  • behandelt die Statuscodes 400, 401, 403, 404 und 500, aber nur, wenn die Antwort noch nicht begonnen hat (Response.HasStarted), sonst entstünden Folgefehler bei laufenden Umleitungen;
  • fängt unbehandelte Ausnahmen und schreibt Log.Fatal mit Methode und Adresse;
  • entscheidet die Form der Antwort allein an der Anfrage: Ein Seitenaufruf wird auf /<code> umgeleitet, ein Aufruf mit JSON bekommt { "State": <code>, "message": … } und behält seinen Statuscode. Einstellbar ist das nicht, und es gilt für Statuscodes und Ausnahmen gleich.
Seitenaufruf Aufruf mit JSON
Statuscode 404 302 auf /404 404 mit { "State": 404, "message": "Status Code 404" }
Unbehandelte Ausnahme 302 auf /500 500 mit { "State": 500, "message": "<Meldung>" }

Der Entwicklermodus hält die Middleware zurück

UseSiwfErrorPages(app.Environment.IsDevelopment()) gibt die Angabe weiter. Ist sie gesetzt, dann:

  • bleibt jeder Statuscode stehen, es gibt weder Umleitung noch JSON-Rumpf;
  • wird eine unbehandelte Ausnahme weitergegeben statt beantwortet.

Das Weitergeben ist der Sinn der Sache: Nur eine Ausnahme, welche die Middleware verlässt, erreicht die Entwickler-Fehlerseite von ASP.NET mit Meldung, Stapel und Anfrage. Würde sie hier beantwortet, blieben beim Entwickeln allein die Angaben im Protokoll.

Diese Doku-Anwendung ruft bewusst nur die erste Hälfte auf

In Program.cs steht AddSiwfErrorPages, aber kein UseSiwfErrorPages. Die Seiten sind trotzdem über /400 bis /500 erreichbar, und dank AddSiwfErrorPages tragen sie Logo und Texte dieser Anwendung. Beide Dateien stehen als Code-Tabs daneben.

Mit UseSiwfErrorPages(app.Environment.IsDevelopment()) liesse sich die Middleware inzwischen einhängen, ohne beim Entwickeln die Entwickler-Fehlerseite zu verlieren; der Entwicklermodus hält sie zurück. Die Doku-Anwendung lässt sie dennoch aussen vor, damit ihre Pipeline dem entspricht, was die Code-Tabs zeigen, und ein Fehler hier immer auf demselben Weg sichtbar wird.

Was zu beachten ist

  • Nicht mit UseStatusCodePages kombinieren. Beide behandeln dieselben Statuscodes; welche Antwort gewinnt, hängt an der Reihenfolge in der Pipeline und ist keine Eigenschaft, auf die man sich stützen sollte.
  • Die JSON-Erkennung liest den Inhaltstyp der Anfrage, über HttpRequest.HasJsonContentType() des Frameworks. Parameter und Schreibweise sind ohne Bedeutung, application/json; charset=utf-8 und APPLICATION/JSON gelten also, und die Ausprägungen mit Endung +json wie application/problem+json ebenso. Nicht dazu gehört text/json.
  • Ein GET gilt nie als JSON-Anfrage. Ohne Rumpf trägt eine Anfrage keinen Inhaltstyp, und der Accept-Header wird nicht gelesen. Ein Aufruf aus JavaScript, der Daten nur abholt, bekommt deshalb die Umleitung auf die HTML-Seite. Wer die Statuscodes dort auswerten will, prüft die Antwort auf 302.
  • Im Entwicklermodus wird die Ausnahme weitergegeben. Wer die Middleware dort einhängt, braucht hinter ihr etwas, das die Ausnahme aufnimmt. In der Entwicklung ist das die Entwickler-Fehlerseite, die WebApplication von selbst einhängt; wer sie abgeschaltet hat, bekommt eine leere 500er-Antwort.
  • Ohne AddSiwfErrorPages sind Logo und Texte der Rückfall, und das fällt erst auf, wenn tatsächlich eine Fehlerseite erscheint. Ein Aufruf von /404 nach dem Einrichten ist deshalb die einfachste Kontrolle.
  • Der Protokolleintrag beim Start ist derzeit nicht zu sehen. AddSiwfErrorPages ruft LogSetting() auf, das über den statischen Serilog-Logger schreibt. Die Dienste werden aber vor builder.Build() registriert, zu diesem Zeitpunkt ist Log.Logger noch der stille Standard. Das gilt genauso für AddBenutzerPraesenzPruefer; wer die Werte im Log sehen will, protokolliert sie nach dem Bauen der Anwendung erneut.