Picture: Microsoft

Warum IP-Adressen kein eindeutiges Merkmal sind

Immer wieder sieht man in Code, der für den Login eines Benutzers verwendet wird, dass mit Hilfe der IP-Adresse oder zum Beispiel auch dem Browser-User-Agent versicht wird, die Session eindeutig zu kennzeichnen. Idee ist i.d.R., dass eine Login Session wirklich nur dem richtigen Benutzer zugeordnet wird und eben mit Hilfe dieser zusätzlichen Merkmalen ein Hijacken verhindert wird.

Das Problem der IP-Adresse

ist, dass IP-Adressen kein eindeutiges Merkmal für einen Benutzer sind.

IP-Adressen beziehen sich auf einen Internet-Anschluss; nicht auf den Benutzer. Gäste in Hotels, Mitarbeiter in Unternehmen oder oft auch Kunden von Internet-Providern teilen sich einen Anschluss und damit auch eine IP-Adresse.

Erste beiden Beispiele ist den meisten bewusst; doch dass man mit seinem Kabel-Internet-Anschluss sich seine IP teilt; das ist vielen nicht bewusst.

Dual-Stack Lite

Dual-Stack Lite (auch DS-Lite) ist eine Technologie, die prinzipiell auf dem IPv4 Protokoll basiert. Das Grundproblem, das damit gelöst werden soll, ist die Knappheit an vorhandenen IPv4-Adressen.

Bei DS-Lite ist der Internetanschluss am Kunden nicht direkt mit dem Internet verbunden, sondern erst mal mit dem privaten Netz des Kabel- bzw. Internet-Providers. Die Nutzer gehen dann von dem privaten Netz des Providers über einen zentralen Punkt in das Internet.

Das führt dazu, dass viele Kunden über einen gemeinsamen Punkt des Providers ins Internet gehen - und sich damit auch die IP-Adresse des jeweils gemeinsamen Punkts teilen.

In den USA ist dies schon lange eine verbreitete Technologie; besonders in enger bebauten Gebieten. In Deutschland setzten dies vor allem die Kabel-Betreiben ein, da diese nicht über so viele Internet-Adressen verfügen wie andere Provider - und damit einfach auch kosten sparen.

User Agent als Merkmal

Was man auch immer wieder lesen kann ist, dass statt der IP-Adresse - eben weil sie kein eindeutiges Merkmal ist - der User Agent des Browsers verwendet wird. Idee ist, dass die jeweilige Login-Session direkt an den Browser geknüpft ist.

Das ist leider jedoch nicht der Fall.
Das Problem des User Agent ist, dass dieser sich ändert, sobald der Browser sich aktualisiert - und das erfolgt bei modernen Browser vollautomatisch; der Benutzer merkt von einem Update nichts. Die Session jedoch, die an die Browser-Version geknüpft ist, ist ungültig, da sich aufgrund der Versionsänderung im User Agent dieser nun unterscheidet.

Die Alternative

Technologisch gibt es keine sichere, eindeutige Möglichkeit eine Session tatsächlich an eine spezifische Browser-Instanz auf einem spezifischen Gerät zu verknüpfen.
Theoretisch ist es immer möglich, dass man manuell die Informationen - wie Cookies - von einem PC auf einen anderen überträgt.

Die gängige Lösung ist, dass man einen Benutzer über einen neuen Login informiert und dem Benutzer die Möglichkeit gibt seine aktiven Login-Sessions zu verwalten und gegebenenfalls zu verwerfen.
Um eine Session verwerfen zu können, muss man auf der Server-seite ein eindeutiges Merkmal generieren, in der Datenbank wie auch im Cookie speichern und bei jedem Request validieren.

Grund dieses Aufwands ist, dass technologisch Authentifizierungscookies als "single source of identity" gelten; das heisst: ist ein mal ein valider Cookie ausgestellt, dann ist bleibt dieser valide - es gibt keine Möglichkeit im Standard, dass der Server dies ablehnt.
Daher ist es notwendig, dies selbst zu implementieren.

Implementierung in ASP.NET Core

