AngularJS Sicherheit und Authentisierung

Authentisierung ist ein Thema, das so gut wie jede größere Webanwendung betrifft. Da HTTP ein zustandsloses Protokoll ist, und damit keine Sessions kennt, muss bei jeder Anfrage eine Authentisierungsinformation mitgeschickt werden. Damit sich ein Benutzer nur ein mal pro Session anmelden muss, soll diese Information erhalten bleiben und automatisch bei jedem Request mitgeschickt werden.

Konkret heißt das, der Benutzer meldet sich bei der Webanwendung an, und der Server ordnet dem authentifizierten Benutzer ein Sicherheitstoken zu (z.B. eine UUID), das auf Serverseite gespeichert wird. Das Token wird dem Client übermittelt, der sich dieses nun irgendwie “merken” muss, um es bei jeder weiteren Anfrage mitzuschicken. Der Server kann dann überprüfen, ob das Token gültig ist.

Webframeworks bieten hier zwei klassische Vorgehensweisen: Sticky Sessions oder Cookies.

Sticky Sessions werden auf der Serverseite gehalten und über URL-Parameter zugeordnet. Das führt meist zu Problemen bei der Skalierung, da die Entwickler gerne noch jede Menge andere Daten als nur das Token in der Session speichern. Zudem sind die daraus entstehenden URLs oft nicht “bookmarkable” und der Zurück-Knopf funktioniert nicht mehr korrekt.

Alternativ kann der Server ein Cookie ausstellen, das das Sicherheitstoken enthält, und der Browser schickt dieses Cookie dann automatisch bei jedem Request mit. Damit entfallen zwar die Probleme von Sticky Sessions, jedoch hat man hier plötzlich ein Sicherheitsproblem - wird das Cookie vom Server genutzt um Anfragen to erlauben, ist es möglich das Cookie zu “stehlen” und böswillige Anfragen auszuführen.

Wie ist das möglich? Angenommen ein Benutzer unserer Webanwendung landet beim Surfen auf einer Seite mit Bildern von süßen Hundwelpen. Doch die Macher der Seite haben hinterhältige Absichten und starten beim Laden der Seite durch ein unsichtbares Bild Requests auf unseren Webservice. Da der Browser die Cookies mit dem Sicherheitstoken mitschickt, gehen die Requests durch. Das nennt man auch Cross-Site Request Forgery (CSRF oder XSRF)1.

Single Page Web Applications to the rescue!

Single Page Applications (SPA), wie man sie vorzugsweise mit AnguarJS entwickelt, können diese Probleme komplett umgehen. Da unsere SPA die ganze Zeit im Speicher bleibt, können wir das vom Server erhaltene Token ohne Probleme bei jedem Request mitschicken. Dazu nutzen wir keine anfälligen Cookies, sondern einen eigenen HTTP-Header.

Damit sich der Benutzer bei einem Page-Reload (neuer Tab o.ä.) nicht neu anmelden muss, kann man das Token nach wie vor im Cookie oder Local Storage ablegen. Dieses Cookie wird zwar auch an den Server geschickt, der ignoriert es aber und so entsteht keine Sicherheitslücke.

Auch für diesen Ansatz gilt natürlich, dass man eine verschlüsselte Verbindung über HTTPS wählen sollte.

Hier der Workflow noch mal im Bild:

Security Workflow

Auth-Token in AngularJS

Das klingt doch schon mal recht vielversprechend, aber wie geht das nun in AngularJS? Müssen wir etwa bei jedem Request den Header mitschicken? Wäre doch super, wenn es da schon was gäbe. Gibt es natürlich!

Zunächst bietet $http die Möglichkeit, einen Header für jeden Request zu setzen. Der $httpProvider (das Modul, das den $http-Service injiziert) hat ein defaults.header-Objekt, das wiederum Unterobjekte für die üblichen HTTP-Verben bietet. Möchte man den Header an alle Requests hängen, gibt es dafür das Objekt common:

$http.post("/login", credentials).then(function(response) {
  $httpProvider.defaults.headers.common["X-AUTH-TOKEN"] = response.data.token;
});

Da es sich aber um eine recht übliche Anforderung handelt, bringt $http einen Auth-Token-Mechanismus von Haus aus mit. Dabei muss der Server einfach nur ein Cookie namens XSRF-TOKEN ausliefern. Angular liest das Cookie automatisch aus und setzt den Header X-XSRF-TOKEN. Wird das Cookie vom Server oder Client entfernt, setzt Angular den Header nicht mehr. Damit müssen wir uns um den Teil schon mal nicht mehr kümmern.

Eine kleine Anmerkung am Rande: die Dokumentation erwähnt, dass das Cookie nach dem ersten GET-Request gesetzt werden soll. Das ist nicht ganz korrekt, das Cookie wird nach jeder Art von Request erkannt. Das stammt daher, dass klassiche Request-Response-Anwendungen nach dem Login erst mal ein Session-Cookie ausstellen, und zusätzlich das XSRF-Cookie nutzen um Missbrauch zu verhindern. Wir beschränken uns hier auf ein einzelnes Cookie.

Auf abgelaufene Token reagieren

Solange das Token gültig ist, klappt alles wunderbar. Doch was passiert, wenn das Token abläuft (und das sollte es aus Sicherheitsgründen)?

