Der eigene kompakte und sichere Web Server mit .NET 2.0 (Teil 2)

Veröffentlicht: 10. Jul 2006

Von Dominick Baier

Im ersten Teil dieses Artikels haben Sie gesehen, wie man mit Hilfe des HTTP.SYS APIs und der HttpListener Klasse einen kompakten eigenen Web Server mit Authentifizierungs- und SSL-Unterstützung implementieren kann. Der Server unterstützt im Moment nur statische Seiten bzw. printf Seiten-Erzeugung, doch mit nur etwas Mehraufwand lässt sich auch ASP.NET integrieren. Um das Web Server-Fundament, auf dem dieser Artikel aufbaut, zu verstehen, wird empfohlen, den ersten Teil zuerst zu lesen.

Auf dieser Seite

 Architektur
 Kommunikation zwischen Host und ASP.NET
 Ein Blick hinter die Kulissen
 Erweitern von SimpleWorkerRequest
 Hinzufügen von Authentifizierung
 Zusammenfassung
 Der Autor

Architektur

Die ganze Architektur von ASP.NET basiert auf der Idee, dass die HTTP Runtime von einem Host geladen wird – und der üblichste Host ist IIS. Der ein oder andere Leser kennt vielleicht Cassini, einen socket-basierten Web Server mit ASP.NET-Unterstützung, der auch als Vorlage für den Visual Studio Web Server gedient hat. Beide benutzen die Klassen und APIs aus dem System.Web.Hosting-Namensraum für die ASP.NET-Integration - und genau diese Schnittstellen werden wir auch für unseren HTTP.SYS-basierten Web Server verwenden.

Es ist wichtig zu verstehen, dass die ASP.NET-Architektur verlangt, dass Host und Web-Anwendung in getrennten AppDomains ausgeführt werden. Dies erlaubt Features wie Shadow Copying der verwendeten Assemblies sowie das automatische Recycling der Web-Anwendung bei Änderung an bestimmten Dateien wie web.config. Dies bedeutet, dass eine Klasse notwendig ist, die Aufrufe zwischen dem Host und der ASP.NET AppDomain möglich macht. Diese Klasse muss einen Remoting Proxy unterstützen (für Cross-AppDomain Calls) und wird deshalb von MarshalByRefObject abgeleitet. Der Rumpf diese Klasse sieht folgendermaßen aus (um die Implementierung kümmern wir uns später):

class AspNet : MarshalByRefObject
{
  internal void ProcessRequest(string page)
  {
    // aufruf an die ASP.NET runtime weiterleiten
  }
}

Die ProcessRequest-Methode erhält alle nötigen Informationen vom Host, verpackt diese in das richtige Format und macht den Aufruf an ASP.NET in der richtigen AppDomain.

Das Erzeugen der AppDomain und Laden unserer Klasse übernimmt die ApplicationHost-Klasse aus dem Hosting-Namensraum (siehe Abbildung 1). Die folgende Zeile Code führt diese Aufgaben durch:

// ASP.NET initialisieren
private AspNet _aspnet;      
_aspnet = (AspNet)ApplicationHost.CreateApplicationHost(
  typeof(AspNet), 
  "/", 
  Environment.CurrentDirectory + "\\content");

Der zweite und dritte Parameter gibt den virtuellen (aus Sicht des Clients) bzw. physischen (aus Sicht des Servers) Pfad der ASP.NET AppDomain an.

Erzeugen der ASP.NET AppDomain
Abbildung 1 Erzeugen der ASP.NET AppDomain

Einige der Features, die wir in dem statischen Web Server hinzugefügt haben, sind bei ASP.NET nicht mehr notwendig, da diese von der HTTP Runtime implementiert werden, z.B.

  • Partial Trust: CAS-Berechtigungen für die ASP.NET AppDomain werden mit dem <trust /> Element in web.config konfiguriert.

  • Impersonierung: Client- und Anwendungs-Impersonierung werden mit dem <identity /> Element in web.config spezifiziert.

  • Nicht-Windows Authentifizierung: ASP.NET verfügt über eigene Mechanismen. Forms Authentication bietet ein HTML-basiertes UI für Logins; Custom Basic Authentication kann über ein HTTP-Modul realisiert werden.

  • Setzen von Thread.CurrentPrincipal: Diese Arbeit wird von den konfigurierten Authentifizierungs-Modulen in ASP.NET erledigt. Wir müssen lediglich die Client-Identität an die HTTP Runtime übermitteln (dazu später mehr).

