WCF Web APIs / WCF Http mit ASP.NET Forms Authentication verwenden

Written on March 02, 2011

Seit einiger Zeit arbeitet Microsoft daran, WCF z.B. für die Verwendung in RESTful Szenarien zu vereinfachen.

Die neuen WCF Http APIs ermöglichen ein einfacheres Hosting der WCF-Services in (bestehenden) ASP.NET (MVC) Websites ohne den von WCF bisher bekannten Konfigurationsaufwand.

In den aktuellen Builds ist allerdings die Unterstützung für Authentifzierung und Authorisierung noch nicht wirklich vorhanden.

Geplant ist z.B. die Unterstützung von OAuth.

Allerdings habe ich mir die Frage gestellt, warum man nicht Bewährtes, wie z.B. die ASP.NET Forms Authentication wiederverwendet.

Nach einigen Versuchen bin ich für mich zu einer brauchbaren Lösung gekommen, die ich hier vorstellen möchte.

Requirements

Folgende Anforderungen habe ich an die Authentifizierung gestellt:

  • Forms Authentifizierung sowohl für die ASP.NET MVC 3 Website als auch für die darin gehosteten WCF Http Services
  • Die RESTful Authentifizierung soll keine Login-Formulare ausfüllen, sondern mit den Forms Authentication Credentials gegen einen WCF Http Authentication Service funktionieren
  • Im Browser sollen die RESTful Services XML liefern
  • An den Console Test Client sollen die RESTful Services JSON liefern

Umsetzung -- Host

Die vorgestellte Lösung basiert auf den WCF Web APIs Preview 3.

Zunächst wird eine leere ASP.NET MVC 3 Website erstellt.

XML liefern die WCF Http APIs automatisch zurück.

Um JSON ausgeben zu können, wird ein sog. JsonProcessor benötigt, der bereits in den APIs enthalten ist.

Um Formulareingaben verarbeiten zu können, muss ein FormUrlEncodedProcessor verwendet werden, der ebenfalls bereits vorhanden ist.

Damit ergibt sich folgende Konfiguration für unsere Services:

public class ContactManagerConfiguration : HttpHostConfiguration, IProcessorProvider {
    private readonly CompositionContainer _container;

    public ContactManagerConfiguration(CompositionContainer container) {
        _container = container;
    }

    public void RegisterRequestProcessorsForOperation(HttpOperationDescription operation, IList<Processor> processors, MediaTypeProcessorMode mode) {
        processors.Add(new JsonProcessor(operation,mode));
        processors.Add(new FormUrlEncodedProcessor(operation,mode));
    }

    public void RegisterResponseProcessorsForOperation(HttpOperationDescription operation, IList<Processor> processors, MediaTypeProcessorMode mode) {
        processors.Add(new JsonProcessor(operation,mode));
    }

    public object GetInstance(Type serviceType, InstanceContext instanceContext, Message message) {
        var contract = AttributedModelServices.GetContractName(serviceType);
        var identity = AttributedModelServices.GetTypeIdentity(serviceType);
        var definition = new ContractBasedImportDefinition(contract, identity, null, ImportCardinality.ExactlyOne, false,
                                                            false, CreationPolicy.NonShared);
        return _container.GetExports(definition).First().Value;
    }
}

Wir erstellen zwei Services:

  • Contact Service, in der Klasse ContactResource

  • Login Service, in der Klasse LoginResource

Die Implementierungen werden weiter unten vorgestellt.

Die Registrierung der Services erfolgt in der Global.asax.cs

Für die Registrierung von sog. ServiceRoutes wurde eine Extension Method AddServiceRoute() eingeführt.

Die Registrierung sieht dann wie folgt aus:

var catalog = new AssemblyCatalog(typeof(Global).Assembly);
var container = new CompositionContainer(catalog);
var configuration = new ContactManagerConfiguration(container);
RouteTable.Routes.AddServiceRoute<ContactResource>("contact", configuration);
RouteTable.Routes.AddServiceRoute<LoginResource>("login", configuration);

Damit die ServiceRouten von WCF HTTP und die normalen MVC Routen parallel funktionieren, müssen für die MVC-Routen die WCF HTTP Routen ausgefiltert werden, was mittels einer IRouteConstraint funktioniert:

public class WcfRoutesConstraint : IRouteConstraint {
    public WcfRoutesConstraint(params string[] values) {
        this._values = values;
    }

    private string[] _values;

    public bool Match(HttpContextBase httpContext,
    Route route,
    string parameterName,
    RouteValueDictionary values,
    RouteDirection routeDirection) {
        // Get the value called "parameterName" from the
        // RouteValueDictionary called "value"
        string value = values[parameterName].ToString();

        // Return true is the list of allowed values contains
        // this value.
        bool match = !_values.Contains(value);
        return match;
    }
}

Die WcfRouteConstraint wird in der MVC MapRoute-Definintion übergeben:

routes.MapRoute(
    "Default", // Route name
    "{controller}/{action}/{id}", // URL with parameters
    new { controller = "Home", action = "Index", id = UrlParameter.Optional }, // Parameter defaults
    new { controller = new WcfRoutesConstraint(new string[] {"contact","login"}) }
);

Die ContactResource sieht wie folgt aus - der Einfachheit wegen ohne Datenbankzugriff o.ä.:

