Angular+Serverless Framework+AWSな構成のサーバレスWebアプリにAmplify+API Gateway Lambda AuthorizerでCognitoユーザ認証を組み込んでみた

これはServerless Advent Calendar 2018の15日目です。

インフラ構築Backend APIFrontend SPAと実装してきたサーバレスWebアプリのサンプルにAWS AmplifyAWS API Gateway Lambda Authorizerを使ってCognitoユーザ認証を組み込んでみました。

今回のコード

それぞれ記事時点のコードにタグを打ってあります。

実装の概要

詳細はGitHubにコードがありますのでここでは概要だけ記していきます。

f:id:nihma:20181215143641p:plain

f:id:nihma:20181215162102p:plain

Frontend SPA

AngularでAWS Amplifyを導入してSignUp/SignIn/SignOutページの作成とTodo操作のログイン必須化対応、APIリクエストへ認証トークン追加を行いました。 参考にした記事や情報は下記あたりです。

dev.classmethod.jp dev.classmethod.jp dev.classmethod.jp

今回はローカルのDocker環境を外部サービスに依存せずスタンドアロンでも動作できるようにしたいと思いました。*1 そのため環境変数USER_POOL_IDで指定するCognitoプールIDの内容により認証スキップできるようにしました。スキップしたい時の指定はDummyとしました。

Cognito認証のためのAmplify設定

まず環境変数USER_POOL_IDUSER_POOL_CLIENT_IDを追加します。*2

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/dockers/docker-compose.yml#L10

    environment:
...
      USER_POOL_ID: 'Dummy'
      USER_POOL_CLIENT_ID: 'Dummy'

そしてenvironment.tsにAmplifyの設定を追加します。environment.tsをデプロイ時に動的生成するpre_build.shです。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/bin/pre_build.sh#L21

  amplify: {
    Auth: {
      region: 'ap-northeast-1',
      userPoolId: '${USER_POOL_ID}',
      userPoolWebClientId: '${USER_POOL_CLIENT_ID}'
    }
  },
...
  localstorageBaseKey: 'CognitoIdentityServiceProvider.${USER_POOL_CLIENT_ID}.'

Cognito認証するAuthServiceと認証しないMockAuthServiceの切り替えはAppModuleのprovidersで行います。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/app.module.ts#L72

  providers: [
    MockWebApiService,
    {
      provide: AuthService,
      useClass: environment.amplify.Auth.userPoolId === 'Dummy' ? MockAuthService : AuthService
    }
  ],

AuthServiceMockAuthServiceの内容は下記です。共にinterfaceのIAuthServiceをimplementsします。

f:id:nihma:20181214224717p:plain

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/auth/auth.service.ts

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import Amplify, { Auth } from 'aws-amplify';
import { environment } from './../../environments/environment';

export interface IAuthService {
  signUp(email: string, password: string): Observable<any>;
  confirmSignUp(email: string, code: string): Observable<any>;
  signIn(email: string, password: string): Observable<any>;
  getData(): Observable<any>;
  getIdToken(): Promise<string>;
  isAuthenticated(): Observable<boolean>;
  signOut(): void;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService implements IAuthService {
  public loggedIn: BehaviorSubject<boolean>;
  password!: String;

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

  public signUp(email: string, password: string): Observable<any> {
    this.password = password;

    return from(Auth.signUp(email, password, email));
  }

  public confirmSignUp(email: string, code: string): Observable<any> {
    return from(Auth.confirmSignUp(email, code));
  }

  public signIn(email: string, password: string): Observable<any> {
    return from(Auth.signIn(email, password))
      .pipe(
        tap(() => this.loggedIn.next(true))
      );
  }

  public getData(): Observable<any> {
    return from(Auth.currentAuthenticatedUser());
  }

  public getIdToken(): Promise<string> {
    return Auth.currentSession()
      .then(session => {
        return session.getIdToken()
          .getJwtToken();
      });
  }

  public isAuthenticated(): Observable<boolean> {
    return from(Auth.currentAuthenticatedUser())
      .pipe(
        map(_result => {
          this.loggedIn.next(true);

          return true;
        }),
        catchError(_error => {
          this.loggedIn.next(false);

          return of(false);
        })
      );
  }

  public signOut(): void {
    from(Auth.signOut())
      .subscribe(
        _result => {
          this.loggedIn.next(false);
          this.router.navigate(['/login']);
        },
        error => console.log(error)
      );
  }
}

@Injectable({
  providedIn: 'root'
})
export class MockAuthService implements IAuthService {
  public loggedIn: BehaviorSubject<boolean>;
  password!: String;
  idSignIn = false;

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

  public signUp(_email: string, password: string): Observable<any> {
    this.password = password;

    return of(true);
  }

  public confirmSignUp(_email: string, _code: string): Observable<any> {
    return of(true);
  }

  public signIn(_email: string, _password: string): Observable<any> {
    this.idSignIn = true;

    return of([])
      .pipe(
        tap(() => this.loggedIn.next(true))
      );
  }