Dies macht den Host um Einiges einfacher.

 

Kommunikation zwischen Host und ASP.NET

Um ein allgemeines Hosting von ASP.NET zu ermöglichen, wird die gesamte Kommunikation mit der HTTP Runtime über eine von HttpWorkerRequest abgeleiteten Klasse abgewickelt. Wenn Sie sich den System.Web.Hosting-Namensraum anschauen, werden Sie z.B. eine ISAPIWorkerRequest-Klasse finden, die genau diese Arbeit leistet, wenn ASP.NET über eine ISAPI Extension in IIS gehostet wird.

Die Aufgabe der Worker Request Klasse ist es, Informationen über die HTTP-Abfrage bereitzustellen (z.B. angeforderte Resource, Cookies, Header) sowie einen Ausgabe-Strom anzubieten, in den ASP.NET die gerenderte Seite schreiben kann (Abbildung 2 stellt dies schematisch dar).

Für einfache Aufgaben (und als Ausgangs-Punkt für eigene Klassen) kann man die SimpleWorkerRequest Klasse benutzen. Dies ist eine Minimal-Implementierung, die lediglich GET Requests unterstützt. Für POSTs, Authentifizierung und andere Features muss diese Klasse erweitert werden.

Kommunikation mit ASP.NET über einen Worker Request
Abbildung 2 Kommunikation mit ASP.NET über einen Worker Request

Im einfachsten Fall müssen lediglich die angeforderte Ressource, eventuelle Query String-Parameter und der Ausgabe-Strom vom HttpListenerContext extrahiert und an ASP.NET übergeben werden.

Folgender Code zeigt diese Logik:

// HttpListener request verarbeitung in default AppDomain
private static void ProcessRequest(HttpListenerContext ctx)
{
  // URL prefix entfernen
  string resourcename = ctx.Request.Url.LocalPath.Remove(
    0, "/MeineAnwendung/".Length);
            
  // informationen an ASP.NET AppDomain übergeben
  _aspnet.ProcessRequest(
    resourcename, 
    ctx.Request.Url.Query.Replace("?", ""), 
    ctx.Response.OutputStream);
}

// verbindungs-klasse in ASP.NET AppDomain
class AspNet : MarshalByRefObject
{
  internal void ProcessRequest(
    string page, string queryString, Stream output)
  {
    // StreamWriter für den ausgabe-strom
    StreamWriter writer = new StreamWriter(output);  

    // SimpleWorkerRequest mit relevanten informationen erzeugen
    SimpleWorkerRequest wr = new SimpleWorkerRequest(
      page, queryString, writer);

    // request an HTTP runtime übergeben
    HttpRuntime.ProcessRequest(wr);
 
    writer.Close();
  }
}

 

Ein Blick hinter die Kulissen

Die von CreateApplicationHost erzeugte AppDomain hat ein paar Eigenheiten. Wie man vermuten würde, wird die AppBase auf das angegebene physikalische Verzeichnis gesetzt (der dritte Parameter). Allerdings wird ebenfalls das Suchen nach Assemblies für die AppBase deaktiviert und dafür ein Probing Pfad für das /bin-Verzeichnis eingerichtet. Dies erklärt, warum alle Assemblies einer ASP.NET-Anwendung im /bin-Verzeichnis der Web Root abgelegt sein müssen.

Dies hat aber auch Auswirkungen auf unseren Host. Da die AspNet-Klasse in die ASP.NET AppDomain geladen werden muss, muss diese aber auch gefunden werden können. Das Verzeichnis, von dem aus der Host mal gestartet wurde, ist für die sekundäre AppDomain nicht mehr sichtbar. Dieses Problem kann man auf zwei Arten lösen:

  • Kopieren des Host Assemblies in das /bin-Verzeichnis der Web-Anwendung.

  • Registrieren des Hosts im GAC.

Die erste Alternative impliziert, dass Sie pro Web-Anwendung immer zwei Kopien des Hosts benötigen. Ganz abgesehen davon wird dadurch die Versionierung des Servers unnötig kompliziert.

