ASP.NET MVC mit ActiveDirectory-Authentifizierung

Im Unternehmensumfeld wird oft nachgefragt, wie man User gegen das Active Directory authentizeren kann. Bei mir kam eine solche Anfrage auch gerade rein und ich möchte die Gelegenheit nutzen, hier meine Ergebnisse zum besten zu geben.

VS2012-Solution (7zip)

Anforderungen

Zunächst einmal scheint die Anforderung recht klar: Eigentlich sollen nur solche User sich mit der Website verbinden können, die im AD die entsprechenden Rechte besitzen. Jetzt könnte man mit den AD-Bordmitteln so einiges an den GPO und mit Zertifikaten zaubern. Das wäre zwar sehr elegant, ignoriert aber den Umstand, dass die entwickelnden Abteilungen zusammen mit den Fachbereichen oft allein steuern möchten, wer was darf. Dazu werden mittlerweile fast ausschließklich Gruppenmitgliedschaften heran gezogen. Man definiert also im fachlichen Modell, dass nur Mitglieder der AD-Gruppe „G1“ überhaupt Zugriff haben sollen und z.B. Mitglieder von „G2“ auf den Admin-Bereich dürfen.

Auch das automatische Einloggen auf der Seite ist meist unerwünscht, das datenschutzrechtliche Bedenken, das Loggen von Anmeldevorgängen usw. dagegen stehen. Vielmehr soll das klassiche Login-Fenster kommen, nur eben, dass die Prüfung der eingegebenen Daten im AD erfolgen soll.

Ich habe mir also ein ASP.NET MVC3-Template genommen und werde im Folgenden die Schritte skizzieren, um die Anforderungen umzusetzen.

Webcast

Für die Eiligen Zeitgenossen, wie inzwischen fast Usus bei codingfreaks, hier der Webcast zum Thema:

Los gehts

Zunächst einmal lege ich eine Projektmappe vom Typ „ASP.NET MVC3 Web Application an. Im MVC-Dialog gleich nach dem Fenster für das neue Projekt stelle ich folgendes ein:

Abb. 1: Einstellungen im MVC-Dialog

Nun geht es an die ersten Anpassungen des Standard-Templates. Zunächst einmal sollte klar sein, dass in unserem Fall keine Registrierung, kein Kennwortwechsel usw. möglich sein soll. All das wird ja zum Glück im AD getan. Entsprechend passe ich also die folgenden Dateien an:

  • AccountController: Bis auf die Methoden LogOn (2), LogOff und ErrorCodeToString alles entfernen.
  • AccountModel: Alle Klassen bis auf „LogOnModel“ entfernen
  • In Views/Account „Register“ entfernen
  • In Views/Account/LogOn den Link auf Register entfernen

Der Rest der Applikation kann im Standard belassen werden. In Wahrheit sind die oben aufgeführten Änderungen eigentlich nicht nötig, sollten aber meiner Meinung nach im Sinne von Datensparsamkeit vorgenommen werden.

An das AD hängen

Die Magie für die Authentifizierung ist glücklicherweise bereits von jemandem programmiert worden. Noch besser ist, dass dieser Jemand Microsoft war und dass das Ergebnis in der Klasse „System.Web.Security.ActiveDirectoryMembershipProvider“ manifestiert wurde. Das vereinfacht die Sache für uns allgemein.

Für diejenigen, die sich noch nicht mit Authentifizierung im Microsoft-Web-Umfeld interessiert haben, sei hier kurz vermerkt, dass MS hier ähnlich wie bei den Klassen in System.Data einen sog. Provider-Ansatz fährt. Das bedeutet letztlich, dass es ein Interface gibt, das eine Klasse implementieren muss. Eine Klasse, die dies tut darf zwar z.B. theoretisch auch MP3 abspielen können, das interessiert aber nicht weiter. Hauptsache, sie kann das, was ein Provider können muss. Im Falle der MembershipProvider ist dies kein Interface im technischen Sinne, sondern eine Basisklasse „MembershipProvider“. Alles, was von ihr erbt, ist ebenfalls ein MembershipProvider, nur eben ein spezieller.

Alles, was man nun tun muss, ist der ASP.NET-Anwendung (eigentlich dem IIS, aber egal) mitteilen, dass man diesen Provider nutzen will, wenn es nötig wird, einen User zu authentifizieren. Die notwendigen Schritte sind folgende:

Zunächst einmal sollte man das Projekt im SolutionExplorer anklicken und sich die Eigenschaften dazu ansehen (F4). Das ganze muss ungefähr so aussehen:

