AWS
angular
cognito
serverless
aws-amplify

AWS Amplify + AngularでサーバーレスSPAの認証をするサンプル

これまで、サーバーレスなSPA on AWSを実現しようとした場合、Cognitoによる認証部分の実装には、amazon-cognito-identity-jsというAWS製のJavaScriptライブラリを使うのが主流でした。
ただ、amazon-cognito-identity-jsを使う場合、このライブラリ本体の他にAWS SDKなどを含めた複数のライブラリを読み込む必要があったり、用意されているAPIの使い勝手が良くなかったりと、多少の辛みを感じる部分がありました。

今回は、AWSより新しくリリースされたAWS Amplifyというライブラリを使ってAngularアプリケーションにCognitoによる認証機能の実装を行ってみたいと思います。
AWS Amplifyの概要は、本アドベントカレンダー4日目の nakayama_cwさんがAWS AmplifyでサーバレスWebアプリの構築(Cognito + API Gateway + IAM認証)という記事で説明されていますので、そちらを御覧ください。

補足

AWS Amplifyには次の3つのパッケージが存在しており、ベースがaws-amplify、Reactでログイン周りのUIコンポーネントまで用意されているのがaws-amplify-react、React版と同様の機能をReactNative実装しているのがaws-amplify-react-nativeというようになっています。

※リンク先は、npm packageのページ

今回やること

上記で紹介したように、ベースのライブラリの他にReactのコンポーネントまでそろったライブラリが出ていますが、ここはあえてAngluarでもAWS Amplifyを試してみたいと思います。
今回は、aws-amplifyのAuthモジュールを使って、Angluarアプリに次の機能を実装する手順を紹介します。

  • サインアップ機能
  • ログイン機能
  • ログアウト機能
  • Router Guardによる遷移操作

また、今回作成するサンプルの完成版は以下のリポジトリで公開しています。
angular-aws-amplify -GitHub

手順

環境

  • Angluar
$ ng -v
...
Angular CLI: 1.6.1
Node: 9.2.0
OS: darwin x64
Angular: 
...
  • aws-amplify
0.1.20

AWS(Cognito)のリソース用意

Cognito User Poolの作成

AWS Consoleにログインし、Cognito -> 「ユーザープールの管理」-> 「ユーザープールを作成する」を選択します。

「ユーザープールをどのように作成しますか?」では、「ステップに従って設定する」を選択します。
また、次の手順でサインインの方法する画面になりますが、ここでは「E メールアドレスおよび電話番号」にチェックを入れます。

スクリーンショット 2017-12-18 3.38.05.png

ユーザープールへのアクセス権限設定では、「アプリクライアントの追加」からメニューを展開します。

スクリーンショット 2017-12-18 3.43.09.png

「アプリクライアント名」を入力し、「クラインとシークレットを生成する」のチェックを外してアプリクライントを作成します。

スクリーンショット_2017-12-18_3_40_27.png

その他の項目はデフォルトのままで進めます。

Cognito Identity Poolの作成

Cognito -> 「フェデレーテッドアイデンティティの管理」を選択します。
選択すると、「新しい ID プールの作成」の画面が表示されるので、次のように設定して、「プールの作成」をクリックします。
認証プロバイダーにはCognitoを選択して、先ほど作成したユーザープールIDとアプリクライアントIDを入力します。

スクリーンショット_2017-12-18_3_52_04.png

Angularプロジェクトの準備

まずは、プロジェクト作成します。
今回は、Router Guardによる遷移操作の実装まで行うため--routingオプションをつけています。

$ ng new angular-aws-amplify --routing

次に必要なパッケージのインストールを行います。
必要なパッケージはAWS Amplifyのみです🎉

$ npm install aws-amplify --save

次に、AWS Amplifyの初期化時に渡すためのAWSの情報を設定ファイルに記述していきます。
今回は、ng newした際に作成されている、src/environments/environment.tsに記述していきます。

