Angular 2でのSPA実装(SPA + API)
本ドキュメントはSPA + APIアーキテクチャシナリオの一部で、Angular 2でSPAを実装する方法を説明します。実装したソリューションについての情報は、シナリオを参照してください。
Angular 2でのSPA実装で使用する全ソースコードは、こちらのGitHubリポジトリでご覧いただけます。
1.構成
アプリケーションには特定の構成情報が必要になります。残りの実装作業に進む前に、さまざまな構成値を入れるAuthConfig
インターフェイスを作成してください。このインターフェイスは、auth0-variables.ts
というファイルに入れます。
interface AuthConfig {
clientID: string;
domain: string;
callbackURL: string;
apiUrl: string;
}
export const AUTH_CONFIG: AuthConfig = {
clientID: '',
domain: '',
callbackURL: 'http://localhost:4200/callback',
apiUrl: ''
};
Was this helpful?
2.ユーザーの認可
認可サービスの作成
ユーザー認証に必要なタスクを管理・調整する最善の方法は、再利用可能なサービスを作成することです。これにより、アプリケーション全体でそのメソッドを呼び出せるようになります。auth0.jsのWebAuth
オブジェクトのインスタンスは、サービスで作成できます。
import { Injectable } from '@angular/core';
import { AUTH_CONFIG } from './auth0-variables';
import { Router } from '@angular/router';
import 'rxjs/add/operator/filter';
import auth0 from 'auth0-js';
@Injectable()
export class AuthService {
userProfile: any;
requestedScopes: string = 'openid profile read:timesheets create:timesheets';
auth0 = new auth0.WebAuth({
clientID: AUTH_CONFIG.clientID,
domain: AUTH_CONFIG.domain,
responseType: 'token id_token',
audience: AUTH_CONFIG.apiUrl,
redirectUri: AUTH_CONFIG.callbackURL,
scope: this.requestedScopes
});
constructor(public router: Router) {}
public login(): void {
this.auth0.authorize();
}
public handleAuthentication(): void {
this.auth0.parseHash((err, authResult) => {
if (authResult && authResult.accessToken && authResult.idToken) {
window.location.hash = '';
this.setSession(authResult);
this.router.navigate(['/home']);
} else if (err) {
this.router.navigate(['/home']);
console.log(err);
alert('Error: <%= "${err.error}" %>. Check the console for further details.');
}
});
}
private setSession(authResult): void {
// Set the time that the Access Token will expire at
const expiresAt = JSON.stringify((authResult.expiresIn * 1000) + new Date().getTime());
// If there is a value on the scope param from the authResult,
// use it to set scopes in the session for the user. Otherwise
// use the scopes as requested. If no scopes were requested,
// set it to nothing
const scopes = authResult.scope || this.requestedScopes || '';
localStorage.setItem('access_token', authResult.accessToken);
localStorage.setItem('id_token', authResult.idToken);
localStorage.setItem('expires_at', expiresAt);
localStorage.setItem('scopes', JSON.stringify(scopes));
}
public logout(): void {
// Remove tokens and expiry time from localStorage
localStorage.removeItem('access_token');
localStorage.removeItem('id_token');
localStorage.removeItem('expires_at');
localStorage.removeItem('scopes');
// Go back to the home route
this.router.navigate(['/']);
}
public isAuthenticated(): boolean {
// Check whether the current time is past the
// Access Token's expiry time
const expiresAt = JSON.parse(localStorage.getItem('expires_at'));
return new Date().getTime() < expiresAt;
}
public userHasScopes(scopes: Array<string>): boolean {
const grantedScopes = JSON.parse(localStorage.getItem('scopes')).split(' ');
return scopes.every(scope => grantedScopes.includes(scope));
}
}
Was this helpful?
このサービスには、認証を処理するためのメソッドがいくつかあります。
login:ユニバーサルログインを開始する
authorize
をauth0.jsから呼び出すhandleAuthentication:URLハッシュで認証結果を探し、auth0.jsの
parseHash
メソッドで処理するsetSession:ユーザーのアクセストークン、IDトークン、およびアクセストークンの有効期限を設定する
logout:ブラウザーストレージからユーザーのトークンを削除する isAuthenticated:アクセストークンの有効期限が切れたかどうかを確認する
認証結果の処理
ユーザーがユニバーサルログイン経由で認証し、アプリケーションにリダイレクトで戻されると、認証情報はURLのハッシュフラグメントに含まれます。AuthService
のhandleAuthentication
メソッドが、ハッシュの処理を行います。
アプリのルートコンポーネントでhandleAuthentication
を呼び出すことで、ユーザーがアプリにリダイレクトで戻された後、アプリを最初に読み込む際、認証のハッシュフラグメントを処理できるようにします。
// src/app/app.component.ts
import { Component } from '@angular/core';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor(public auth: AuthService) {
auth.handleAuthentication();
}
}
Was this helpful?
コールバックコンポーネントの追加
ユニバーサルログインを使用すると、ユーザーはアプリケーションからAuth0がホストするページに移動します。そして、正常に認証された後、クライアント側セッションがセットアップされた状態のアプリケーションに戻ります。
ユーザーを戻す場所はアプリケーション内の任意のURLに設定できますが、認証に成功したユーザーが戻る中心的な場所として専用のコールバックルートを作成することを推奨します。コールバックルートを単一にする利点は主に2つあります。
複数の(時として未知の)コールバックURLを許可リストに登録する必要がなくなる
アプリケーションがクライアント側セッションを設定する間、読み込み中のインジケーターを表示する場所になる
CallbackComponent
というコンポーネントを作成して、読み込み中インジケーターを自動入力します。
<!-- app/callback/callback.html -->
<div class="loading">
<img src="/docs/assets/loading.svg" alt="loading">
</div>
Was this helpful?
この例では、assets
ディレクトリで何らかの読み込み中スピナーを使えることが想定されています。デモはダウンロード可能なサンプルをご覧ください。
認証後、ユーザーは短時間だけ、読み込み中インジケーターが表示された/callback
ルートに移動します。この間にクライアント側セッションが設定され、完了したら/home
ルートにリダイレクトされます。
3.ユーザープロファイルの取得
トークンから情報を抽出する
このセクションでは、アクセストークンと/userinfoエンドポイントを使って、ユーザー情報を取得する方法について説明します。ライブラリーを使って、単にIDトークンをデコードすることもできます(必ず先に検証をしてください)。結果は同じです。他のユーザー情報が追加で必要な場合は、Management APIの使用を検討してください。
ユーザーのプロファイルを取得するには、既存のAuthService
クラスを更新します。ユーザーのアクセストークンをローカルストレージから抽出するgetProfile
関数を追加し、それをuserInfo
関数に渡してユーザー情報を取得します。
// Existing code from the AuthService class is omitted in this code sample for brevity
@Injectable()
export class AuthService {
public getProfile(cb): void {
const accessToken = localStorage.getItem('access_token');
if (!accessToken) {
throw new Error('Access Token must exist to fetch profile');
}
const self = this;
this.auth0.client.userInfo(accessToken, (err, profile) => {
if (profile) {
self.userProfile = profile;
}
cb(err, profile);
});
}
}
Was this helpful?
これで、ユーザーに関する情報を取得して表示したい任意のサービスからこの関数をすぐに呼び出せるようになります。
たとえば、新しいコンポーネントを作成して、ユーザーのプロファイル情報を表示することができます。
import { Component, OnInit } from '@angular/core';
import { AuthService } from './../auth/auth.service';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
styleUrls: ['./profile.component.css']
})
export class ProfileComponent implements OnInit {
profile: any;
constructor(public auth: AuthService) { }
ngOnInit() {
if (this.auth.userProfile) {
this.profile = this.auth.userProfile;
} else {
this.auth.getProfile((err, profile) => {
this.profile = profile;
});
}
}
}
Was this helpful?
このコンポーネントのテンプレートは以下のようなものになります。
<div class="panel panel-default profile-area">
<div class="panel-heading">
<h3>Profile</h3>
</div>
<div class="panel-body">
<img src="/docs/{{profile?.picture}}" class="avatar" alt="avatar">
<div>
<label><i class="glyphicon glyphicon-user"></i> Nickname</label>
<h3 class="nickname">{{ profile?.nickname }}</h3>
</div>
<pre class="full-profile">{{ profile | json }}</pre>
</div>
</div>
Was this helpful?
4.スコープに基づいた条件付きUI要素の表示
認可プロセスで、ユーザーに付与された実際のスコープをすでにローカルストレージに保存しています。authResult
で返されるscope
が空でない場合、ユーザーには最初に要求されたものと異なる一連のスコープが発行されたことを意味するので、authResult.scope
を使ってユーザーに付与されたスコープを判断する必要があります。
authResult
で返されるscope
が空の場合は、要求されたすべてのスコープがユーザーに付与されたことを意味するので、要求されたスコープを使用してユーザーに付与されたスコープを判断することができます。
この確認を行うために先ほど書いたsetSession
関数のコードがこちらです。
private setSession(authResult): void {
// Set the time that the Access Token will expire at
const expiresAt = JSON.stringify((authResult.expiresIn * 1000) + new Date().getTime());
// If there is a value on the `scope` param from the authResult,
// use it to set scopes in the session for the user. Otherwise
// use the scopes as requested. If no scopes were requested,
// set it to nothing
const scopes = authResult.scope || this.requestedScopes || '';
localStorage.setItem('access_token', authResult.accessToken);
localStorage.setItem('id_token', authResult.idToken);
localStorage.setItem('expires_at', expiresAt);
localStorage.setItem('scopes', JSON.stringify(scopes));
this.scheduleRenewal();
}
Was this helpful?
次に、ユーザーが特定のスコープを付与されているかどうかを判断するために呼び出すことができる関数をAuthService
クラスに追加する必要があります。
@Injectable()
export class AuthService {
// some code omitted for brevity
public userHasScopes(scopes: Array<string>): boolean {
const grantedScopes = JSON.parse(localStorage.getItem('scopes')).split(' ');
return scopes.every(scope => grantedScopes.includes(scope));
}
}
Was this helpful?
このメソッドは、特定のUI要素を表示すべきかどうかを判断するために呼び出すことができます。例として、approve:timesheets
スコープを持つユーザーにのみ[Approve Timesheets(タイムシートの承認)]リンクを表示したい場合を考えます。下のコードでは、リンクを表示すべきか否かを判断するためにuserHasScopes
関数の呼び出しを追加します。
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#">Timesheet System</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a routerLink="/">Home</a></li>
<li><a *ngIf="auth.isAuthenticated()" routerLink="/profile">My Profile</a></li>
<li><a *ngIf="auth.isAuthenticated()" routerLink="/timesheets">My Timesheets</a></li>
<li><a *ngIf="auth.isAuthenticated() && auth.userHasScopes(['approve:timesheets'])" routerLink="/approval">Approve Timesheets</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a *ngIf="!auth.isAuthenticated()" href="/docs/javascript:void(0)" (click)="auth.login()">Log In</a></li>
<li><a *ngIf="auth.isAuthenticated()" href="/docs/javascript:void(0)" (click)="auth.logout()">Log Out</a></li>
</ul>
</div>
</div>
</nav>
<main class="container">
<router-outlet></router-outlet>
</main>
Was this helpful?
ルートの保護
ユーザーに正しいスコープが付与されていない場合にユーザーがルートにナビゲートされないよう、ルートを保護する必要もあります。このために、新しいScopeGuardService
サービスクラスを追加することができます。
import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable()
export class ScopeGuardService implements CanActivate {
constructor(public auth: AuthService, public router: Router) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
const scopes = (route.data as any).expectedScopes;
if (!this.auth.isAuthenticated() || !this.auth.userHasScopes(scopes)) {
this.router.navigate(['']);
return false;
}
return true;
}
}
Was this helpful?
追加したら、ルートの構成時に使用して、ルートを有効にしてよいかどうかを判断します。以下のapproval
ルートの定義では、新しいScopeGuardService
が使用されています。
// app.routes.ts
import { Routes, CanActivate } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProfileComponent } from './profile/profile.component';
import { CallbackComponent } from './callback/callback.component';
import { AuthGuardService as AuthGuard } from './auth/auth-guard.service';
import { ScopeGuardService as ScopeGuard } from './auth/scope-guard.service';
import { TimesheetListComponent } from './timesheet-list/timesheet-list.component';
import { TimesheetAddComponent } from './timesheet-add/timesheet-add.component';
import { ApprovalComponent } from './approval/approval.component';
export const ROUTES: Routes = [
{ path: '', component: HomeComponent },
{ path: 'profile', component: ProfileComponent, canActivate: [AuthGuard] },
{ path: 'callback', component: CallbackComponent },
{ path: 'timesheets/add', component: TimesheetAddComponent, canActivate: [AuthGuard] },
{ path: 'timesheets', component: TimesheetListComponent, canActivate: [AuthGuard] },
{ path: 'approval', component: ApprovalComponent, canActivate: [ScopeGuard], data: { expectedScopes: ['approve:timesheets']} },
{ path: '**', redirectTo: '' }
];
Was this helpful?
5.APIの呼び出し
angular2-jwtモジュールは、APIに対する要求にJSON Web Tokenを自動的にアタッチするために使用できます。これは、AngularのHttp
クラスのラッパーであるAuthHttp
クラスを提供することで実現されます。
angular2-jwt
をインストールします。
# installation with npm
npm install --save angular2-jwt
# installation with yarn
yarn add angular2-jwt
Was this helpful?
angular2-jwt
の構成値を含むファクトリ関数を作成して、アプリケーションの@NgModule
でproviders
配列に追加します。ファクトリ関数には、ローカルストレージからaccess_token
を取得するtokenGetter
関数が必要です。
import { Http, RequestOptions } from '@angular/http';
import { AuthHttp, AuthConfig } from 'angular2-jwt';
export function authHttpServiceFactory(http: Http, options: RequestOptions) {
return new AuthHttp(new AuthConfig({
tokenGetter: (() => localStorage.getItem('access_token'))
}), http, options);
}
@NgModule({
declarations: [...],
imports: [...],
providers: [
AuthService,
{
provide: AuthHttp,
useFactory: authHttpServiceFactory,
deps: [Http, RequestOptions]
}
],
bootstrap: [...]
})
Was this helpful?
angular2-jwt
が構成されたら、AuthHttp
クラスを使用してアプリケーションの任意の場所からAPIを安全に呼び出すことができます。そのためには、AuthHttp
を必要な任意のコンポーネントまたはサービスに注入し、Angularの標準Http
クラスと同様に使用します。
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { AuthHttp } from 'angular2-jwt';
import 'rxjs/add/operator/map';
import { NewTimesheetModel } from '../models/new-timesheet-model';
@Injectable()
export class TimesheetsService {
constructor(public authHttp: AuthHttp) { }
addTimesheet(model: NewTimesheetModel) {
return this.authHttp.post('http://localhost:8080/timesheets', JSON.stringify(model));
}
getAllTimesheets() {
return this.authHttp.get('http://localhost:8080/timesheets')
.map(res => res.json())
}
}
Was this helpful?
6.アクセストークンの更新
ユーザーのアクセストークンの更新には、Angular SPAのアップデートが必要です。auth0.jsからcheckSession
メソッドを呼び出すメソッドをAuthService
に追加します。更新できたら、既存のsetSession
メソッドを使用してローカルストレージに新しいトークンを設定します。
public renewToken() {
this.auth0.checkSession({
audience: AUTH_CONFIG.apiUrl
}, (err, result) => {
if (!err) {
this.setSession(result);
}
});
}
Was this helpful?
AuthService
クラスにscheduleRenewal
というメソッドを追加して、認証をサイレント更新すべき時間をセットアップします。以下の例では、実際のトークンが期限切れになる30秒前に更新されるようにセットアップしています。また、Observableからサブスクリプションを解除するunscheduleRenewal
というメソッドも追加します。
public scheduleRenewal() {
if (!this.isAuthenticated()) return;
const expiresAt = JSON.parse(window.localStorage.getItem('expires_at'));
const source = Observable.of(expiresAt).flatMap(
expiresAt => {
const now = Date.now();
// Use the delay in a timer to
// run the refresh at the proper time
var refreshAt = expiresAt - (1000 * 30); // Refresh 30 seconds before expiry
return Observable.timer(Math.max(1, refreshAt - now));
});
// Once the delay time from above is
// reached, get a new JWT and schedule
// additional refreshes
this.refreshSubscription = source.subscribe(() => {
this.renewToken();
});
}
public unscheduleRenewal() {
if (!this.refreshSubscription) return;
this.refreshSubscription.unsubscribe();
}
Was this helpful?
最後に、スケジュールの更新を開始する必要があります。そのためには、ページの読み込み時に実行されるAppComponent
内のscheduleRenewal
を呼び出します。これは、ユーザーの明示的なログインかサイレント認証のいずれかの認証フロー後に毎回発生します。
リフレッシュトークンローテーション
ブラウザーにおけるユーザーのプライバシー管理についての最近の進歩は、サードパーティクッキーへのアクセスを防ぐことでユーザーエクスペリエンスに悪影響を与えています。Auth0では、「リフレッシュトークンのローテーション」の使用を推奨しています。リフレッシュトークンローテーションは、SPAでリフレッシュトークンを安全に使用するためのセキュリティ方式であり、リソースにアクセスするエンドユーザーに、ITPのようなブラウザーのプライバシー保護技術に煩わされないシームレスなユーザーエクスペリエンスを提供します。