Angular に認証の仕組みを追加する

2022/03/21に公開

Angular に認証の仕組みを追加する

以下で作成した認証 API を利用して Angular に認証の仕組みを追加していきます。

https://zenn.dev/mikakane/articles/spaauth_2_nestjs

サンプルアプリケーションの挙動は以下で確認できます。

https://cryptic-forest-11002.herokuapp.com/cookie/login

今回作成するのは Cookie ベースの認証を利用した SPA アプリケーションです。

認証の実装ステップ

Angular などの SPA で認証を実装する場合、通常以下のステップで作業を進めていきます。

  • 各種画面の作成(signup,mypage,login)
  • signup 画面でログイン API のコールを行い、Cookie データを取得する。
  • mypage などの認証画面に Guard を適用する
    • Guard の中で Cookie の検証を行う
    • Guard の中で ユーザデータを取得し、グローバルストアに格納する
  • logout の実装を追加する。
    • Guard の中で Cookie が不正だった場合の強制リダイレクトを実装する

実際のコードは以下の リポジトリからも確認できます。

https://github.com/chatbox-inc/ng-nestjs-auth0/tree/master/packages/client

実装のポイント

各種画面の作成

画面単位でコンポーネントを作成して、ルートの定義に加えていきます。

signup 画面での API コール

API コールは httpClient モジュールを利用して行います。
サンプルコードでは、リアクティブフォームを利用しているため、
this.form.invalid でフォームのバリデーション確認を行ってから、API の送信を行っています。

import { Component, OnInit } from '@angular/core';
import {FormControl, FormGroup, Validators} from "@angular/forms";
import {HttpClient} from "@angular/common/http";
import {Router, RouterModule} from "@angular/router";

@Component({
  selector: 'app-signup-page',
  templateUrl: './signup-page.component.html',
  styleUrls: ['./signup-page.component.scss']
})
export class SignupPageComponent implements OnInit {

  form = new FormGroup({
    email: new FormControl("",Validators.required),
    lastName: new FormControl("",Validators.required),
    firstName: new FormControl("",Validators.required),
    password: new FormControl("",Validators.required),
  })

  constructor(
    private http: HttpClient,
    private router: Router
  ) { }

  submit(){
    if(this.form.invalid){
      alert("すべてのフォームを埋めてください。")
      return
    }
    this.http.post("/api/signup",{
      ...this.form.value
    }).subscribe({
      next:(r)=>{
        return this.router.navigateByUrl("/cookie/mypage")
      },
      error: ()=>{
        alert("エラー: 登録できませんでした。")
      }
    })
  }
}

API コール時に Chrome 検証ツールの Network 欄で、API のレスポンスに SetCookie ヘッダが付与されているのを確認しておきましょう。

Guard の実装

mypage などの認証画面で、認証済みのユーザのみにアクセスを許可するために、Guard を作成します。
Guard は、ルートと紐付けてルートアクセス時にフィルタ処理を実行できる Angular の機構です。

Guard の中では、ユーザのログイン状態を管理する必要があり、これを実現するためにまず、AuthService を作成しましょう。

import { Injectable } from '@angular/core';
import {BehaviorSubject} from "rxjs";

interface User{
  email: string,
  firstName: string,
  lastName: string,
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  private _user$

  constructor() {
    this._user$ = new BehaviorSubject<User|null>(null)
  }

  get user$(){
    return this._user$.asObservable()
  }

  login(user:User){
    this._user$.next(user)
  }

  logout(){
    this._user$.next(null)
  }
}

AuthService に _user$ を用意して、アプリケーション全体でユーザ状態の変更を監視できるようにしています。
Subject は書き込み可能な Observable のような存在で、今回は Subject の中でも直前の値を1つ保持してくれる BehaviorSubject を利用しています。

AuthService では、login / logout を実装し、next をコールしてユーザ状態の変更を通知できるようにしています。

外部に公開する際には getter の user$ で Observable に変換してから公開し、外部からの自由なユーザ情報変更を行えないようにしています。

ここまで準備ができたら、作成した AuthService を利用して、AuthGuard を作成します。

AuthGuard の処理は以下のようなステップで実行されます。

  • ルートアクセス前に、AuthService の状態を確認する。
  • AuthService.user$ の値が空なら プロフィール API にアクセスする
    • プロフィール API がエラーなら ゲスト状態とする
    • プロフィール API からデータが取得できたら 認証済みとする
  • 認証済みの場合、AuthServiceのデータを更新する
  • ゲスト状態の場合、ログイン画面にリダイレクト
  • 認証済みの場合、true を返してルートアクセスを許可
import { Injectable } from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
import {Observable, of} from 'rxjs';
import {AuthService} from "./auth.service";
import {catchError, distinct, map, switchMap, tap} from "rxjs/operators";
import {HttpClient} from "@angular/common/http";

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(
    private auth: AuthService,
    private http: HttpClient,
    private router: Router
  ) {
  }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    return this.auth.user$.pipe(
      distinct(),
      switchMap((userOrNull)=>{
        if(userOrNull === null){
          // AuthService にデータが入っていないとき  
          return this.http.get<{user:any}>("/api/profile").pipe(
            catchError(()=>of({user:null})),
            map((result) => result.user)
          )
        }else{
          // AuthService にデータが入っているとき
          return of(userOrNull)
        }
      }),
      tap(async (userOrNull)=>{
        // データが存在する場合、AuthService のデータを更新 
        this.auth.login(userOrNull)
      }),
      switchMap((userOrNull) => {
        if(userOrNull === null){
          // 認証失敗  
          return of(this.router.parseUrl(`/cookie/login?redirect_to=${state.url}`))
        }else{
          // 認証失敗  
          return of(true)
        }
      }),
    )
  }
}

Discussion