Angular

【Angular】Bearer認証機能の実装

マナビト
マナビト
こんにちはマナビトです。
普段はフロントエンドエンジニアとしてJavaScript/TypeScript/Aangular/GraphQLを
メインに開発業務を行なっています。

今回は、AngularにおけるBearer認証機能の実装を行なっていきます。

はじめてWebアプリを開発するような人に向けたAngularの入門書です。

概要

AngularでBearer認証機能を実装します。

ソースツリー

今回作成したアプリのソースツリーは下記の通りです。

├── app
│   ├── _helper
│   │   └── auth.guard.ts
│   ├── _intercepter
│   │   ├── backend.intercepter.ts
│   │   ├── error.interceptor.ts
│   │   ├── index.ts
│   │   └── jwt.intetceptor.ts
│   ├── _models
│   │   └── user.ts
│   ├── _service
│   │   ├── authentication.service.ts
│   │   └── user.service.ts
│   ├── app-routing.module.ts
│   ├── app.component.html
│   ├── app.component.scss
│   ├── app.component.ts
│   ├── app.module.ts
│   ├── common
│   │   ├── alphanumerics.ts
│   │   └── oneCharacters.ts
│   └── login
│       ├── login-routing.module.ts
│       ├── login.component.html
│       ├── login.component.scss
│       ├── login.component.ts
│       ├── login.module.ts
│       └── mypage-top
│           ├── mypage-top-routing.module.ts
│           ├── mypage-top.component.html
│           ├── mypage-top.component.scss
│           ├── mypage-top.component.ts
│           └── mypage-top.module.ts
├── assets
├── environments
│   ├── environment.prod.ts
│   └── environment.ts
├── favicon.ico
├── index.html
├── main.ts
├── polyfills.ts
├── styles.scss
└── test.ts

バージョン

フレームワークのアプリケーション情報です。

Github

今回開発したソースコードは全てGithubに掲載しています。

TypeDoc

コメントはTypeScript用ドキュメントツールのTypeDocを使用します。

命名規則

命名規則は下記コーディング規約に従います。

Bearer認証方式

Bearer認証は、HTTPのAuthorizationヘッダにユーザー名とパスワードをセットして有効かどうかを検証する認証方式です。

クライアントからAuthorization: Bearer <token>のように指定してリクエストを送信して、アクセストークンが有効だった場合は、サーバーがクラアントが要求した内容を返信します。もし、認証が失敗した場合、サーバは401 Unauthorizedを返信します。この時に、WWW-Authenticateヘッダのerrorパラメータ内にリクエストが失敗した理由が格納されます。

型定義

インターフェイスの情報です。tokenにアクセストークンをセットします。

export interface User {
    id?: number;
    username?: string;
    firstName?: string;
    lastName?: string;
    token?: string;
}

Serviceの実装

Authentication service

Authentication serviceにログイン、ログアウト時の挙動を実装します。

  private currentUserSubject: BehaviorSubject<User>;
  public currentUser: Observable<User>;

  constructor(private http: HttpClient) {
    this.currentUserSubject = new BehaviorSubject<User>(
      JSON.parse(localStorage.getItem('currentUser') as any)
    );
    this.currentUser = this.currentUserSubject.asObservable();
  }

ユーザー情報を保持する為に、BehaviorSubjectを使用します。newの段階でlocalStrageから現在のユーザー情報を取得します。

localStrageはローカルのStorageオブジェクトにアクセスして、データを保存する役割を担います。ログインの途中でページをリロードしてもログイン情報を保持することができます。

localStorageによく似た機能として、sessionStorageがあります。違いはsessionStorageがセッションが終わると同時にデータが削除されますが、localStorageの場合はデータに保存期間がないことです。

ユーザー情報を取得するにゲッターをセットします。

  /**
   * ユーザー情報を取得
   */
  public get currentUserValue(): User | undefined {
    return this.currentUserSubject?.value;
  }

ログイン時に、サーバーにリクエストを送信します。成功した場合は、localStorageに情報を保持して、BehaviorSubjectは情報の更新を行う処理を実装します。

  /**
   * リクエストを送信。成功した場合はlocalStorageに保持
   * @param {string}username
   * @param {string}password
   * @returns {any}
   */
  login(username: string, password: string): Observable<any> {
    return this.http
      .post<any>(`${environment.apiUrl}/users/authenticate`, {
        username,
        password,
      })
      .pipe(
        map((user) => {
          localStorage.setItem('currentUser', JSON.stringify(user));
          this.currentUserSubject?.next(user);
          return user;
        })
      );
  }