src/environments/environment.ts
export const environment = {
  production: false,
  amplify: {
    // 以下がAWS Amplify(Auth)の設定
    Auth: {
      identityPoolId: 'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
      region: 'ap-northeast-1',
      userPoolId: 'ap-northeast-1_xxxxxxxxx',
      userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxx'
    }
  }
};

AuthService

認証系の処理をまとめたサービスを作成します。

$ ng g service auth/auth

コンストラクタでAmplify.configure()に設定ファイルを渡して初期化する他、AWS AmplifyのAuthモジュールの機能をハンドリングするメソッドを実装していきます。

src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
// 追加
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { fromPromise } from 'rxjs/observable/fromPromise';
import { map, tap, catchError } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';
import Amplify, { Auth } from 'aws-amplify';
import { environment } from './../../environments/environment';

@Injectable()
export class AuthService {

  public loggedIn: BehaviorSubject<boolean>;

  constructor(
    private router: Router
  ) {
    Amplify.configure(environment.amplify);
    this.loggedIn = new BehaviorSubject<boolean>(false);
  }

  /** サインアップ */
  public signUp(email, password): Observable<any> {
    return fromPromise(Auth.signUp(email, password));
  }

  /** 検証 */
  public confirmSignUp(email, code): Observable<any> {
    return fromPromise(Auth.confirmSignUp(email, code));
  }

  /** ログイン */
  public signIn(email, password): Observable<any> {
    return fromPromise(Auth.signIn(email, password))
      .pipe(
        tap(() => this.loggedIn.next(true))
      );
  }

  /** ログイン状態の取得 */
  public isAuthenticated(): Observable<boolean> {
    return fromPromise(Auth.currentAuthenticatedUser())
      .pipe(
        map(result => {
          this.loggedIn.next(true);
          return true;
        }),
        catchError(error => {
          this.loggedIn.next(false);
          return of(false);
        })
      );
  }

  /** ログアウト */
  public signOut() {
    fromPromise(Auth.signOut())
      .subscribe(
        result => {
          this.loggedIn.next(false);
          this.router.navigate(['/login']);
        },
        error => console.log(error)
      );
  }
}
  • loggedIn(BehaviorSubject)

    • グローバルなナビゲーションの表示を切り替えるために使っています。
  • signUp()、confirmSignUp()、signIn()、geUserInfo()

    • AWS AmplifyのAuthモジュールのメソッドが返すPromiseをRxのfromPromiseでObservableに変換するだけ。
  • isAuthenticated()

    • ログイン状態にある場合にはAuth.currentAuthenticatedUser()がPromiseのユーザー情報オブジェクトを返すため、それをBooleanのObservableに変換しています。(後ほど実装するGuardで使うため)
  • signOut()

    • Auth.signOut()を呼びつつ、ログイン画面に遷移させる。

AuthGuard

AngularのGuardでは、ルーティング前にセッションチェックなどの処理を行って、ルーティングの拒否を行うことができます。

$ ng g guard auth/auth

今回は、CanActivateの機能を使って、ログイン状態にない場合に、login画面に画面遷移させるGuardを作成します。
CanActivateはPromiseまたは、Observableの非同期処理を行った結果のbooleanを戻り値に取ることが出来るので、
先ほどのAuthServiceで作成したisAuthenticated()メソッドの結果を使っています。

src/app/auth/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
// 追加
import { of } from 'rxjs/observable/of';
import { map, tap, catchError } from 'rxjs/operators';
import { AuthService } from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private router: Router,
    private auth: AuthService
  ) { }

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> {
      return this.auth.isAuthenticated()
        .pipe(
          tap(loggedIn => {
            if (!loggedIn) {
              this.router.navigate(['/login']);
            }
          })
        );
  }
}

AppRoutingModule

ルーティングの定義を行います。
プロジェクト作成時に--routingオプションを指定しているので、src/app/app-routing.module.tsが作成されているはずです。このファイルを編集していきます。

