Authentifizierung In Angular 2

Mit OAuth2, OIDC, Dem Neuen Router Und Guards

Update im Januar 2017: Der Beitrag wurde für die finale Version von Angular 2.x aktualisiert und verwendet nun die Bibliothek angular-oauth2-oidc.

Der neue Router für Angular 2 bietet mit sogenannten Guards der SPA die Möglichkeit, das Routing zu beeinflussen. Dabei handelt es sich um Services, deren Methoden der Router beim Aktivieren bzw. Deaktivieren von Routen aufruft. Diese Methoden nennen sich sinngemäß canActivate und canDeactivate. Durch das Retournieren eines booleans können sie angeben, ob die jeweilige Aktion tatsächlich erlaubt ist. Zusätzlich können sie auch ein Observable<boolean> zurückliefern, um diese Entscheidung hinauszuzögern. Empfänge der Router über dieses Observable den Wert true, führt er die jeweilige Routingaktion aus. Empfängt er hingegen false, bricht er diese ab.

In meinem Beitrag hier habe ich anhand eines Beispiels die Nutzung von canDeactivate beschrieben. Es zeigt vor dem Verlassen einer Route eine Warnmeldung an und gibt dem Benutzer die Möglichkeit, seine Entscheidung zu revidieren.

Dieser Beitrag zeigt, wie eine Anwendung mit canActivate unberechtigte Benutzer von bestimmten Routen fernhalten kann. Dies dient weniger der Sicherheit, zumal Sicherheit bei Browser-basierten SPAs immer im Backend zu realisieren ist. Vielmehr dient dies der Benutzerfreundlichkeit, da hierdurch die Anwendung den Benutzer im Fall des Falls zur Anmeldung auffordern kann. Der gesamte Quellcode des hier präsentierten Beispiels findet sich hier. Es nutzt neben dem Guard-Konzept auch die Security-Standards OAuth 2 und OpenId Connect (OIDC), um die Authentifizierung und Autorisierung von der Anwendung zu entkoppeln und Single-Sign-On zu ermöglichen.

Vorbereitung

Zunächst benötigt man eine Bibliothek, die OAuth 2 und OIDC implementiert. Das hier betrachtete Beispiel nutzt meine Implementierung, welche via npm zur Verfügung steht:

npm install angular-oauth2-oidc --save

Danach ist das von dieser Bibliothek bereitgestellte OAuthModule ins Root-Module zu importieren:

import { OAuthModule } from 'angular-oauth2-oidc';

[...]

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    OAuthModule.forRoot()
    [...]
  ],
  [...]
})
export class AppModule {
}

Zum Konfigurieren der Bibliothek bietet sich der Konstruktor der Top-Level-Component an. Die nachfolgend eingerichteten Einstellungen verweisen auf einen OAuth2/OIDC-Auth-Server, den ich zu Testzwecken in der Cloud zur Verfügung stelle:

import {Component} from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';

@Component({
  selector: 'app', // <app></app>
  templateUrl: './app.component.html',
})
export class AppComponent {

  constructor(private oauthService: OAuthService) {

    // URL of the SPA to redirect the user to after login
    this.oauthService.redirectUri = window.location.origin + "/index.html";

    // The SPA's id. The SPA is registerd with this id at the auth-server
    this.oauthService.clientId = "spa-demo";

    // set the scope for the permissions the client should request
    // The first three are defined by OIDC. The 4th is a usecase-specific one
    this.oauthService.scope = "openid profile email voucher";

    // set to true, to receive also an id_token via OpenId Connect (OIDC) in addition to the
    // OAuth2-based access_token
    this.oauthService.oidc = true; // ID_Token

    // Use setStorage to use sessionStorage or another implementation of the TS-type Storage
    // instead of localStorage
    this.oauthService.setStorage(sessionStorage);

    // Discovery Document of your AuthServer as defined by OIDC
    let url = 'https://steyer-identity-server.azurewebsites.net/identity/.well-known/openid-configuration';

    // Load Discovery Document and then try to login the user
    this.oauthService.loadDiscoveryDocument(url).then(() => {

      // This method just tries to parse the token(s) within the url when
      // the auth-server redirects the user back to the web-app
      // It dosn't send the user the the login page
      this.oauthService.tryLogin({});

    });

  }

}

Der Aufruf der Methode tryLogin prüft, ob die Anwendung über das Hash-Fragment Security-Token empfangen hat. Sie parst diese Token und entnimmt Informationen über den Benutzer. Falls diese Informationen sicherheits-kritisch sind, muss die Anwendung das Token noch validieren. Dies ist vorallem bei hybriden und nativen Anwendungen, die diese Daten zum Zugriff auf lokale Ressourcen nutzen, der Fall. Das nachfolgende Beispiel nutzt dazu einen Callback, der sich zur Validierung an das Backend wendet:

this.oauthService.tryLogin({
    validationHandler: context => {
        var search = new URLSearchParams();
        search.set('token', context.idToken); 
        search.set('client_id', oauthService.clientId);
        return http.get(validationUrl, { search }).toPromise();
    }
});

Login

Um den Benutzer zur Login-Maske des Auth-Servers weiterzuleiten, ist lediglich die Methode initImplicitFlow des OAuthServices aufzurufen. Das nachfolgende Beispiel veranschaulicht dies mit der Methode login. Die Methode logout meldet den Benutzer ab. Dazu löscht sie alle Security-Token und falls die Anwendung dem Service eine Logout-Url mitgeteilt hat, leitet sie den Benutzer dorthin um:

import { Component } from '@angular/core';
import { OAuthService} from 'angular2-oauth2/oauth-service';