  public getData(): Observable<any> {
    return of([]);
  }

  public getIdToken(): Promise<string> {
    return Promise.resolve('dummyToken');
  }

  public isAuthenticated(): Observable<boolean> {
    this.loggedIn.next(this.idSignIn);

    return of(this.idSignIn);
  }

  public signOut(): void {
    this.idSignIn = false;
    this.loggedIn.next(this.idSignIn);
    this.router.navigate(['/login']);
  }
}

SignUp/SignIn/SignOutページ

参考にした記事とほぼ同じです。

https://github.com/nihemak/aws-sls-spa-sample-web/tree/v0.0.2/src/app/component/signup https://github.com/nihemak/aws-sls-spa-sample-web/tree/v0.0.2/src/app/component/login

Todoのログイン前提化

Todoの操作をログイン前提にするためのAuthGuardは下記です。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/auth/auth.guard.ts

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AuthService } from './auth.service';

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

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

これを使ってTodosComponentでガードを行います。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/app-routing.module.ts#L8

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

APIリクエストに認証トークンを指定

あとはTodoServiceでAPIリクエストに認証トークンを渡すためにhttpヘッダにAuthorizationを追加しました。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/todo.service.ts#L18

      headers: new HttpHeaders({
        'Content-Type': 'application/json',
        Authorization: token
      })

そして、ngrxでトークンを引き継ぐためにActionのpayloadにトークンを追加します。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/store/todo.ts#L29

export class FindAll implements TodoAction {
  readonly type = FIND_ALL;
  constructor(public payload: { token: string }) {}
}

TodosComponentでngrxのStoreをdispatchしている箇所をActionのpayload経由でトークン指定するよう変更します。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/todos/todos.component.ts#L35

  getTodos(): void {
    this.authService.getIdToken()
      .then(token => {
        this.store.dispatch(new TodoFindAll({ token }));
      });
  }

あとはEffectでActionのpayloadからトークンを取り出してTodoServiceに指定するようにします。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/store/todo.effect.ts#L40

    switchMap(action => {
      const { token } = action.payload;

      return this.todoService
        .getTodos(token)
        .pipe(
          map(todos => new FindAllSuccess({ todos })),
          catchError(error => of(new FindAllFail({ error })))
        );
    })

これでSignUpしてSignInし自分のTodoをCRUDできるようになりました。

Backend API

Serverless FrameworkでAPI Gateway Lambda Authorizerを使用してCognito認証とTodoのユーザ個別化の実装を行いました。 参考にした記事や情報は下記あたりです。

serverless.com docs.aws.amazon.com github.com Cognito JSON ウェブキートークンの署名を復号して検証する docs.aws.amazon.com github.com

Frontendと同様、BackendもローカルのDocker環境を外部サービスに依存せずスタンドアロンでも動作できるようにしたいと思いました。 具体的にはLambda Authorizerの中で環境変数USER_POOL_IDに指定するCognitoプールIDの内容を判断して認証スキップできるようにしました。これを実現したかったことがauthorizerにCognitoのarnを指定するのではなくLambda Authorizerにした理由です。スキップしたい時の指定はDummyとします。

Cognito認証のためのLambda Authorizer

まず環境変数USER_POOL_IDを追加します。*3

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/dockers/docker-compose.yml#L19

    environment:
...
      USER_POOL_ID: 'Dummy'

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/serverless.yml#L19

  environment:
 ...
    USER_POOL_ID: ${env:USER_POOL_ID}

そしてserverless.ymlでLambda Authorizerにauth.authorizeを指定します。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/serverless.yml#L25

functions:
  authorizer:
    handler: app/http/controllers/auth.authorize

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/serverless.yml#L42

  list:
...
    events:
      - http:
...
          authorizer: authorizer

auth.authorizeは下記です。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/http/controllers/auth.ts

// https://github.com/serverless/examples/tree/master/aws-node-auth0-cognito-custom-authorizers-api
// https://github.com/awslabs/aws-apigateway-lambda-authorizer-blueprints/blob/master/blueprints/nodejs/index.js
import jwk from "jsonwebtoken";
import request from "request";
import jwkToPem from "jwk-to-pem";

const region = process.env.REGION;
const userPoolId = process.env.USER_POOL_ID;
const iss = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;

// Generate policy to allow this user on this API:
const generatePolicy = (principalId: any, resource: any) => {
  const tmp = resource.split(":");
  const apiGatewayArnTmp = tmp[5].split("/");
  const awsAccountId = tmp[4];
  const region = tmp[3];
  const restApiId = apiGatewayArnTmp[0];
  const stage = apiGatewayArnTmp[1];
  const resourceArn = `arn:aws:execute-api:${region}:${awsAccountId}:${restApiId}/${stage}/*/*`;

  const policy = {
    principalId,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: "Allow",
          Resource: [resourceArn]
        }
      ]
    }
  };

  return policy;
};