src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule, CanActivate } from '@angular/router';
// 追加
import { LoginComponent } from './login/login.component';
import { SignupComponent } from './signup/signup.component';
import { HomeComponent } from './home/home.component';
import { AuthGuard } from './auth/auth.guard';

const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
  { path: 'login', component: LoginComponent },
  { path: 'signup', component: SignupComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

HomeComponent、LoginComponent、SignupComponentの3つのページがあり、HomeComponentをログインしたユーザーのみが閲覧できるようにするため、AuthGuardでルーティングまえに検証しています。
※各ページのコンポーネントについてはあとで紹介します。

LoginComponent

ログインページのコンポーネントを作成します。

$ ng g component login
src/app/login/login.component.html
<p>login</p>
<div class="login">
  <form [formGroup]="loginForm" (ngSubmit)="onSubmitLogin(loginForm.value)">
    <p>
      <label>Email: </label>
      <input type="email" formControlName="email">
    </p>
    <p>
      <label>Password: </label>
      <input type="password" formControlName="password">
    </p>
    <button type="submit">Login</button>
  </form>
</div>
src/app/login/login.component.ts
import { Component, OnInit } from '@angular/core';
// 追加
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from './../auth/auth.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
  public loginForm: FormGroup;

  constructor(
    private fb: FormBuilder,
    private router: Router,
    private auth: AuthService
  ) { }

  ngOnInit() {
    this.initForm();
  }

  initForm() {
    this.loginForm = this.fb.group({
      'email': ['', Validators.required],
      'password': ['', Validators.required]
    });
  }

  onSubmitLogin(value: any) {
    const email = value.email, password = value.password;
    this.auth.signIn(email, password)
      .subscribe(
        result => {
          this.router.navigate(['/']);
        },
        error => {
          console.log(error);
        });
  }
}
  • onSubmitLogin()
    • ログインフォームがサブミットされる際のイベントハンドラ。AuthServicesignIn()メソッドの結果、ログインに成功したらhomeへ遷移します。

SignupComponent

サインアップ(登録)ページのコンポーネントを作成します。
このページでは、登録情報の入力と、確認コードの検証までを行います。

$ ng g component signup
src/app/login/signup.component.html
<p>signup</p>
<button (click)="successfullySignup = true" *ngIf="!successfullySignup">メールアドレスと確認コードで認証</button>
<div class="signup" *ngIf="!successfullySignup">
  <form [formGroup]="signupForm" (ngSubmit)="onSubmitSignup(signupForm.value)">
    <p>
      <label>Email: </label>
      <input type="email" formControlName="email">
    </p>
    <p>
      <label>Password: </label>
      <input type="password" formControlName="password">
    </p>
    <button type="submit">Submit</button>
  </form>
</div>
<div class="confirmation" *ngIf="successfullySignup">
  <form [formGroup]="confirmationForm" (ngSubmit)="onSubmitConfirmation(confirmationForm.value)">
    <p>
      <label>Email: </label>
      <input type="email" formControlName="email">
    </p>
    <p>
      <label>Confirmation Code: </label>
      <input type="text" formControlName="confirmationCode">
    </p>
    <button type="submit">Confirm</button>
  </form>
</div>
src/app/login/signup.component.ts
import { Component, OnInit } from '@angular/core';
// 追加
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from './../auth/auth.service';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.component.html',
  styleUrls: ['./signup.component.css']
})
export class SignupComponent implements OnInit {
  public signupForm: FormGroup;
  public confirmationForm: FormGroup;
  public successfullySignup: boolean;

  constructor(
    private fb: FormBuilder,
    private router: Router,
    private auth: AuthService
  ) { }

  ngOnInit() {
    this.initForm();
  }

  initForm() {
    this.signupForm = this.fb.group({
      'email': ['', Validators.required],
      'password': ['', Validators.required]
    });
    this.confirmationForm = this.fb.group({
      'email': ['', Validators.required],
      'confirmationCode': ['', Validators.required]
    });
  }