$http bietet einige Convenice-Methoden um GET-, POST-, PUT-, DELETE- und HEAD-Requests auszuführen. Diese Methoden liefern nicht nur eine Promise zurück, sondern eine erweiterte Promise, die die Methoden success(callbackFn) und error(callbackFn) bietet und somit Methodchaining (wie bekannt aus jQuery) erlaubt. Außerdem wird die Response schon destrukturiert.

$http.get("/users/3")
.success(function(data, status, headers, response) {
  $scope.user = data;
})
.error(function(data, status) {
  if (status == 401)
    // Zur Login-Seite
  else
    // Fehlermeldung anzeigen
});

Nicht schlecht, aber natürlich wollen wir nicht in jedem Controller abfragen, ob der Server mit 401 geantwortet hat. Und auch hier bietet Angular wieder etwas, und zwar können wir beim $httpProvider einen so genannten HTTP-Interceptor anmelden. Ein Interceptor fängt jede Response ab und entscheidet, ob die Response an die aufrufende Funktion weitergeleitet wird oder nicht. Ein Interceptor ist dabei nichts anderes als eine Funktion, die eine Promise übermittelt bekommt. Status-Codes im 200er-Bereich werden dabei als erfolgreiche (resolved) Promise übergeben, alle anderen Codes sind nicht-erfolgreich (rejected). Auf Basis unserer eignenen Logik können wir darauf reagieren oder sogar die Promise ändern.

var interceptor = function() {
  // Die Promise enthält eine Response; wir müssen wieder eine Promise zurückliefern
  return function(promise) {
    return promise.then(
      function(response) { return response;}, // alles ok, dabei belassen wir es
      function(response) {
        if (response.status == 401) {
          // Zur Login-Seite
        }
        return $q.reject(response);
      }
    );
  };
};
$httpProvider.responseInterceptors.push(interceptor);

Man kann hier richtig kreativ werden und jede Menge Nettigkeiten einbauen, z.B. könnte man bei einem 401 direkt ein Login-Fenster anzeigen und danach den ursprünglichen Request erneut abschicken (dies wird mit Angular 1.22 und around-interceptors noch einfacher). Oder warum nicht einen 404-Request nach kurzem Timeout noch mal abschicken und die Daten nachladen?

Routing

Wenn man mit Routen arbeitet, bietet es sich an, schon vor dem Laden der Route abzufragen, ob der Nutzer autorisiert ist die angeforderte Seite anzuschauen. Beim Konfigurieren der Routen kann man dazu einen weiteren Parameter resolve übergeben. Dieser Parameter muss mit einem Objekt gefüllt werden, das pro selbst gewähltem Key eine Funktion anbietet, die beim Laden der Route aufgerufen wird (alles klar?). Gibt die Funktion eine Promise zurück, so entscheidet das Ergebnis (resolve oder reject) der Promise, ob die Route geladen wird. Über den Key lässt sich die aufgelöste Promise in den Controller injizieren, so dass wir beispielsweise (s. folgendes Snippet) den User in den Controller übergeben bekommen und die Seite nur geladen wird, wenn wir die Rechte haben, auf die Daten zuzugreifen.

$routeProvider.when("/users/:id", {templateUrl:'/user.html', controller:UserCtrl, resolve:{
  user: function($http, $routeParams) {
    return $http.get("/users/" + $routeParams.id); // $http.get liefert eine Promise zurück
  }
 }
})

Antwortet der Server nun mit 401 wird das Ereignis $routeChangeError gefeuert, auf das wir nun reagieren können. Da uns mit nextRoute die angeforderte Route übergeben wird, können wir sie uns merken und nach dem Login wieder ansteuern.

$scope.$on("$routeChangeError", function(event, nextRoute, currentRoute) {
  // Zur Login-Seite
  $rootScope.nextRoute = nextRoute; // oder in einem Service speichern
});

Datei-Uploads

Eine letzte Herausforderung ist das Hochladen von Dateien. XHR unterstützt das Hochladen von Dateien nicht, bzw. erst in Version 2 des Protokolls, das aber erst ab IE10 zur Verfügung steht. Ein klassischer Trick ist hier das Posten in ein iFrame (für Angular gibt es hier z.B. das recht einfach gehaltene Modul ngUpload3).

Wie können wir aber nun unseren Upload autorisieren? Einen Header können wir nicht mitschicken, also sollten wir unseren Webservice so erweitern, dass das Auth-Token auch in der URL oder als Form-Value mitgeschickt werden kann. Der URL-Ansatz empfiehlt sich hier, da der Service dann nicht erst den HTTP-Body parsen muss um zu entscheiden, ob der Request erlaubt ist oder nicht.

Zusammenfassung

Sicherheit und Benutzerverwaltung sind in AngularJS auch nicht komplizierter als in klassischen Webanwendungen und wie wir gesehen haben, sind doch einige nette Tricks möglich, ohne dass man einen absurd großen Programmieraufwand hätte. Und auch die guten alten HTTP-Statuscodes sind in modernen SPAs noch gut zu gebrauchen.

Schaut euch einfach das Beispielprojekt4 an und bei weiteren Fragen nehmt gerne Kontakt auf.

Diesen Post teilen

RSS-Feed

Neue Posts direkt im Newsreader.

RSS-Feed abonnieren

Newsletter

Neue Posts sowie Neuigkeiten rund um Big Data, Spark, und Scala. Maximal eine E-Mail im Monat.

Kommentare