Die zweite Variante ist die Empfohlene und löst dieses Problem auch ganz genauso wie Microsoft. Immerhin benötigen Sie bei einer im IIS gehosteten ASP.NET-Anwendung ja auch keine Kopie von System.Web.Dll in jedem /bin-Verzeichnis.

Mit diesem Ansatz lösen wir gleich noch ein zweites Problem. SimpleWorkerRequest erfordert Full Trust bei der Erzeugung. Haben Sie aber die ASP.NET AppDomain mit dem <trust /> Element in web.config für Partial Trust konfiguriert, hat dies eine SecurityException zur Folge. Durch Registrieren des Hosts im GAC erhält der Host implizit Full Trust und kann diese Initialisierungs-Tätigkeiten durchführen (auch genauso wie System.Web.Dll).

 

Erweitern von SimpleWorkerRequest

Um mehr Features als einen einfachen anonymen GET Request zu ermöglichen, muss die SimpleWorkerRequest-Klasse erweitert werden. Prinzipiell muss man die meisten Eigenschaften des HttpListenerRequests (von HttpListener) in eine HttpWorkerRequest- Klasse (von ASP.NET) kopieren. Dabei erschweren einem folgende Fakten etwas das Leben:

  • SimpleWorkerRequest kann nur in der ASP.NET AppDomain erzeugt werden.

  • HttpListenerContext kann nicht über AppDomain-Grenzen übertragen werden.

Aus diesem Grund muss man eine neue Klasse einführen, die als Daten-Container zwischen den beiden AppDomains fungiert. Der Container enthält Eigenschaften für die HTTP-Methode, Cookies, Header, den User Agent sowie die Ein- und Ausgabe-Ströme.

Dieser Container wird an die ASP.NET AppDomain übergeben und dort wiederum verwendet, um ein AspNetWorkerRequest (eine von SimpleWorkerRequest abgeleitete Klasse) zu erzeugen. Diese wird dann letztendlich an die HTTP Runtime übergeben.

Diese verändert den Code nur geringfügig:

// HttpListener request verarbeitung in default AppDomain
private static void ProcessRequest(HttpListenerContext ctx)
{
  // URL prefix entfernen
  string resourcename = ctx.Request.Url.LocalPath.Remove(
    0, "/MeineAnwendung/".Length);
            
  // container objekt erzeugen und füllen
  RequestInfo info = RequestInfo.PrepareRequestInfo(ctx);

  // informationen an ASP.NET AppDomain übergeben
  _aspnet.ProcessRequest(
    resourcename, 
    ctx.Request.Url.Query.Replace("?", ""), 
    info);

    info.ResponseWriter.Close();
}

class AspNet : MarshalByRefObject
{
  internal void ProcessRequest(
    string page, string queryString, RequestInfo info)
  {

    // worker request aus container erstellen
    AspNetWorkerRequest wr = new AspNetWorkerRequest(
      page, queryString, info);

    HttpRuntime.ProcessRequest(wr);
  }
}

AspNetWorkerRequest enthält einige Low-Level-Methoden, die implementiert werden mussten. Primär um den eingehenden Entity Body (für POST und PUT Requests) sowie Header und Cookies zu lesen. Diesen Code finden Sie im Download zu diesem Artikel. Im nachfolgenden möchte ich anhand von Authentifizierung die Erweiterbarkeit von HttpWorkerRequest demonstrieren.

 

Hinzufügen von Authentifizierung

Im letzten Teil habe ich bereits detailliert besprochen, wie man mit HttpListener-Authentifizierung durchführen kann. Da ASP.NET über seine eigenen Mechanismen für Nicht-Windows-Authentifizierung verfügt, muss der Host sich lediglich darum kümmern, dass er irgendeine Art von Windows Token an ASP.NET weitergeben kann. Dieser wird bei Integrated und Digest automatisch erzeugt, bei Basic ist ein Aufruf des Win32 LogonUser APIs notwendig.

Nach einer erfolgreichen Authentifizierung wird der Client Token wiederum in der Container-Klasse gespeichert.