ログアウトの際はlocalStorageからユーザー情報を削除して、BehaviorSubjectもnullに設定します。

  /**
   * user情報を削除。
   * @returns {void}
   */
  logout(): void {
    localStorage.removeItem('currentUser');
    this.currentUserSubject?.next(null as any);
  }

少し話がそれてしまいますが、以前ブログで@NgRX/component-storeを紹介しました。BehaviorSubjectとは別に値を保持することが出来ます。

【Angular】@ngrx/component-storeによる状態管理アプリ 以前、Angularの状態管理ライブラリである@ngrx-component-storeを使用したカウンターア...

この@NgRX/component-storeにsessionStorageを組み合わせることで、component-storeによる状態管理とともに、サイトをリロードしてもデータを保持することが可能になります。

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { ComponentStore } from '@ngrx/component-store';
import { PersonState } from '../models/person-state';
import { Person } from 'src/app/voes/person';

@Injectable()
export class PersonStore extends ComponentStore<PersonState> {
  private static SESSION_STORAGE_KEY = 'starWars.state';

  constructor() {
    super(
      sessionStorage.getItem(PersonStore.SESSION_STORAGE_KEY) || {
        people: [],
      }
    );


  // 値をStoreから取得
  readonly people$: Observable<Person[]> = this.select(
    ({ people }) => people
  ).pipe(tap(console.log));


  // 値をアップデート
  readonly loadPeople = this.updater((state, people: Person[] | null) => {
    state = {
      ...state,
      people: people || [],
    };
    sessionStorage.setItem(PersonStore.SESSION_STORAGE_KEY, state);
    return state;
  });


  /**
   * 全データ、Sessionを削除
   */
  private readonly clear = this.updater((state) => {
    sessionStorage.removeItem(PersonStore.SESSION_STORAGE_KEY);
    return {} as PersonState;
  });

}

User service

getメゾットを使用して、ユーザー情報を取得します。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
import { User } from 'src/app/_models/user';

@Injectable({ providedIn: 'root' })
export class Userservice {
  constructor(private http: HttpClient) {}

  getAll(): any {
    return this.http.get<User[]>(`${environment.apiUrl}/users`);
  }
}

Auth Guard

Auth Guardはページ遷移前にログインされたユーザーなのかをチェックを行い、ログインしていないユーザーをブロックすることが出来ます。

import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, } from '@angular/router';
import { AuthenticationService } from 'src/app/_service/authentication.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(
    private router: Router, 
    private authenticationService: AuthenticationService
    ) {}

  /**
   * ログイン済みユーザーをチェック
   * @param route
   * @param state
   * @returns {boolean}
   */
  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    const currentUser = this.authenticationService.currentUserValue;
    if (currentUser) {
      return true;
    }

    this.router.navigate(['']), { queryParams: { returnUrl: state.url } };
    return false;
  }
}

Routeの設定ファイルにAuthGuardの処理を挟み込みます。

import { AuthGuard } from 'src/app/_helper/auth.guard'

const routes: Routes = [
  { path: '', component: LoginComponent },
  {
    path: 'mypagetop',
    loadChildren: () =>
      import('./login/mypage-top/mypage-top.module').then((m) => m.mypagetopModule),
      canActivate: [AuthGuard]
  },
  { path: '**', redirectTo: '' }
];

Interceptor

AngularのInterceptorはリクエストとレスポンスの間に特定の処理を行う場合や中身を検証する場合にに使用します。サービスクラスとして定義してDI機構を通じてInterceptorとして登録します。

Error Interceptor

Error Interceptorでは、エラーハンドリングの共通化処理を行います。

interceptメゾッとはリクエストを表すrequsetと次のInterceptorに処理を引き継ぐnextを引数に取ります。リクエスト結果が401の場合は自動的にログアウトとなり、コンソールに表示させるようにします。

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthenticationService } from 'src/app/_service/authentication.service';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
  constructor(private authenticationservice: AuthenticationService) {}

  /**
   * エラーハンドリング
   * @param {HttpRequest}
   * @param {HttpHandler}
   * @throws {throwError}
   */
  intercept(
      request: HttpRequest<any>, next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return next.handle(request).pipe(
      catchError((error) => {
        if (error.status === 401) {
          this.authenticationservice.logout();
          location.reload(); // URL再読み込み
        }
        const err = error.error.message | error.statusText;
        return throwError(err);
      })
    );
  }
}