export function authorize(event: any, _context: any, cb: any) {
  console.log("Auth function invoked");

  if (userPoolId === "Dummy") {
    console.log("Auth function dummy mode");
    cb(null, generatePolicy("dummySub", event.methodArn));
    return;
  }

  if (event.authorizationToken) {
    const token = event.authorizationToken;
    // Make a request to the iss + .well-known/jwks.json URL:
    request(
      { url: `${iss}/.well-known/jwks.json`, json: true },
      (error, response, body) => {
        if (error || response.statusCode !== 200) {
          console.log("Request error:", error);
          cb("Unauthorized");
        }
        const keys = body;
        // Based on the JSON of `jwks` create a Pem:
        const k = keys.keys[0];
        const jwkArray = {
          kty: k.kty,
          n: k.n,
          e: k.e
        };
        const pem = jwkToPem(jwkArray);

        // Verify the token:
        jwk.verify(token, pem, { issuer: iss }, (err, decoded: any) => {
          if (err) {
            console.log("Unauthorized user:", err.message);
            cb("Unauthorized");
          } else {
            cb(null, generatePolicy(decoded.sub, event.methodArn));
          }
        });
      }
    );
  } else {
    console.log("No authorizationToken found in the header.");
    cb("Unauthorized");
  }
}

TodosテーブルにuserId追加

Todoを各ユーザの所有物にするためにTodosテーブルにuserIdカラムを追加します。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/databases/Todos.ts#L35

@table("todos")
export class TodoRecord {
...
  @attribute()
  userId!: string;
...
}

userIdカラムでフィルタリングしてクエリするためにGSIとしてuserIdIdxを追加します。ここではDynamoDB Local用の設定を行います。*4

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/databases/Todos.ts#L121

    const params = {
      AttributeDefinitions: [
        {
          AttributeName: "userId",
          AttributeType: "S"
        },
        {
          AttributeName: "createdAt",
          AttributeType: "N"
        }
      ],
      GlobalSecondaryIndexUpdates: [
        {
          Create: {
            IndexName: "userIdIdx",
            KeySchema: [
              {
                AttributeName: "userId",
                KeyType: "HASH"
              },
              {
                AttributeName: "createdAt",
                KeyType: "RANGE"
              }
            ],
            Projection: {
              ProjectionType: "ALL"
            },
            ProvisionedThroughput: {
              ReadCapacityUnits: 1,
              WriteCapacityUnits: 1
            }
          }
        }
      ],
      TableName: tableName
    };
    dynamodb.updateTable(params, function(err) {
      if (err) {
        throw err;
      }
    });

Todo操作の個別ユーザ化

httpリクエストからuserIdを取得できるようにします。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/adapters/http/requests/Todos.ts#L10

class TodoInput {
  protected _getAuthUserId(headers: { [name: string]: string }): string {
    let userId = headers["Authorization"];
    if (process.env.USER_POOL_ID !== "Dummy") {
      const decodedJwt: any = jwk.decode(userId, { complete: true });
      userId = decodedJwt.payload.sub;
    }
    return userId;
  }
}

そして取得したuserIdを使ってusecaseでTodosテーブル操作するようにします。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/usecases/implementations/Todos.ts#L45

  public async list(
    input: TodoListInput,
    output: TodoListOutput
  ): Promise<void> {
    const todos: Todo[] = await this.store.all(input.getAuthUserId());
    output.success(todos);
  }

これでCognito認証済みの場合のみユーザごとのTodoを操作できるようになりました。

インフラ構築

Backend APIにCognito指定

Backend APIをserverless deployするCodeBuildの環境変数にCognitoプールIDを追加します。

https://github.com/nihemak/aws-sls-spa-sample-terraform/blob/v0.0.3/modules/codebuild/api/production/main.tf#L49

resource "aws_codebuild_project" "api" {
...
    environment_variable {
      "name"  = "USER_POOL_ID"
      "value" = "${var.cognito_pool_id}"
    }

TodosテーブルにGSIのuserIdIdxを追加

DynamoDBのTodosテーブルにGSIのuserIdIdxを追加します。

https://github.com/nihemak/aws-sls-spa-sample-terraform/blob/v0.0.3/modules/dynamodb/main.tf

resource "aws_dynamodb_table" "todos" {
...
  attribute {
    name = "userId"
    type = "S"
  }

  attribute {
    name = "createdAt"
    type = "N"
  }

  global_secondary_index {
    name               = "userIdIdx"
    hash_key           = "userId"
    range_key          = "createdAt"
    write_capacity     = 1
    read_capacity      = 1
    projection_type    = "ALL"
  }

これでAWS環境で動作するようになりました。

まとめ

今回はサーバレスWebアプリのサンプルにAmplifyとAPI Gateway Lambda Authorizerを追加してCognito認証できるようにしてみました。色々と突貫で付け足したのでもろもろリファクタリングしたい気持ちです。

*1:依存してると簡単に動かして試したい時とかテストとかで煩わしいので

*2:ここの値を実際のCognitoの値にすればCognito認証を行えます。

*3:ここの値を実際のCognitoの値にすればCognito認証を行えます。

*4:実際のTodosテーブルのスキーマ変更はインフラ構築の実装で行います。