Abb. 2: Einstellungen für das Projekt

Man muss sich Anonym an der Seite anmelden können, d.h., dass der Browser zulässt, dass er den User nicht erkennt. Dazu muss man verhindern, dass ggf. die Windows-Authentifizierung dazwischenfunkt, was in Domänen durchaus nicht unwahrscheinlich ist. Das ist deshalb wichtig, damit wir den Vorgang wirklich komplett in den AD-Kanal hinein drücken und nichts anderes zulassen.

Als nächstes ist die web.config des Projektes entscheidend. Im MVC-Standard-Template ist hier bereits ein tag <membership> unterhalb von <system.web> eingerichtet und mit einem SQL-Server-basierten Standard-Provider versehen. Diesen kann man getrost raus löschen und durch folgendes ersetzen:

<membership defaultProvider="AdMembershipProvider">      
  <providers>
    <add name="AdMembershipProvider"
          type="System.Web.Security.ActiveDirectoryMembershipProvider, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
          connectionStringName="AD" 
          connectionUsername="{DOMAIN}\{USER}" 
          connectionPassword="{PASS}"
          attributeMapUsername="sAMAccountName" />        
  </providers>
</membership>

Wichtig ist, dass man zunächst in Zeile 1 den defaultProvider auf den gleichen Wert setzt, wie den Inhalt des name-Attributes in dem add-Tag. Die Zeilen 6 und 7 müssen nun noch an die eigenen Einstellungen angepasst werden. Statt „{DOMAIN}\{USER}“ bitte den Benutzernamen im angegebenen Format eingeben, der die Check-Abfragen im AD vornehmen soll. Hier eignet sich am besten ein Account, der keiner Person zugeordnet ist. Zum Testen kann man aber auch den eigenen Account benutzen. Das entsprechende Passwort ist in anstelle von „{PASS}“ einzutragen. Danach sollte man noch über eine Verschlüsselung der web.config nachdenken, aber das würde jetzt hier zu weit führen.

In Zeile 5 sieht man noch einen Verweis auf eine Verbindungszeichenfolge mit dem Namen „AD“. Die gibt es aber noch gar nicht, sodass wir in der MVC-web.config-Vorlage ganz nach unten scrollen und in folgendes stehen lassen:

<connectionStrings>
  <add name="AD" connectionString="LDAP://{SERVER.DOMAIN.SUFFIX}/CN=Users,DC={DOMAIN},DC={SUFFIX}"/>    
</connectionStrings>

Auch diese Einstellung muss an die eigene Infrastruktur angepasst werden. Wenn codingfreaks ein eigenes AD hätte und dessen Server hieße „main“, dann müsste unser connectionString so angepasst werden:

<connectionStrings>
  <add name="AD" connectionString="LDAP://{SERVER.DOMAIN.SUFFIX/CN=Users,DC=codingfreaks,DC=de"/>    
</connectionStrings>

Erste Tests

Für erste Tests des Logins ist man nun schon bereit. Einfach F5 drücken und oben rechts im MVC-Template auf „Logon“. Dann mit Username und Kennwort anmelden (Domäne vor dem Benutzer ist nicht erforderlich). Damit das mit dem Benutzernamen auch funktioniert, haben wir in Listing 1 und dort in Zeile 8 noch angegeben, dass der SAMAccountName zum Anmelden verwendet werden soll.

Einzelne Seiten absichern

Bei wem die ersten Tests bereits gelungen sind, der kann sich jetzt daran machen, die Seite abzudichten. Ich bin dabei ein Freund der Attribut-basierten Vorgehensweise. Im MVC-Pattern haben wir ja pro angezeigter Seite auf jeden Fall eine Methode in einem Controller. Wenn man nun z.B. verhindern möchte, dass nichtangemeldete Benutzer eine Seite sehen sollen, dann kann man sich eines sehr einfachen Mechanismus bedienen: ActionFilter.

Es gibt im MVC-Bereich bereits eine Basisklasse „ActionFilterAttribute“. Wie dessen Name anzeigt, kann man von ihr erben und somit ein eigenes Attribut erstellen, dass z.B. auf Methoden angewandt werden kann. Ich nenne es „RequiresAuthenticationAttribute“ und definiere es wie folgt:

using System;
using System.Web.Mvc;
using System.Web.Security;