In ASP.NET Core gibt es keine fertige Implementierung für diesen Fall; es muss selbst übernommen werden. Die Grundsteps dafür sind:

  • Anmeldeinformationen validieren
  • Login-Session auf Server-seite generieren und mit einer eindeutigen Id (meist Guid) in der Datenbank ablegen.
  • Id in den Cookie beim SignIn-Prozess des Benutzers legen
  • Bei jedem Request die Id aus dem Cookie legen und mit dem Eintrag in der Datenbank validieren. Ist der Eintrag ungültig, dann muss die Authentifizierung abgebrochen bzw. abgelehnt werden. Den Benutzer aktiv ausloggen, was bei ASP.NET Core dazu führt, dass der Cookie beim Benutzer überschrieben und damit ungültig gemacht wird.

Snippet

Wie bereits beschrieben muss ein Cookie durch eine serverseitig bekannte Id angereichert werden, um die Authentifizierung identifieren zu können. Da ASP.NET Core und die Identity prinzipiell auf ClaimsIdentity basiert, kann einfach eine Claim dazu verwendet werden, um die Id im Cookie abzulegen.

Wenn möglich versuche ich die ASP.NET Core Identity Funktionalitäten zu nutzen, sodass ich entsprechend den Login-Prozess einfach für diesen Zweck erweitern kann.


public class WebUserSignInManager : SignInManager<WebUser>
{
    public async Task<SignInResult> SignInUserAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure)
    {
        // Zeitpunkt festlegen, wann die Session erstellt wurde und wie lange diese andauern soll.
        // Hierfür wird prinzipiell die Cookie Expired-Information verwendet. Ebenso wird beim Request geprüft,
        //   ob die Session bezogen auf den Zeitpunkt noch gültig ist.
        DateTimeOffset createdLoginOn = DateTimeOffset.UtcNow;
        DateTimeOffset validTo = createdLoginOn.AddSeconds(_userAuthOptions.ExpireTimeSeconds);

        // Laden des Users über die Standardschnittstelle von UserManager<PortalUser>
        PortalUser user = await _userManager.FindByNameAsync(userName);

        // Wenn der User nicht existiert, dann schlägt der Login fehl.
        if (user is null) return SignInResult.Failed; 


        // CheckPasswordSignInAsync ist die Standardschnittstelle für das Prüfen der Credentials.
        // Es prüft dabei auch, ob der Login insgesamt überhaupt möglich ist, zB weil der Benutzer gesperrt ist.
        SignInResult attempt = await CheckPasswordSignInAsync(user, password, lockoutOnFailure);
        if (attempt.Succeeded)
        {
            // An dieser Stelle müsste nun 2FA erfolgen, sofern gewünscht.

            // Hier ermitteln wir den User Agent. Der User Agent wird ebenfalls in der Session in der Datenbank hinterlegt.
            // Der Benutzer bekommt die Browser-Bezeichnung in der Session Übersicht angezeigt, sodass er die verschiedenen Logins unterscheiden kann
            string browserAgent = _httpContextAccessor.HttpContext.Request.Headers["User-Agent"];

            // Beim Hinzufügen wird eine Id generiert, die nun im Claim hinterlegt wird.
            Guid loginId = await _eventDispatcher.Send(new UserAddLoginCommand(user.Id, user.UserName, createdLoginOn, validTo, browserAgent));

            // Leider ist das die einzige Stelle, die bei ASP.NET Core Identity für das Hinzufügen von eigenen Claims verwendet werden kann.

            var loginIdClaimName = "https://schemas.benjaminabt.de/identity/login/session/id"
            Claim[] customClaims = { new Claim(loginIdClaimName, loginId.ToString()) };

            // Der Benutzer wird nun eingeloggt und der Cookie erhält die Claims
            await SignInWithClaimsAsync(user, isPersistent, customClaims);

            return SignInResult.Success;
        }

        return attempt;
    }
}

Der der WebUserSignInManager eine Erweiterung des SignIn-Managers ist, kann dieser stattdessen in den Actions dazu verwendet werden, den Benutzer einzuloggen.

public LoginController(
    IWebUserSignInProvider signInProvider,
    )
{
    _signInProvider = signInProvider;

}