[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
[ServiceContract]
[Export]
public class ContactResource {
    [ImportingConstructor]
    public ContactResource() {

    }

    [WebGet(UriTemplate="{id}")]
    public ContactDto Get(string id, HttpResponseMessage responseMessage) {
        var contact = new ContactDto
                        {
                              Name = "Alexander Zeitler"
                        };
        return contact;
    }
}

Die LoginResource sieht wie folgt aus:

[ServiceContract]
[Export]
public class LoginResource {
    [ImportingConstructor]
    public LoginResource() {

    }

    [WebInvoke(UriTemplate="", Method = "POST")]
    public void Login(Credentials credentials, HttpResponseMessage responseMessage) {
        bool auth = Membership.ValidateUser(credentials.Username, credentials.Password);

        if (auth) {
            FormsAuthentication.SetAuthCookie(credentials.Username,true);
        }
        else {
            responseMessage.StatusCode = HttpStatusCode.Unauthorized;
        }
    }
}

Die Funktion ist schnell erklärt: Über einen selbst implementierten Credentials Parameter mit den Properties Username und Password kommen die Login-Daten des Users per POST-Methode in den Service.

Diese werden gegen die ASP.NET Membership Datenbank authentifiziert und im Erfolgsfall wird das ASP.NET FormsAuthentication Cookie zurückgeliefert.

Im Fehlerfall wird ein HTTP Error 401 Unauthorized zurückgegeben.

Damit die ASP.NET FormsAuthentication funktioniert, sind in der web.config einige Einstellungen vorzunehmen.

Zunächst müssen die zu schützenden URLs für die nicht authentifizierten User gesperrt werden.

Der Login-Service muss allen Usern zugänglich sein:

<location path="">
    <system.web>
        <authorization>
            <allow roles="Admins"/>
            <deny users="*"/>
        </authorization>
    </system.web>
</location>
<location path="login">
    <system.web>
        <authorization>
            <allow users="*"/>
        </authorization>
    </system.web>
</location>

Die FormsAuthentication wird wie folgt konfiguriert:

<authentication mode="Forms">
    <forms loginUrl="~/Account/LogOn" timeout="2880" name=".ASPXFORMSAUTH" />
</authentication>

Der Account-Controller sieht wie folgt aus:

public class AccountController : Controller
{
    [HttpGet]
    public ActionResult Logon() {
        Response.StatusCode = (int)HttpStatusCode.Unauthorized;
        return View();
    }



    [HttpPost]
    public ActionResult Logon(string username, string password) {
        if(Membership.ValidateUser(username, password)) {
            FormsAuthentication.SetAuthCookie(username,true);
        }
        return View();
    }

    public ActionResult LogOff() {
        FormsAuthentication.SignOut();
        return RedirectToAction("Logon", "Account");
    }
}

Die parameterlose Action "Logon" dient dazu, nichtauthentifizierte Aufrufe der RESTful Clients zu blocken.

Die Post-Variante dient für die normale Formular-Authentifizierung im Browser.

Die LogOff Action ist selbsterklärend...

Die zugehörige Logon-View besteht aus einem Formular mit den beiden Eingabefeldern Username und Password soweit einem Button und einem Link auf die Abmelde-Action.

Umsetzung Client

Der Client besteht aus zwei Funktionen:

  • Authentifzierung

  • Kontaktdaten lesen

Die Authentifzierung erfolgt mittels HttpWebRequest:

HttpWebRequest loginRequest = (HttpWebRequest)HttpWebRequest.Create("http://localhost:44857/login");
loginRequest.Method = "POST";


CookieContainer cookieContainer = new CookieContainer();
loginRequest.CookieContainer = cookieContainer;
loginRequest.ContentType = "application/x-www-form-urlencoded";
ASCIIEncoding encoding = new ASCIIEncoding();
string postData = "Username=foo&Password=bar";
byte[] data = encoding.GetBytes(postData);

loginRequest.ContentLength = postData.Length;
Stream dataStream = loginRequest.GetRequestStream();
dataStream.Write(data, 0, data.Length);
dataStream.Close();

loginRequest.GetResponse();

Wichtig ist das Setzen des ContentTypes sowie die Verwendung des CookieContainers.

Der CookieContainer speichert nach dem Aufruf von loginRequest.GetResponse() die vom Server erhaltenen Cookies.

Nach erfolgreicher Authentifizierung können die Daten vom Kontakt-Service geladen werden.

Damit die Formular-Authentifizerung die Requests an den geschützten Kontakt-Service weiterreicht, muss das oben erhaltene Cookie mit übergeben werden, was man durch die Wiederverwendung des CookieContainers erreicht:

HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create("http://localhost:44857/contact/1");
request.CookieContainer = cookieContainer;
request.Accept = "application/json";
request.Method = "GET";
try {
    HttpWebResponse response = (HttpWebResponse)request.GetResponse();
    Stream responseStream = response.GetResponseStream();
    StreamReader reader = new StreamReader(responseStream, Encoding.UTF8);
    string result = reader.ReadToEnd();
    JavaScriptSerializer jsonDeserializer = new JavaScriptSerializer();
    ContactDto contact = jsonDeserializer.Deserialize<ContactDto>(result);
    Console.WriteLine(contact.Name);
    Console.ReadLine();
}
catch (WebException e) {
    Console.WriteLine(((HttpWebResponse)e.Response).StatusCode);
    Console.ReadLine();
}

Der Aufruf im Browser liefert nach erfolgter Authentifizierung das gewünschte XML zurück:

XML

Der Client liefert das deserialisierte JSON zurück:

JSON

Bei erfolgloser oder fehlender Authentifizierung wird der entsprechende StatusCode zurückgeliefert:

Unauthorized

Bitte beachtet, dass ich ein Update gepostet habe, das den neuen HttpClient verwendet.

Damit sind die zu Beginn gestellten Forderungen implementiert.

Ebenso wie die in einem frühen Stadium vorliegenden WCF HTTP APIs erhebt diese Lösung keinen Anspruch auf vollständige Funktion und soll auch als Diskussionsgrundlage dienen.

Die Beispiel-Implementierung kann hier heruntergeladen werden: WcfHttpMvcAuth.zip (9.45 mb)

DotNetKicks-DE Image