Angular+Cognitoで作るログインページ– ClassmethodサーバーレスAdvent Calendar 2017 #serverless #adventcalendar

2017.12.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

追記:2018年6月24日

現時点の最新版で書き直しました。下記を参照ください。

どうも!大阪オフィスの西村祐二です。

このエントリはServerless Advent Calendar 2017の12日目の記事となります。

今、Angularを勉強しているので、AngularとCognitoを使って簡易なログインページを作ってみたいと思います。
ついでに、API GatewayにもCognitoで認証をかけて、Cognito使ってログインしているユーザのみ
利用できるようにしてみます。

Amazon Cognitoでの作業

Cognito User Pool作成

▼マネージメントコンソールより、Cognito -> 「ユーザープールの管理」-> 「ユーザープールを作成する」を選択します。

▼ユーザープール名を「cognito-test」として作成します。
「ユーザープールをどのように作成しますか?」では、「デフォルトを確認する」を選択します。
内容を確認して、「プールの作成」をクリックします。

▼「アプリクライアント」-> 「アプリクライアントの追加」からアプリクライアントを追加します。
今回は「cognito-test」としています。
「アプリクライアントの作成」をクリックします。

ユーザ登録

ユーザプールにユーザを登録します。

全体設定 -> ユーザーとグループ -> 「ユーザの作成」をクリックします。

「user-test」として今回作成しています。

電話番号は、プラス記号 (+) から始めて国番号がその後に続く必要があります。
日本の場合は81番なので「090-XXXX-YYYY」ときは「+8190XXXXYYYY」となります。

API Gatewayでの作業

サンプルAPIを作成

▼マネージメントコンソールより、API Gateway -> 「APIの作成」-> 「APIの例」を選択します。
APIを作成すると「PetStore」が作成されます。

今回、ペット一覧を取得するGETメソッドに認証をかけたいと思います。

オーサライザーの作成

▼Cognitoを使ってAPIを認証するために、オーサライザーを作成します。

「PetStore」 -> オーサライザー を選択します。
設定は下記画像のように設定します。
名前「cognito-test」、トークンのソース「Authorization」として設定しています。

認証設定

▼「PetStore」 -> 「リソース」 -> /petsのGET を選択します。
メソッドリクエスト -> 認証をクリックし、「cognito-test」を選択します。

デプロイ

▼動作確認
ブラウザからアクセスしてみると、きちんと「Unauthorized」となっていることがわかります。

Angularでログインページ作成

さて、ここからが本題です。

雛形作成

▼雛形ファイルを作成します。

# ng new cognito-login --routing

# ng g service service/cognito
# ng g service service/pet

# ng g component login
# ng g component petlist
# ng g component dashboard

▼必要なパッケージをインストールします。

# npm i --save amazon-cognito-identity-js
# npm i --save aws-sdk

▼プロジェクトの設定を行います。

aws-sdkを使うためにnodeを定義します。

tsconfig.app.json

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "es2015",
    "types": [
      "node"
    ]
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ]
}

AWSのリソース情報を設定ファイルに記述します。

src/environments/environment.ts

export const environment = {
  production: false,
  region: 'ap-northeast-1',
  userPoolId: 'ap-northeast-1_ABCDEFG10', // User Pools の画面から取得できる User Pools ID。
  clientId: 'XXXxxxXXXX'  // User Pools で発行したクライアントアプリケーションのID。
};

▼追加したサービスを追記しておきます。

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { PetlistComponent } from './petlist/petlist.component';
import { DashboardComponent }   from './dashboard/dashboard.component';

// add service
import { CognitoService } from './service/cognito.service';
import { PetService } from './service/pet.service';

@NgModule({
  declarations: [
    AppComponent,
    LoginComponent,
    PetlistComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [CognitoService, PetService],
  bootstrap: [AppComponent]
})
export class AppModule { }

Cognito認証処理の実装

下記ブログを参考に実装ています。

Amazon Cognito と仲良くなるために歴史と機能を整理したし、 Cognito User Pools と API Gateway の連携も試した

▼参考サイト
https://github.com/aws/amazon-cognito-identity-js

service/cognito.service.ts

import { Injectable } from '@angular/core';
import { environment } from './../../environments/environment';

import {
  CognitoUserPool,
  CognitoUser,
  AuthenticationDetails
} from 'amazon-cognito-identity-js';
import * as AWS from 'aws-sdk';

@Injectable()
export class CognitoService {

  private userPool: CognitoUserPool;
  private poolData: any;
  public cognitoCreds: AWS.CognitoIdentityCredentials;

  constructor() {
    AWS.config.region = environment.region;
    this.poolData = { UserPoolId: environment.userPoolId, ClientId: environment.clientId };
    this.userPool = new CognitoUserPool(this.poolData);
  }

  //ログイン処理
  login(username: string, password: string): Promise<any> {
    const userData = {
      Username: username,
      Pool: this.userPool,
      Storage: localStorage
    };
    const cognitoUser = new CognitoUser(userData);
    const authenticationData = {
      Username: username,
      Password: password,
    };
    const authenticationDetails = new AuthenticationDetails(authenticationData);
    return new Promise((resolve, reject) => {
      cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess: function (result) {
          alert('LogIn is success!');
          console.log('id token + ' + result.getIdToken().getJwtToken());
          console.log('access token + ' + result.getAccessToken().getJwtToken());
          console.log('refresh token + ' + result.getRefreshToken().getToken());
          resolve(result);
        },
        onFailure: function (err) {
          alert(err);
          console.log(err);
          reject(err);
        },
        newPasswordRequired: function (userAttributes, requiredAttributes) {
          delete userAttributes.email_verified;
          delete userAttributes.phone_number_verified;
          cognitoUser.completeNewPasswordChallenge(newpassword, userAttributes, this);
        }
      });
    });
  }

  //ログイン済確認処理
  isAuthenticated(): Promise<any> {
    const cognitoUser = this.userPool.getCurrentUser();
    return new Promise((resolve, reject) => {
      if (cognitoUser === null) { reject(cognitoUser); }
      cognitoUser.getSession((err, session) => {
        if (err) {
          reject(err);
        } else {
          if (!session.isValid()) {
            reject(session);
          } else {
            resolve(session);
          }
        }
      });
    });
  }
 
  //トークン取得処理
  getCurrentUserIdToken(): any {
    const cognitoUser = this.userPool.getCurrentUser();
    if (cognitoUser != null) {
      cognitoUser.getSession(function (err, session) {
        if (err) {
          alert(err);
          return;
          } else {
          return session.getIdToken().getJwtToken();
          }
        });
      }
    }
    
  //ログイン処理
  logout() {
    console.log('LogOut!');
    const currentUser = this.userPool.getCurrentUser();
    if (currentUser) {
      currentUser.signOut();
    }
  }
}