/// <summary> 
/// Is used to mark an action so it needs authentication before it can take place.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class RequiresAuthenticationAttribute : ActionFilterAttribute
{
    #region methods

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        if (!filterContext.HttpContext.User.Identity.IsAuthenticated)
        {
            // currently there is no user authenticated inside this context
            var redirectOnSuccess = filterContext.HttpContext.Request.Url.AbsolutePath;
            //send them off to the login page
            var redirectUrl = string.Format("?ReturnUrl={0}", redirectOnSuccess);
            var loginUrl = FormsAuthentication.LoginUrl + redirectUrl;
            filterContext.HttpContext.Response.Redirect(loginUrl, true);
        }
    }

    #endregion
}

Der Trick ist das Überschreiben der Methode OnActionExecuting. Hier bekommt man immer einen Kontext für die Aktion mitgeliefert und kann diesen Nutzen, um herauszubekommen, ob der Kontext über einen authentifizierten Nutzer (egal, wie dieser authentifiziert wurde) besitzt. Wenn ja, wird die Anforderung durchgelassen, wenn nein, gehts zur Login-URL, die in der Web.config unter eingetragen ist.

Hat man das Attribut in seinem Projekt, kann man nun jede beliebige Controller-Methode mit diesem ausstatten:

[RequiresAuthentication]
public ActionResult Index()
{
    ViewBag.Message = "Welcome to ASP.NET MVC!";

    return View();
}

Es handelt sich also um ein sog. Flag-Attribut, das lediglich etwas anzeigt und daher ohne Parameter gesetzt werden kann.

Wer den Test jetzt wiederholen würde, würde gleich beim Start der Website auf das Login-Form umgeleitet werden, wenn es in der web.config vorhanden ist (ist Standard bei MVC-Vorlage):

Gruppen auslesen

Im Beispiel-Projekt habe ich den Index-View ein wenig erweitert:

@using AuthTest.Utils
@{
    ViewBag.Title = "Home Page";
}
<h2>@ViewBag.Message</h2>
<p>Ihre derzeitigen Domänengruppen sind:</p>
<ul>
    @foreach(var group in AuthUtil.GetUserGroupNames(User.Identity.Name))
    {
        <li>@group</li>
    }
</ul>

Hier wird eine Liste der AD-Gruppen ausgegeben, bei der der aktuell angemeldete Benutzer Mitglied ist. Dazu nutzt der View die Methode GetUserGroupNames einer AuthUtil-Klasse, die ich dem Sample-Projekt beigefügt habe. Die Methode kommt wie folgt daher:

/// <summary>
/// Retrieves a list of groups names the <paramref name="userName"/> is member of.
/// </summary>
/// <param name="userName">The name of the user.</param>
/// <returns>The list of groupnames.</returns>
public static List<string> GetUserGroupNames(string userName)
{
    var result = new List<string>();
    using (var context = GetContext())
    {
        try
        {
            using (var userPrinc = UserPrincipal.FindByIdentity(context, IdentityType.SamAccountName, userName))
            {
                foreach (var groupPrinc in userPrinc.GetGroups())
                {
                    try
                    {
                        result.Add(groupPrinc.Name);
                    }
                    finally
                    {
                        groupPrinc.Dispose();
                    }
                }
            }
        }
        catch (Exception ex)
        {
            // TODO: Handle Exception  
        }
                                
    }
    return result;
}

/// <summary>
/// Is used internally to create a context for AD queries.
/// </summary>
/// <returns>The instantiated context.</returns>
private static PrincipalContext GetContext()
{
    return new PrincipalContext(ContextType.Domain, ConfigurationManager.AppSettings["DomainAccessServer"], ConfigurationManager.AppSettings["DomainAccessUser"], ConfigurationManager.AppSettings["DomainAccessPassword"]);
}

Sie nutzt einen sog. PrincipalContext, um eine Abfrage auf dem AD durchzuführen. Dessen Einstellungen habe ich im Sample-Projekt ebenfalls in die web.config gepackt (). Auf diese Weise will ich aufzeigen, wie man anhand eines im AD authentifizierten Usernamens an dessen Gruppenmitgliedschaft heran kommt. Mit ein wenig mehr Code kann man nun einen User durchlassen, wenn er authentifiziert (richtiges Kennwort) UND Mitglied einer bestimmten Gruppe ist. Dies würde ich als Parameter in RequiresAuthenticationAttribute als Parameter einführen, damit man später etwas ähnliches wie:

[RequiresAuthentication("G1")]
public ActionResult Index()

schreiben kann, um sich anzumelden.

Resumé

Authentifizierung gegen das AD ist eigentlich nicht schwer, nur hat sich gezeigt, dass die Doku eher sparsam mit Beispielen ist. Meist hängt es nicht am Programmcode, sondern am richtigen Setzen der Einstellungen in web.config und IIS-Settings.