backend Interceptor

Backend側の実装となります。

ユーザー名とパスワードを手打ちで実装します。

// ユーザー名とパスワード
const users = [
  {
    id: 1,
    username: 'test',
    password: 'test2020D',
    firstName: 'Test',
    lastName: 'User',
  },
];

フロントエンド側から渡された、ユーザ名とパスワードが一致すれば、正常にレスポンスが返ってきます。(後から気づきましたが、errorメッセージがハードコーディングされており、定数化するべきでした...)

    /**
     * ユーザー名、パスワードが一致するか確認
     * @returns {any}
     */
    function authenticate(): any {
      const { username, password } = body;
      const user = users.find(
        (x) => x.username === username && x.password === password
      );

      if (!user) {
        return error('ユーザー名もしくはパスワードが正しくありません。');
      } else {
        return ok({
          id: user.id,
          username: user.firstName,
          lastName: user.lastName,
          token: 'fake-jwt-token',
        });
      }
    }

リクエストがGETであれば、ユーザー情報取得し、POSTであれば、先程の処理が走りレスポンスを返します。

    /**
     * 指定したURLよって条件分岐
     * @returns {any}
     */
    function handleRoute(): any {
      switch (true) {
        case url.endsWith('/users/authenticate') && method === 'POST':
          return authenticate();
        case url.endsWith('/users') && method === 'GET':
          return getUsers();
        default:
          next.handle(requset);
      }
    }

JWT Interceptor

Bearer認証の処理を実装します。
tokenが存在すれば、Authorization: Bearer <token>のように指定してリクエストをサーバーに送信します。

import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { AuthenticationService } from '../_service/authentication.service';

@Injectable()
export class JWTInterceptor implements HttpInterceptor {
  constructor(private authenticationservice: AuthenticationService) {}

  /**
   * Bearer認証の処理
   * @param requset 
   * @param next 
   * @returns {requset}
   */
  intercept(requset: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    
    const currentUser = this.authenticationservice.currentUserValue;
    const isLoggedIn = currentUser && currentUser.token;
    const isApiUrl = requset.url.startsWith(environment.apiUrl);

    if (isLoggedIn && isApiUrl) {
      requset = requset.clone({
        setHeaders: {
          Authorization: `Bearer $ { currentUser.token }`,
        },
      });
    }
    return next.handle(requset);
  }
}

各InterceptorをHttpInterceptorProvidersとして一つにまとめます。

import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { ErrorInterceptor } from 'src/app/_intercepter/error.interceptor';
import { JWTInterceptor } from 'src/app/_intercepter/jwt.intetceptor';
import { backendInterceptor } from 'src/app/_intercepter/backend.intercepter';


