はじめに
この記事は、Qrunchに投稿した記事1) 2)を、はてなブログ向けに再編集したものです。
アプリケーションのコンテンツをユーザに開示するかどうかを制御する仕組みの実装方法と、そのテストに関する手法を取り上げます。
認証と認可
よく混同される概念ですが、認証とは「その人が誰なのかを確認すること」、(狭義の)認可とは「リソースに対してアクセス権を与えること」です。
たとえばログイン処理のように、ユーザIDとパスワードを入力させて、『この人は鈴木一郎さんに違いない!』という判定を下すのが認証。
『この人は管理職ロールを持っているから、××機能に対するアクセスを許可しよう』とか、『キャンペーン期間中だけ特設ページへのアクセスを許可しよう』という仕掛けが認可です。
前提知識
- @angular/cli の基本的な使い方がわかること
- Observable (rxjs) に関して基礎的な知識があること
- Component や Service を自作できること
- Routingを利用して簡単な遷移を実装できること
開発環境
- Angular CLI: 7.1.4
- Node: 10.14.1
- OS: win32 x64
- Angular: 7.1.4
Guardについて
認可にはGuardを使おう
Angular には、「対象のページをユーザに見せてもよいか」を判定し、ついでに判定結果によってアクセスを制御するために Guard という仕組みが用意されています(Angular で構築したシステムは Single Page Application になりますが、本記事では便宜上、Router によってパスに紐づけられたコンポーネントをページと呼ぶことにします)。
※ Angular の Guard を用いると、この他にも多様な振る舞いを実現できますが、今回は話をシンプルにするために「ページに対するアクセス制御」という一点に的を絞ってお話しします。
最初に覚えておきたいポイントは以下4点です。
- 遷移先のページへのアクセス可否を制御できる
- Guard は遷移時だけでなく、URLを直叩きした際も有効になる
- ページごとに異なる Guard を設定できる
- 否認時には別のページに飛ばすことができる
では、さっそく Guard を作ってみましょう。
Guard の作り方
@angular/cli を利用している場合、ターミナルに以下のコマンドを入力するだけで Guard のボイラープレートが自動生成されます。
$ ng g guard [Guard名]
たとえば、$ ng g guard Auth
と入力すると、以下のファイルがプロジェクトに追加されます。
- <project root>
- node-modules
- src
- app
- auth.guard.ts | spec.ts
- app.component.ts | html | css | spec.ts
- app.module.ts
- app-routing.module.ts
- main.ts
- index.html
- ⋯
- app
- ⋯
また、ソースには予め以下の内容がコーディングされています。
src/app/auth.guard.ts
import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { return true; } }
CanActivate
インタフェースを実装している点以外は、何の変哲もない Service クラスであることが分かります。
少しごちゃごちゃしていますが、まず初めに抑えておくべきポイントは3つです。
- 認可判定の本体は
canActive
メソッドである canActive
はtrue
(認可)かfalse
(否認)のどちらかを返す(※ 後半に補足があります)false
の場合、ページへのアクセスは自動で遮断される
初期状態では、常に認可を与える(常にtrue
を返す)実装になっています。
Guard の組み込み方
作成した Guard を Route に設定してみましょう。 前提として、page1
というコンポーネントが既に存在し(下図)、さらに app-routing.module がセットアップされているものとします(下図)。
典型的な Angular アプリケーションの場合、Route はpath
とcomponent
の2つのプロパティを持ちますが、Guard を組み込む場合はさらに canActivate
というプロパティが必要です。
src/app/app-routing.module.ts
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { Page1Component } from './page1/page1.component'; import { AuthGuard } from './auth.guard'; const routes: Routes = [ { path: 'page1', component: Page1Component, canActivate: [AuthGuard] } ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
なお、canActivate
プロパティには複数の Guard を指定できます。 その場合は、全ての Guard が true
の場合のみ、当該ページへのアクセスが許可されます。
ここまでできたら、試しにcanActivate
メソッドの戻り値を false
に変更してhttp://localhost:4200/page1
にアクセスしてみましょう。 アクセスが遮断されるはずです。
src/app/auth.guard.ts
(抜粋)
export class AuthGuard implements CanActivate { canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { return false; // 変更 } }
他のページから遷移する場合だけでなく、URL を直叩きしたときにも Guard は有効になります。
canActive の引数
canActivate
メソッドには、next: ActivatedRouteSnapshot
とstate: RouterStateSnapshot
という2つのパラメータが引き渡されてきます。
ActivatedRouteSnapshot
は、遷移先の Route の状態を表すイミュータブルオブジェクトです。 URLのセグメント情報やクエリパラメータ、フラグメントパラメータを取得できます。RouterStateSnapshot
は、遷移先の Router の状態を表すイミュータブルオブジェクトです。 コンポーネントの配置や表示情報を表現する木構造であり、フレームワークが UI を構築するために内部的に利用する情報です。 正直、有効な使いどころをぼくは知りません。
以下は、クエリパラメータに基づいた認可処理を行うサンプルです。
src/app/auth.guard.ts
import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { constructor(private router: Router) { } canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { // クエリパラメータの id が '1' の場合のみ認可。 // それ以外は、エラーページに強制遷移。 const id = next.queryParamMap.get('id'); if (id !== '1') { this.router.navigate(['/error']); return false; } return true; } }
canActive の戻り値
先ほどは、話を簡単にするために「canActivate
は boolean
(true
かfalse
のいずれか)を返す」と書きました。
ですが実際には、canActivate
の戻り値は boolean
のほかに、 Observable<boolean>
型または Promise<boolean>
型のインスタンスを返すこともできます。
後二者は、非同期処理の解決を待ち合わせて遷移可否を判断する場合に用います(たとえばサーバと非同期通信した結果を利用したい場合などが一般的なユースケースでしょう)。
以下は、1,000ミリ秒後に true
となる Observable<boolean>
を返す Guard です。
src/app/auth.guard.ts
import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { Observable, of } from 'rxjs'; import { delay } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { return of(true).pipe(delay(1000)); } }
否認時に別のページに飛ばす
Guard が認可を与えない場合に、任意のパス(ログインページ等)へ強制的に飛ばすといったインタラクションも可能です。
src/app/auth.guard.ts
(抜粋)
export class AuthGuard implements CanActivate { constructor(private router: Router) { } // Router を DI canActivate( next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean { if ( /* 略 */ ) { this.router.navigate(['/login']); // ログインページに飛ばして return false; // falseを返す } return true; } }
適当なまとめ(なぜこんなものが必要か)
人によっては、「わざわざ Guard などといった新たな概念を利用しなくても、アクセス制御なんてComponent 内の初期化処理(constructor
や ngOnInit
等)で行えばよいじゃないか」と思われるかも知れません。
直感的な議論になりますが、ページ遷移が完了した後で認可処理が走るのはそもそも順番がおかしいです。 例えて言うなら「ノックもせずにドアを開けて、既に中に人がいたら諦める」ようなもので、いまいちお行儀が良くありません。 ムダな処理が実行されてユーザビリティも悪化します。
また、認可処理の振る舞いは、どちらかというと全体のポリシに関わる話であり、Component が個別に意識すべき関心事ではありません。 十歩譲って、必要な Component 全てに認可処理を埋め込む場合、「ngOnInit
に共通処理のおまじないコードを横展開する」という拷問が待ち受けています。開発者は泣きます。
せっかく便利な仕掛けがあるのだから、オレオレ実装などせず、ベストプラクティスに従って気持ちよくコードを書きたいですね。
単体テスト編
ここからは、怒涛の単体テスト編です。
単体テストについて
この記事における単体テストもしくはユニットテストとは、アプリケーションを構成するプログラムの最小単位ごとに実施するテストを指します。
念のために付け加えておくと、「ユーザの操作シナリオを手動で実行し、その結果が期待通りかどうかを目視で照合する行為」のことではありません。
Jasmine について
Angular では、標準で Jasmine というテスティングフレームワークを利用できます。
Jasmine については、Qiita にあるこちらの記事が詳しいです。
改めて先ほどの Guard を眺めてみると、Router
サービスが DI (依存性注入)され、さらに、内部ロジックは ActivatedRouteSnapshot
に依存した実装がなされている点が少々厄介です。
テストの対象はあくまで AuthGuard
クラスですので、テストコードを書く際には、それら無関係な外部サービスをことごとく切り離す(全てダミー実装のスタブに置き換える)必要があります。
Guard のテストコード
ここでは、以下 2 点を検証するコードを書いてみましょう。
ActivatedRouteSnapshot
のqueryParamMap
の内容に応じて認可判定を行っていること- クエリパラメータの
id
が1
の場合、canActivate
メソッドはtrue
を返すこと(認可) - クエリパラメータの
id
が1
以外(例えばnull
)の場合、canActivate
メソッドはfalse
を返すこと(否認)
- クエリパラメータの
- 否認の場合は
Router
のnavigate
に正しいパラメータ(/error
)を引き渡していること
src/app/auth.guard.spec.ts
import { TestBed, inject } from '@angular/core/testing'; import { AuthGuard } from './auth.guard'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; describe('AuthGuard', () => { // クエリパラメータのスタブ const queryParams = { id: null }; // ルータのスタブ const router = jasmine.createSpyObj('router', ['navigate']); beforeEach(() => { queryParams.id = null; TestBed.configureTestingModule({ providers: [ AuthGuard, { provide: ActivatedRouteSnapshot, useValue: { queryParamMap: { get: () => queryParams.id } } }, { provide: Router, useValue: router } ] }); }); it('クエリパラメータの id が 1 のとき、認可', inject([AuthGuard, ActivatedRouteSnapshot], (guard: AuthGuard, ars: ActivatedRouteSnapshot) => { queryParams.id = '1'; expect(guard.canActivate(ars, <RouterStateSnapshot>{})).toBe(true); })); it('クエリパラメータの id が null のとき、error に飛ばす', inject([AuthGuard, ActivatedRouteSnapshot], (guard: AuthGuard, ars: ActivatedRouteSnapshot) => { queryParams.id = null; expect(guard.canActivate(ars, <RouterStateSnapshot>{})).toBe(false); expect(router.navigate).toHaveBeenCalledWith(['/error']); })); });
ここまで書けたら、ターミナルに以下のコマンドを打ち込みます。
$ ng test
テストが実行され、どちらも SUCCESS になるはずです。
めでたし。