// auf Basic authentifizierung prüfen
if (_config.AuthenticationScheme == AuthenticationSchemes.Basic)
{
  // credentials mit LogonUser überprüfen
  HttpListenerBasicIdentity credentials = 
    context.User as HttpListenerBasicIdentity;

  WindowsPrincipal p = LogonUserHelper.GetWindowsPrincipal(
    credentials.Name, credentials.Password, ".");
  if (p != null)
  {
    info.ClientIdentity = (WindowsIdentity)p.Identity;
  }
  else
  {
    Console.WriteLine("Basic Authentication fehlgeschlagen ({0})", 
      context.User.Identity.Name);
    WriteError(context, 401);
    return;
  }
}
else if (_config.AuthenticationScheme !=  
           AuthenticationSchemes.Anonymous &&
         _config.AuthenticationScheme != AuthenticationSchemes.None &&
         _config.AuthenticationScheme != AuthenticationSchemes.Basic)
{
  // bei integrated und digest, kann die WindowsIdentity direkt
  // vom HttpListenerContext bezogen werden
  info.ClientIdentity = context.User.Identity as WindowsIdentity;
}

Das ist aber nur die Hälfte der Arbeit. Die WindowsIdentity muss via den Container mit dem AspNetWorkerRequest assoziiert werden. Das ASP.NET WindowsAuthenticationModule ruft zwei Methoden in der Worker Request-Klasse auf, um seine eigene Kopie der WindowsIdentity zu erstellen, sowie HttpContext.User und Thread.CurrentPrincipal zu setzen. Diese müssen implementiert werden.

Die GetUserToken Methode muss einen Zeiger auf den Client Token zurückliefern und der Benutzername sowie die Authentifizierungs-Methode werden über die Server Variablen LOGON_USER und AUTH_TYPE erfragt. Im Folgenden sehen Sie beide Methoden:

public class AspNetWorkerRequest : SimpleWorkerRequest
{
  private RequestInfo _requestInfo;
    
  public AspNetWorkerRequest(
    String page, String query, RequestInfo info) 
    : base(page, query, info.ResponseWriter)
  {
    _requestInfo = info;
  }

  public override IntPtr GetUserToken()
  {
    return _requestInfo.ClientIdentity.Token;
  }

  public override string GetServerVariable(string name)
  {
    switch (name)
    {
      case "LOGON_USER":
        return _requestInfo.ClientIdentity.Name;
      case "AUTH_TYPE":
        return _requestInfo.ClientIdentity.AuthenticationType;
    }

    return base.GetServerVariable(name);
  }
}

Wenn Sie nun in einer ASP.NET-Seite den Inhalt von Context.User ausgeben, werden Sie sehen, dass die Client-Identität nun in ASP.NET bekannt ist und ab sofort ganz normale Bordmittel wie das <authorization /> Element benutzt werden können.

<%@ Page %>

<% = Context.User.Identity.Name %>

 

Zusammenfassung

HttpListener ermöglicht den einfachen Zugriff auf das HTTP-Protokoll. ASP.NET ermöglicht eine einfache Integration in Custom Hosts. Diese beiden Technologien zusammen erlauben es einen kompletten Web Server inkl. ASP.NET-Integration mit überschaubarem Code Aufwand zu realisieren. Neben dem generellen Design von Server-Anwendungen mit Sicherheits-Features zeigt dieser Artikel auch ein wenig dokumentiertes Feature der ASP.NET Runtime – das Hosting API. Kenntnis dieser beiden Technologien ermöglichen ein tieferes Verständnis der IIS/ASP.NET-Serverarchitektur.

 

Der Autor

Dominick Baier entwickelt ASP.NET- und Security-Kurse bei DevelopMentor, einer Entwickler-Schulungsfirma. Darüber hinaus unterstützt er Unternehmen beim Schreiben von sicheren verteilten Anwendungen. Als Referent kann man ihn auf diversen Konferenzen antreffen (BASTA, WinDev, DevWeek u.a.); Online findet man ihn auf seinem Blog unter www.leastprivilege.com.

Artikel-Serie „Der eigene kompakte und sichere Web Server mit .NET 2.0“ im Überblick:

Der eigene kompakte und sichere Web Server mit .NET 2.0 (Teil 1)

Der eigene kompakte und sichere Web Server mit .NET 2.0 (Teil 2): ASP.NET-Integration