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.