[HttpPost, Route("login")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginSubmitModel submitModel)
{
    if (!ModelState.IsValid)
    {
        return View(ViewsRoot + "Login.cshtml");
    }

    // Login
    SignInResult signinResult = await _signInProvider.LoginAsync(submitModel.Username, submitModel.Password, submitModel.RememberMe);

    if (signinResult.Succeeded)
    {
        string redirectTo = Url.IsLocalUrl(submitModel.ReturnUrl) ? submitModel.ReturnUrl : _router.ToPortal();
        return Redirect(redirectTo);
    }

Sessions sollten mindestens folgenden Kriterien als valide deklariert werden:

  • Die Id kann überhaupt einer Login-Session eines Benutzer zugeordnet werden
  • Die Login-Session ist nicht abgelaufen
  • Die Login-Session wurde nicht beendet

Dahingehend sieht meine Datenbank-Entität beispielsweise folgendermaßen aus:

    public class UserLoginEntity : IBaseEntity
    {
        public Guid Id { get; set; }

        /// Der Name ist Optional. Der Benutzer kann der Session ein eigenes Alias geben, sodass er die Session einfacher wiedererkennt
        public string Name { get; set; }

        // Der Zeitpunkt, an dem die Session erstellt wurde
        public DateTimeOffset CreatedOn { get; set; }

        // Der Zeitpunkt, an dem die Session ausläuft.
        // Es ist durchaus üblich, dass sich dieser Wert ändert, wenn zB der Benutzer besondere Rechte hat oder
        //   entsprechend bei Benutzeraktivität sich die Zeit verlängert
        public DateTimeOffset ExpiresOn { get; set; }

        // Zeitpunkt der letzten Aktivität des Benutzers.
        // Darüber kann der Benutzer auch in einer UI-Übersicht erkennen, wann welche Session aktiv war
        public DateTimeOffset LatestActivityOn { get; set; }
        public DateTimeOffset? KilledOn { get; set; }

        // Informationen zu Browser und Betriebssystem
        // Darüber kann der Benutzer auch in der UI-Übersicht die Session einfacher erkennen
        public string Browser { get; set; }
        public string BrowserVersion { get; set; }
        public string OperatingSystem { get; set; }
        public string OperatingSystemVersion { get; set; }
        public string DeviceType { get; set; }

        // Die Zuordnung zum Benutzer
        public virtual UserAccountEntity UserAccount { get; set; }
        public int UserAccountId { get; set; }

Damit wäre der Login fertig. Was noch fehlt ist, dass bei jedem Request überprüft werden muss, ob die Session noch valide ist. Hierfür bietet die ASP.NET Core Cookie Middleware entsprechend Events an.

// Cookie
services.ConfigureApplicationCookie(options =>
{
    // ... settings..
    options.Events = new UserLoginCookieValidationHandler();
});

Der Handler muss dabei selbst entwickelt werden.

public class UserLoginCookieValidationHandler : CookieAuthenticationEvents
{
    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
    {
        ClaimsPrincipal userPrincipal = context.Principal;

        // Der Provider ist ein Helfer um Werte aus der ClaimsPrincipal-Instanz zu lesen
        // In diesem Fall die Benutzer Id sowie entsprechend der Claim-Wert unserer Login Session
        var authorizationProvider = context.HttpContext.RequestServices.GetRequiredService<IUserAuthorizer>();
        if (!authorizationProvider.TryGetUserSessionInfo(userPrincipal, out int userId, out Guid sessionId))
        {
            // wenn diese Werte nicht vorhanden sind, wird Login abgelehnt
            context.RejectPrincipal();
        }
        else
        {
            // Wenn die Werte vorhanden sind, dann kann wird ermittelt, ob die Session noch gültig ist.
            // Im gleichen Atemzug wird der Zeitpunkt der letzten Aktivität gesetzt sowie die Ablaufzeit erhöht.
            IEventDispatcher eventDispatcher = context.HttpContext.RequestServices.GetRequiredService<IEventDispatcher>();
            bool valid = await eventDispatcher.Get(new GetUserSessionStatusQuery(userId, sessionId));
            if (!valid)
            {
                // Session ist nicht mehr valide, Login wird abgelehnt
                context.RejectPrincipal();
            }
        }
    }
}

Dieser Mechanismus ermöglicht es, dass nicht mehr der Cookie allein der "single point of identity" ist. Der führende Punkt ist nun die Session-Verwaltung, die den PrincipalContext ablehnen kann, wenn er unseren Vorgaben nicht entspricht.