@Component({
    selector: 'home',
    template: require('./home.component.html')
})
export class HomeComponent {

    constructor(private oauthService: OAuthService) {
    }

    public login() {
        this.oauthService.initImplicitFlow();
    }

    public logout() {
        this.oauthService.logOut();
    }

    public get userName() {

        var claims = this.oauthService.getIdentityClaims();
        if (!claims) return null;

        return claims.given_name;
    }

}

Zusätzlich versucht der Getter userName den Vornamen des Benutzers zu ermitteln. Dazu greift er auf die Claims, welche die Bibliothek aus dem Security-Token entnommen hat, zu.

Das dazugehörige Template bindet sich an diese Eigenschaften und Methoden:

<h1 *ngIf="!userName">Welcome!</h1>
<h1 *ngIf="userName">Hello, {{userName}}!</h1>
<p>Welcome to this demo-application.</p>
<p>
    <button (click)="login()" class="btn btn-default">Login</button>
    <button (click)="logout()" class="btn btn-default">Logout</button>
</p>    
<p>
    Username/Passwort: max/geheim
</p>

Mit Guard unauthorisierte Benutzer fernhalten

Um anonyme oder nicht berichtige Benutzer von Routen fernzuhalten, kann sich die Anwendung auf Guards stützen. Das nachfolgende Beispiel zeigt eine entsprechende Implementierung. Es handelt sich dabei um einen Angular-2-Service, der CanActivate implementiert und sich den OAuthService injizieren lässt. Die vom Interface vorgegebene Methode canActivate prüft, ob die nötigen Security-Token vorliegen. Dabei handelt es sich um das von OAuth 2 definierte Access-Token sowie um das von OIDC ergänzte Id-Token. Wenn beide vorliegen und beide noch nicht abgelaufen sind, liefert sie true. Damit signalisiert sie dem Router, dass er die gewünschte Aktion ausführen darf. Ansonsten liefert sie false und bricht damit die Routing-Aktion ab:

import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { OAuthService } from 'angular-oauth2-oidc';
import { Injectable } from '@angular/core';

@Injectable()
export class FlightBookingGuard implements CanActivate {

    constructor(private oauthService: OAuthService) {
    }

    canActivate(
        route: ActivatedRouteSnapshot, 
        state: RouterStateSnapshot) {

            var hasIdToken = this.oauthService.hasValidIdToken();
            var hasAccessToken = this.oauthService.hasValidAccessToken();

            return (hasIdToken && hasAccessToken);
    }
}

Die von canActivate entgegengenommenen Parameter informieren über die aktuelle Route sowie über die angestrebte Routing-Aktion.

Der Guard ist dann noch in der Routing-Konfiguration in der Eigenschaft canActivate der jeweiligen Routen zu hinterlegen. Dabei handelt es sich zunächst lediglich um ein Token, welches später über einen Provider an einen Service zu binden ist:

import { RouterConfig, provideRouter } from '@angular/router';
import { HomeComponent} from './home/home.component';
import { FlightSearchComponent} from './flight-search/flight-search.component';
import { PassengerSearchComponent} from './passenger-search/passenger-search.component';
import { FlightEditComponent} from './flight-edit/flight-edit.component';
import { FlightBookingComponent} from './flight-booking/flight-booking.component';
import { FlightBookingGuard} from './flight-booking/flight-booking.guard';
import { FlightEditGuard} from './flight-edit/flight-edit.guard';
import { InfoComponent} from './info/info.component';
import { DashboardComponent} from './dashboard/dashboard.component';

const APP_ROUTES: RouterConfig = [
    {
        path: '/home',
        component: HomeComponent,
        index: true
    },
    {
        path: '/info',
        component: InfoComponent,
        outlet: 'aux'

    },
     {
        path: '/dashboard',
        component: DashboardComponent,
        outlet: 'aux'
    },    
    {
        path: '/flight-booking',
        component: FlightBookingComponent,
        canActivate: [FlightBookingGuard],
        children: [
            {
                path: '/flight-search',
                component: FlightSearchComponent
            },
            {
                path: '/passenger-search',
                component: PassengerSearchComponent
            },
            {
                path: '/flight-edit/:id',
                component: FlightEditComponent
            }
        ]
    }
];

Da in diesem Fall das Token gleichzetig auch der zu nutzende Service ist, ist dieser lediglich in die Provider-Konfiguration aufzunehmen.

import { OAuthModule } from 'angular-oauth2-oidc';

[...]

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    OAuthModule.forRoot()
    [...]
  ],
  providers: [
    FlightBookingGuard
  ]
  [...]
})
export class AppModule {
}

Web API aufrufen

Beim Aufrufen von Web APIs ist das erhaltene Access-Token zu übergeben. Dieses stellt die Methode getAccessToken des OAuthServices zur Verfügung. Dieses Token ist über den Header Accept zu übergeben:

public find(from: string, to: string) {
    var url = this.baseUrl + "/api/flight";

    var search = new URLSearchParams();
    search.set('from', from);
    search.set('to', to);

    var headers = new Headers();
    headers.set('Accept', 'text/json');
    headers.set('Authorization', 'Bearer ' + this.oauthService.getAccessToken())

    return new Observable((observer: Observer<Flight[]>) => {
        this.http
            .get(url, { search, headers })
            .map(resp => resp.json())
            .subscribe((flights) => {
                this.flights = flights;
                observer.next(flights);
            });
    });
}

Der Wert Bearer gibt an, dass es sich beim übersendeten Wert um ein Bearer-Token handelt. Das sind Tokens, die seinem Überbringer - hier der SPA - die damit einhergehenden Rechte einräumen.