export const HttpInterceptorProviders = [
  { provide: HTTP_INTERCEPTORS, useClass: JWTInterceptor, multi: true},
  { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
  { provide: HTTP_INTERCEPTORS, useClass: backendInterceptor, multi: true },
];

app.module.tsにHttpInterceptorProvidersを登録します。

import { HttpInterceptorProviders } from 'src/app/_intercepter/index';
・
・
・
・
  providers: [
    HttpInterceptorProviders
  ],

Validationの実装

カスタムバリデーターを使用して、入力フォームのバリデーションを実装します。

半角英数字のみ入力可能

import { AbstractControl, ValidationErrors } from '@angular/forms';

export class alphanumerics{

    constructor(){}

  /**
   * 半角英数字のみ設定可能
   * @param form 
   * @returns { ValidationErrors | null }
   */

  public static format(form: AbstractControl): ValidationErrors | null{
    const alphanumeric = /^[0-9a-zA-Z]*$/;
    if (alphanumeric.test(form.value)) {
      return null;
    } else {
      return { alphanumeric: { valid: true } };
    }
  }

}

半角英字大文字, 半角英字小文字, 数字を1文字づつ以上含む場合のみ入力可能

import { AbstractControl, ValidationErrors } from '@angular/forms';

export class oneCharacter {

    constructor(){}

  /**
   * 半角英字大文字, 半角英字小文字, 数字を1文字づつ以上含む
   * @param form 
   * @returns { ValidationErrors | null }
   */

  public static format(form: AbstractControl): ValidationErrors | null{
    const oneCharacter = /(?=.*[0-9])(?=.*[A-Z])(?=.*[a-z])/;
    if (oneCharacter.test(form.value)) {
      return null;
    } else {
      return { oneCharacters: { valid: true } };
    }
  }

}

設定した値以外を入力すると、バリデーションエラーが返ってきます。

login.componentの実装

ログイン用のフォームグループを設定します。バリデーションには先ほど設定したカスタムバリデーターを登録します。

  ngOnInit(): void {
    
    this.loginForm = this.fb.group({
      username: ['', [Validators.required, Validators.maxLength(225)]],
      password: ['', [Validators.required, Validators.minLength(8), Validators.maxLength(24), oneCharacter.format, alphanumerics.format],],
    });

    // 既にログイン済みの場合は、mypage-topに移動する。
    if (this.authenticationService.currentUserValue) {
      this.router.navigate(['mypagetop']);
    }
  }

フォームにてユーザー名とパスワードを入力後、成功すればログイン完了が完了します。

  /**
   * ログイン成功時はreturnUrlに画面遷移する。失敗した時はエラー表示される。
   * @returns {any}
   */
  onSubmit(): any {
    this.submitted = true;
    if (this.loginForm?.invalid) {
      return false;
    }

    this.loading = true;
    this.authenticationService
      .login(this.form?.username.value, this.form?.password.value)
      .pipe(first())
      .subscribe({
        next: () => {
          this.router.navigate(['mypagetop']);
        },
        error: (error) => {
          this.error = error;
          this.loading = false;
        },
      });
  }

バリデーションエラーが発生した場合に、個々のエラーの条件に合わせてエラーメッセージを画面上に表示させます。

  /**
   * Usernameのエラーハンドリング
   * @param {any}errors
   * @returns {any}
   */
  userErrorMessage(errors: any): any {
    if (errors?.required) {
      return 'ユーザー名の入力は必須です。';
    } else if (errors?.maxLength) {
      return `${errors?.maxLength.requiredLength}以内で入力してください。`;
    }
  }

  /**
   * passwordのエラーハンドリング
   * @param {any}errors
   * @returns {any}
   */
  passwordErrorMessage(errors: any): any {
    if (errors?.required) {
      return 'パスワードの入力は必須です';
    } else if (errors?.maxlength) {
      return `${errors.maxlength.requiredLength}文字以内で入力してください。`;
    } else if (errors?.minlength) {
      return `${errors?.minlength.requiredLength}文字以上で入力してください。`;
    } else if (errors?.oneCharacters) {
      return '半角英数字を1文字以上含んで入力してください。';
    } else if (errors?.alphanumerics) {
      return '半角英数字で入力してください。';
    }
  }

ログイン画面の実装を行います。

<h1>ログイン画面</h1>

<div class="loading" *ngIf=loading>
  <mat-spinner></mat-spinner>
</div>

<form *ngIf="!loading" class="form-login" [formGroup]="loginForm" (submit)="submit()" #createForm="ngForm">
  <mat-form-field class="example-full-width" appearance="fill">
    <mat-label>username</mat-label>
    <input matInput formControlName="username" placeholder="username"/>
    <mat-error>{{ userErrorMessage(form?.username?.errors) }}</mat-error>
    <mat-hint>{{ form?.username?.value.length }}/225</mat-hint>
  </mat-form-field>
  <mat-form-field class="example-full-width" appearance="fill">
    <mat-label>password</mat-label>
    <input matInput formControlName="password" placeholder="password"/>
    <mat-error>{{ passwordErrorMessage(form?.password?.errors) }}</mat-error>
  </mat-form-field>
  <button mat-button type="submit" mat-raised-button class="btn btn-primary" (click)="onSubmit()" [disabled]="disabled()">ログイン</button>
  <div *ngIf="loading"  class="alert alert-danger mt-3 mb-0">{{ error }}</div>
</form>
<!-- ここまでformGroup -->

これで入力フォームの実装は完了です。

mypage-top.componentの実装

最後にログイン後、userserviceからユーザー情報を取得して、画面に表示させます。

  ngOnInit(): void {
    //serviceからUser情報を取得
    this.loading = true;
    this.usersrvice.getAll().pip(first()).subscribe((users: User[]) => {
      this.loading = false;
      this.users = users;
    });
  }
<h1>ログイン完了画面</h1>
<div *ngIF="loading"></div>
<ul *ngIf="users">
  <li *ngFor="let user of users">{{user.firstName}}{{user.lastName}}</li>
</ul>

本日はここまでとなります。
最後までお読みいただきありがとうございました。

はじめてWebアプリを開発するような人に向けたAngularの入門書です。