10 Antworten auf „ASP.NET MVC mit ActiveDirectory-Authentifizierung“

  1. Hallo Alexander,

    ich habe fast zwei Tage damit verbracht ein anständiges Tutorial für genau diese Aufgabe gesucht! Wie du bereits im Video erwähnt hast, sind die Beispiele und Tutorials im Netz einfach schlecht oder nicht ausführlich erklärt. Mit Hilfe des Templates bin ich jetzt mit meinem Projekt ein sehr großes Stück weiter gekommen!

    Vielen Dank! Macht weiter so!

  2. Hallo Alexander,

    ich habe dein Tutorial übernommen und alles funktioniert soweit. Nun habe ich versucht das ganze um die Gruppen zu erweitern,wenn ich allerdings die Funktion in der Index aufrufe um mir meine aktuellen Domaingruppen anzeigen zu lassen, bekomme ich foglende Fehlermeldung:
    Anmeldung fehlgeschlagen: unbekannter Benutzername oder falsches Kennwort.
    Dabei hat das Login funktioniert, nur nicht der Aufruf der Funktion um mir meine Gruppen anzeigen zu lassen :/

    ich hoffe du bist noch aktiv und kannst mir da eventuell weiterhelfen!

    1. Hi Alexander, das hat anscheinend nichts mit dem Login selbst, sondern mit den Einstellungen des AD-Controllers gemein. Es könnte sein, dass Du gar nicht mit ihm verbunden bist, sondern das dein IE und damit Dein lokales Windows die Auth vorgenommen haben. Check mal mit einem PortPing (pping von codingfreaks :-)), ob Du den Server erreichen kannst. Dann könnte es auch noch an den Rechten auf dem AD liegen (vielleicht darfst Du mit Deinem Account nicht browsen?).

  3. Hallo, vielen Dank für dieses hervorragende Anleitung. Aber ich habe Zweifel ich die Ausführung der Anwendung erzeugt den folgenden Fehler.

    Se produjo una excepción de tipo ‚System.DirectoryServices.AccountManagement.PrincipalServerDownException‘ en System.DirectoryServices.AccountManagement.dll pero no se controló en el código del usuario

    Información adicional: No fue posible ponerse en contacto con el servidor.

    Sie wissen, dass es sein kann passiert?

  4. Hi,
    ich habe es auch mal versucht nachzubauen.
    Das ganze läuft auf einer VM.
    Diese VM wurde für mich eingerichtet und befindet sich bereits in einer Domäne, welche ich mir in den Servereinstellungen ausgelesen habe.
    Wenn ich versuche mich einzuloggen, erscheint eine Parserfehlermeldung: „Es konnte keine sichere Verbindung mit dem Server hergestellt werden“
    Dabei wird folgende Zeile aus der .config markiert: „type=“System.Web.Security.ActiveDirectoryMembershipProvider, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a““
    Die Versionen habe ich überprüft und es sind die richtigen.
    Oder liegt es eher am ldap connection string?
    Danke schon mal für die Hilfe

  5. Hi,
    habe eine Lösung gefunden. Für alle die ein ähnloches Problem haben:
    Ich habe keine Änderungen an der Config vorgenommen.
    public ActionResult Login( LoginViewModel model, string returnUrl )
    {
    const string DOMAIN_NAME = „domain“;
    const string GROUP_NAME = „group“;
    //The user which is checking the credentials
    const string USER_NAME = „user“;
    const string PASSWORD = „pw“;

    if( !ModelState.IsValid )
    {
    return View( model );
    }

    PrincipalContext pc = new PrincipalContext(ContextType.Domain, DOMAIN_NAME,USER_NAME , PASSWORD);
    UserPrincipal user = UserPrincipal.FindByIdentity( pc, model.UserName );

    if( user != null && pc.ValidateCredentials( model.UserName, model.Password ) )
    {
    GroupPrincipal group = GroupPrincipal.FindByIdentity( pc, GROUP_NAME );
    if( group != null && user.GetGroups().ToList().Contains(group))
    {
    FormsAuthentication.SetAuthCookie( model.UserName, false );
    return RedirectToLocal( „Home/Index“ );
    }
    else
    ModelState.AddModelError( „“, „Sie haben nicht die benötigten Berechtigungen.“ );
    }
    else
    ModelState.AddModelError( „“, „Ungültiger Anmeldeversuch.“ );
    return View();
    }

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.