ルーティングの実装

どのような流れになるか想像しながら、実装していきます。

app-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { PetlistComponent } from './petlist/petlist.component';
import { LoginComponent } from './login/login.component';
const routes: Routes = [
  { path: '', redirectTo: '/login', pathMatch: 'full' },
  { path: 'petlist', component: PetlistComponent },
  {
    path: 'login',
    component: LoginComponent,
    pathMatch: 'full'
  }
];

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

Petリスト取得処理の実装

headerにAuthorizationを付与するために、「HttpInterceptor」を使っています。

service/pet.service.ts

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';

import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { catchError, map, tap } from 'rxjs/operators';

import { Pet } from '../pet';
import { CognitoService } from './cognito.service';

const httpOptions = {
  headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};

@Injectable()
export class PetService {

  private Url = 'https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/test/pets';  // URL to web api

  constructor(
    private http: HttpClient,
    private cognito: CognitoService) { }

  /** GET pets from the server */
  getPets(): Observable<Pet[]> {
    return this.http.get<Pet[]>(this.Url);
  }
}

@Injectable()
export class NoopInterceptor implements HttpInterceptor {
  constructor(private cognito: CognitoService) { }
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // サービスから認証ヘッダーを取得します。
    const authHeader = this.cognito.getCurrentUserIdToken();
    // 新しいヘッダーを加えたリクエストを複製します。
    const authReq = req.clone({ headers: req.headers.set('Authorization', authHeader) });
    // オリジナルのリクエストの代わりに複製したリクエストを投げます。
    return next.handle(authReq);
  }
}

上のやり方はすこしクセがあるのではじめはHttpHeadersに追加する形がいいと思います。

例えば、下記のような形でもリクエストできるかと思います。

  getPets(token: string): Observable<any> {
    const httpOptions = {
      headers: new HttpHeaders({ 'Authorization': token })
    };
    return this.http.get<Pet[]>>(this.Url, httpOptions);

コンポーネントの実装

特に凝ったことはしていないので、下記リポジトリを参照いただければと思います。

https://github.com/nishimura-yuji/angular-cognito-test

モジュール設定

今回、HTTP_INTERCEPTORSを使って、HTTPリクエストするときにヘッダーにAuthorizationを付与しています。
下記、ハイライト箇所がHTTP_INTERCEPTORSを利用するために追記が必要となります。

app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { HTTP_INTERCEPTORS } from '@angular/common/http';

import { NoopInterceptor } from './service/pet.service';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { PetlistComponent } from './petlist/petlist.component';
import { DashboardComponent } from './dashboard/dashboard.component';

// add service
import { CognitoService } from './service/cognito.service';
import { PetService } from './service/pet.service';

@NgModule({
  declarations: [
    AppComponent,
    LoginComponent,
    PetlistComponent,
    DashboardComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    AppRoutingModule,
    HttpClientModule,
    ReactiveFormsModule
  ],
  providers: [{
    provide: HTTP_INTERCEPTORS,
    useClass: NoopInterceptor,
    multi: true,
  },
  CognitoService, PetService],
  bootstrap: [AppComponent]
})
export class AppModule { }

動作確認

ng serverで作成したSPAを確認していきます。
きちんとログインできること、API Gatewayを経由してPetリストが取得できていること、
また、ログアウト後に、Petリストのリンクをクリックしても、Petリストのページにはいかず、
ログイン画面のままであることがわかります。

さいごに

いかがだったでしょうか。

Angular+Cognitoでログインページを作成してみました。
さらに、API GatewayにもCognito認証をかけてみました。

簡単にできるかなと思っていましたが、Angularでめちゃくちゃ苦戦しました。
しかし、想定通りの動作をしたときの達成感はひとしおでした。

誰かの参考になれば幸いです。