  onSubmitSignup(value: any) {
    const email = value.email, password = value.password;
    this.auth.signUp(email, password)
      .subscribe(
        result => {
          this.successfullySignup = true;
        },
        error => {
          console.log(error);
        });
  }

  onSubmitConfirmation(value: any) {
    const email = value.email, confirmationCode = value.confirmationCode;
    this.auth.confirmSignUp(email, confirmationCode)
      .subscribe(
        result => {
          this.router.navigate(['/login']);
        },
        error => {
          console.log(error);
        });
  }
}
  • onSubmitSignup()

    • サインアップフォームがサブミットされる際のイベントハンドラ。AuthServicesignUp()メソッドの結果、サインアップに成功したら、successfullySignupフラグをtrueにし、検証コードの入力フォームを表示しています。
  • onSubmitConfirmation()

    • 検証コードの入力フォームがサブミットされる際のイベントハンドラ。AuthServiceconfirmSignUp()メソッドの結果、サインアップに成功したらloginへ遷移します。

HomeComponent

ログイン済みのユーザーのみが閲覧出来るホームページのコンポーネントを作成します。

$ ng g component home

今回は、ログイン後の機能に関しては実装していないのでコードの記載は省略します。

AppComponent

AppComponentには各ページ共通で表示するナビゲーションを配置しました。

app.component.html
<nav>
  <ul class="main-nav">
    <li><a [routerLink]="['/']">Angular AWS Amplify</a></li>
    <li><a [routerLink]="['/']">ホーム</a></li>
    <li *ngIf="!(auth.loggedIn | async)"><a [routerLink]="['/login']">ログイン</a></li>
    <li *ngIf="(auth.loggedIn | async)"><a (click)="onClickLogout()">ログアウト</a></li>
    <li><a [routerLink]="['/signup']">登録</a></li>
  </ul>
</nav>
<router-outlet></router-outlet>
app.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
// 追加
import { Subscription } from 'rxjs/Subscription';
import { AuthService } from './auth/auth.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
  subscription: Subscription;
  public loggedIn: boolean;

  constructor(
    public auth: AuthService
  ) { }

  ngOnInit() {
    this.subscription = this.auth.isAuthenticated()
      .subscribe(result => {
        this.loggedIn = result;
      });
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

  onClickLogout() {
    this.auth.signOut();
  }
}
  • ナビゲーションのログイン・ログアウト

    • AuthServiceloggedInSubjectをasyncパイプで受け取って、表示する内容を変更しています。
  • ngOnInit()

    • ページがリロードされた際にログイン状態を再取得するためにAuthServiceisAuthenticated()メソッドを呼び出しています。
  • onClickLogout()

    • ログインボタンが押された際のイベントハンドラ。

AppModule

最終的なsrc/app/app.module.tsは次のようになりました。

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
// 追加
import { AuthService } from './auth/auth.service';
import { AuthGuard } from './auth/auth.guard';
import { LoginComponent } from './login/login.component';
import { SignupComponent } from './signup/signup.component';
import { HomeComponent } from './home/home.component';

@NgModule({
  declarations: [
    AppComponent,
    LoginComponent,
    SignupComponent,
    HomeComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    ReactiveFormsModule,
  ],
  providers: [
    AuthService,
    AuthGuard
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

以上で、基本的な実装は完了です。

所感

今回、使用したAuthのAPIに関しては、amazon-cognito-identity-jsではAWS SDKと組み合わせる必要があったりする部分ですが、AWS Amplifyがそこら辺の面倒な処理をうまくラップしてくれているおかげでかなり簡単にCognitoによる認証が実装できました。
細かいところですが、用意されているAPIがPromiseに統一されている部分も好感が持てます。
また、AWS Amplifyにはsigv4署名を簡単に行えるヘルパーメソッド的なものも用意されているようです。
今回は触れられませんでしたが、そのうち試してみたいと思っています。

参考