これはServerless Advent Calendar 2018の15日目です。
インフラ構築、Backend API、Frontend SPAと実装してきたサーバレスWebアプリのサンプルにAWS AmplifyとAWS API Gateway Lambda Authorizerを使ってCognitoユーザ認証を組み込んでみました。
今回のコード
それぞれ記事時点のコードにタグを打ってあります。
- GitHub - nihemak/aws-sls-spa-sample-web at v0.0.2
- GitHub - nihemak/aws-sls-spa-sample-api at v0.0.3
- GitHub - nihemak/aws-sls-spa-sample-terraform at v0.0.3
実装の概要
詳細はGitHubにコードがありますのでここでは概要だけ記していきます。
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_ID
とUSER_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 } ],
AuthService
とMockAuthService
の内容は下記です。共にinterfaceのIAuthService
をimplementsします。
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
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を取得できるようにします。
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テーブル操作するようにします。
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を追加します。
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認証できるようにしてみました。色々と突貫で付け足したのでもろもろリファクタリングしたい気持ちです。