【Angularユーザーのための認証API自作講座①】AWS Cognitoでセキュアなユーザー認証を自力で構築する


※ 当ページには【広告/PR】を含む場合があります。
2021/06/24
2022/08/10
【Angularユーザーのための認証API自作講座②】Serverless FrameworkでCognitoオーソライザー付きRestAPIを構築する
蛸壺の技術ブログ|AWS Cognitoでセキュアなユーザー認証を自力で構築する

認証ありのECサイトを自分の手で一から構築するのは中々に骨の折れる作業で、Auth0などのサードパーティ製のAPI方式認証サービスを使うほうが圧倒的にハードルが低くなります。

とはいえ、サイトの規模が大きくなってくるとサービスの利用料がエンタープライズ版への移行が望ましく、それなりの維持費用が発生してくるようになります。

そこで今回は、管理費を圧迫しつつあったAuth0のようなユーザー認証管理サービスの利用を諦めて、独自のユーザー認証システムを一念発起してCognitoへマイグレーションする際に行うべき作業をまとめてみました。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Auth0の料金が割高に感じた時のCognito

Auth0の料金プランに関してはこちらで言及されているように、B2Cタイプの有料プランでみると、1万MAU(月間アクティブユーザー)まではEssential($0.023/MAU)Professional($0.24/MAU)で利用できます。

それ以上のMAUではEnterpriseプランに移行する必要があります。

仮にEssentialプランで月1万MAUが利用した料金は
$228で、大体2.5万円程度の料金になります。

対して、
Cognitoの料金では、5万MAUまで(無料期間過ぎても)無料で、5万超えると請求が来る方式になるようです。

月間ユーザ数/MAU

MAU単価

0〜5万

無料

5〜10万

$0.00550

10〜100万

$0.00460

100〜1000万

$0.00325

1000万以上

$0.00250

例えば、7万MAUであった月の請求額は、

            
            $0.00550 * (70000 - 50000) = $110 ~ 12000円程度
        
であり、料金的な面だけ切り取って見れば、CognitoはAuth0と比べて圧倒的に安いです。

もちろんCognito自体はユーザー管理の基本機能しかなく、他の必要なマイクロサービスも自作しないといけませんので、システムの構築をする労力を考えるとAuth0を使い続ける方が楽ではあるのですが、あとは使う側の予算と相談かと思います。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Cognitoオーソライザ付きのAPI Gatewayによるユーザー認証

API Gatewayには内部でユーザー認証を行うためのオーソライザーを追加して利用することができます。このオーソライザーにはLambdaタイプか、Cognitoタイプが指定して利用できます。

前述したAuth0などのようなサードパーティ製のユーザー認証APIなどを指定する場合にはLambdaタイプでカスタムオーソライザーを作成して使うことになりますが、AWSネイティブのCognitoは別に区別されているようです。

ということで、今回はCognitoオーソライザの話に限定したお話をしていきます。この構成を組んで何が嬉しいかというと、以下の概念図のようにAPIがクライアントからのリクエストヘッダーの情報を元にユーザー管理ができるバックエンドが簡単に構築できるわけです。

合同会社タコスキングダム|蛸壺の技術ブログ

さて、
公式の手順手引きを参考に、

            
            1. Cognitoコンソール > ユーザープールを作成
2. API Gatewayコンソール > ユーザープールからAPI Gatewayオーソライザーを作成
3. API Gatewayコンソール > 選択したAPIメソッドでオーソライザーを有効にする
4. ユーザープールを有効化したAPIメソッドを呼び出すために、APIクライアントで次のタスクを実行:
    4.1. AWS CLIまたはAPIを使用して、ユーザープールにユーザーをサインインさせ、ID・アクセストークンを取得
    4.2. クライアントから、デプロイされたAPI Gateway APIを呼び出して、Authorizationヘッダーに適切なトークンを指定する
        
という内容で順次説明していきます。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Cognitoの設定手順

まずは手動でCognitoのダッシュボードからユーザー管理サービスをセットアップしていきます。

Cognitoでユーザープールの作成

ユーザープールはWebで展開しているサービスにログインするユーザーを管理するための顧客帳簿のようなものです。

まずはCognitoのダッシュボードのトップから
[ユーザープールの管理] > [ユーザープールを作成する]で新規ユーザープールを作成します。

合同会社タコスキングダム|蛸壺の技術ブログ

ここではプール名を
testusersにして話を進めます。

とりあえず新規作成するプールはデフォルト設定のまま生成しますので、
[デフォルトを確認する] > [プールの作成]で作業を進めます。

ユーザープールが作成されると以下の画面がでますので、

合同会社タコスキングダム|蛸壺の技術ブログ

新規作成したプールID値とARN値を何処かに控えておきましょう。

次にアプリクライアントを追加してみます。

先程のユーザープールで、左側のペインから
[アプリクライアント] > [アプリクライアントの追加]に進んでアプリクライアントを設定します。今回はアプリクライアント名は適当にmyAuthAppにしておきます。

合同会社タコスキングダム|蛸壺の技術ブログ

動作テスト程度ですのでクライアントシークレット機能は不要として、
クライアントシークレットを生成のチェックを外しておきます。

Cognitoによる認証にはIDトークンを使用したいので、
認証用の管理 API のユーザー名パスワード認証を有効にするにチェックを入れておきましょう。

他はデフォルトのまま利用します。

設定が終わると、
[アプリクライアントの作成]からアプリクライアントが新規作成され、アプリクライアントIDが付与されます。

ユーザープールにユーザーを追加する

まだこのままではプールに一人もユーザーを登録していないので、動作確認用に適当に一つユーザーを追加してみます。

対象のユーザープールの左側メニューから
[ユーザーとグループ] > [ユーザー]タブ > [ユーザーの作成]に進みます。

合同会社タコスキングダム|蛸壺の技術ブログ

ここで適当な名前でユーザー・
tacokin-testで登録してみることにします。ユーザーの登録には、ユーザー名の他に、仮パスワードとユーザーのサインアップ確認用の電話番号Eメールが必要です。

今回はEメールでの認証にして、
Eメールを検証済みにしましすか?にチェックを入れて、メールアドレス自体はホワイトリスト登録にしておきます。

また、
この新規ユーザーに招待を送信しますか?にチェックを入れると、仮パスワードが登録したEメールに送信されます。特に仮パスワードは送信するほどのことではないのですが、送信テストを行いたい場合にはチェックを付けておきます。

さて、このままでは肝心のログインシステムができていない状態で、後段の作業に何かと都合が悪い状態です。

そこで、動作確認だけの救済措置として、
こちらにaws cliによるCognito操作でユーザー登録を完了する方法が紹介されています。

AWS-CLIのインストール方法はここでは割愛させていただきますが、
cognito-idpコマンドを使ったサインインを試してみましょう。

まずはちゃんとユーザープールができているかを確認するために、
list-usersサブコマンドを試してみます。

            
            $ aws cognito-idp list-users --user-pool-id [ユーザープールID]
{
    "Users": [
        {
            "Username": "tacokin-test",
            "Attributes": [
                {
                    "Name": "sub",
                    "Value": "****************************"
                },
                {
                    "Name": "email_verified",
                    "Value": "true"
                },
                {
                    "Name": "email",
                    "Value": "****************************"
                }
            ],
            "UserCreateDate": xxxxxxxxxxxxxxxxx,
            "UserLastModifiedDate": xxxxxxxxxxxxxxxxx,
            "Enabled": true,
            "UserStatus": "FORCE_CHANGE_PASSWORD"
        }
    ]
}
        
というレスポンスが返っていれば正しく認識されています。

もし、

            
            $ aws cognito-idp list-users --user-pool-id [ユーザープールID]
An error occurred (ResourceNotFoundException) when calling the ListUsers operation: User pool ap-northeast-1_******* does not exist.
        
というエラーが発生する場合には、現在のCLIで指定しているリージョン名が間違っている場合があります。この例でいうと、AWS_DEFAULT_REGION="ap-northeast-1"になっているかを確認してみてください。

では、
admin-initiate-authサブコマンドで、現在のCognitoのセッション情報を取得してみます。

            
            $ aws cognito-idp admin-initiate-auth \
    --user-pool-id [ユーザープールID値] \
    --client-id [アプリクライアントID] \
    --auth-flow ADMIN_NO_SRP_AUTH \
    --auth-parameters USERNAME=tacokin-test,PASSWORD=[仮パスワード]
#👇レスポンス
{
    "ChallengeName": "NEW_PASSWORD_REQUIRED",
    "Session": "A...中略...Y",
    "ChallengeParameters": {
        "USER_ID_FOR_SRP": "tacokin-test",
        "requiredAttributes": "[]",
        "userAttributes": "{\"email_verified\":\"true\",\"email\":\"**************\"}"
    }
}
        
※パスワードは仮パスワードで置き換えてお読みください。

ここで、
--user-pool-idオプションには上節で設定したユーザープールID値を、--client-idオプションにはアプリクライアントIDを指定します。

返ってきたJSONレスポンスをみると、
NEW_PASSWORD_REQUIRED(正規パスワード要求)のSessionが開かれていることが分かります。

ではこのセッション値を使って、
admin-respond-to-auth-challengeサブコマンドから新しいパスワードに再設定してみます。

            
            $ aws cognito-idp admin-respond-to-auth-challenge \
    --user-pool-id [ユーザープールID] \
    --client-id [アプリクライアントID] \
    --challenge-name NEW_PASSWORD_REQUIRED \
    --challenge-responses 'NEW_PASSWORD=[新しいパスワード],USERNAME=tacokin-test' \
    --session [先程のセッション値]
#👇成功した際のレスポンス
{
    "ChallengeParameters": {},
    "AuthenticationResult": {
        "AccessToken": "e...中略...A",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "e...中略...A",
        "IdToken": "e...中略...g"
    }
}
        
※セッションは数分で有効期限切れになりますので、切れたら再び新しいセッションでやり直す必要があります。

パスワードの再設定が終わると、各種トークンがサーバから返されます。なおデフォルトでは60分間ログイン状態が保持されるようです。

再びユーザーの状態を確認してみますと、

            
            $ aws cognito-idp list-users --user-pool-id [ユーザープールID]
{
    "Users": [
        {
            "Username": "tacokin-test",
            "Attributes": [
            #...中略
            #👇ユーザー登録が完了している
            "UserStatus": "CONFIRMED"
        }
    ]
}
        
のようになれば準備が完了です。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

API Gatewayの設定

ここではもっとも簡単なAPIの例であるPetStoreをインポートして、先程のCognitoをオーソライザとして設定する手順を説明していきます。

API Gatewayの作成

Cognitoオーソライザーを試してみるためのAPIGatewayインスタンスをダッシュボードから新規作成していきます。

まず先程設定したCognitoと同じリージョンであることを確認してから、API Gatewayダッシュボードのトップに移動し、
[APIを作成] > [APIタイプを選択] > [インポート] > プロトコルを選択: [REST] > 新しいAPIの作成: [APIの例]から代表的なREST APIの例であるPetStoreが生成されます。

合同会社タコスキングダム|蛸壺の技術ブログ

このサンプルはGETとPOSTが簡単に学習できる単純なものですが、そのままデプロイして使ってみるのにはうってつけです。

デプロイは
[リソース] > ルートのリソースを選択 > [アクション] > [APIのデプロイ]の順に進めます。

合同会社タコスキングダム|蛸壺の技術ブログ

デプロイにはステージが必要ですので、ここでは開発ステージということで
devにしておきます。APIを生成すると、APIエンドポイントが与えられました。

できたばかりのAPIエンドポイントを以下Curlで叩いてみますと、

            
            $ curl --include https://*************.execute-api.ap-northeast-1.amazonaws.com/dev
HTTP/2 200
date: Wed, 23 Jun 2021 08:54:55 GMT
content-type: text/html
content-length: 1308
x-amzn-requestid: ac9f2768-fadb-4c1c-b193-caede1078e14
x-amz-apigw-id: BXuC_EKmtjMFg0g=
<html>
    <head>
        <style>
        body {
            color: #333;
            font-family: Sans-serif;
            max-width: 800px;
            margin: auto;
        }
        </style>
    </head>
    <body>
        <h1>Welcome to your Pet Store API</h1>
        <p>
            You have successfully deployed your first API. You are seeing this HTML page because the <code>GET</code> method to the root resource of your API returns this content as a Mock integration.
        </p>
        <p>
            The Pet Store API contains the <code>/pets</code> and <code>/pets/{petId}</code> resources. By making a <a href="/dev/pets/" target="_blank"><code>GET</code> request</a> to <code>/pets</code> you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a <a href="/dev/pets/1" target="_blank"><code>GET</code> request</a> to <code>/pets/1</code>.
        </p>
        <p>
            You can use a REST client such as <a href="https://www.getpostman.com/" target="_blank">Postman</a> to test the <code>POST</code> methods in your API to create a new pet. Use the sample body below to send the <code>POST</code> request:
        </p>
        <pre>
{
    "type" : "cat",
    "price" : 123.11
}
        </pre>
    </body>
</html>
        
きちんとしたコンテンツが返されたら成功です。

Cognitoオーソライザの設定

ここからはいよいよ本題であった
CognitoオーソライザのAPI Gatewayの設定手順を見ていきます。

API GatewayのダッシュボードからAPIを選択肢、
[オーソライザー] > [新しいオーソライザーの作成]をクリックします。

合同会社タコスキングダム|蛸壺の技術ブログ

すると
オーソライザーの作成ダイアログが出ますので、ここでは名前をmyCognitoAuthorizer、タイプはCognitoに指定します。

Cognitoユーザープールには上の節で設定した
testusersユーザープールが候補として見えているはずですので、これを選択しておきます。

またリクエストヘッダの
Authorizationに各認証トークンが付与されますので忘れずに設定しておきます。

以上を確認し、
[作成]を押すと、Cognitoオーソライザーが作成されます。

オーソライザーの有効化

オーソライザーを作成しただけではAPIの何処のメソッドにも紐付いていないため、そのままですと無効の状態です。

手始めにルート(
/)のGETメソッドにCognitoオーソライザーを有効化させてみます。

            
            /
  GET # 👈このメソッドにオーソライザを設定
  /pets
    GET
    OPTIONS
    POST
    /{petId}
      GET
      OPTIONS
        
まず対象のAPI Gatewayインスタンスから[リソース] > '/'以下のGETを選択 > [メソッドの実行]ブロック > [メソッドリクエスト] > 許可: [myCognitoAuthorizer]を設定します。

合同会社タコスキングダム|蛸壺の技術ブログ

設定を反映させると、メソッドリクエストの許可に上節で作成したCognitoオーソライザーが設定されていればOKです。

設定変更したリソースは再びデプロイすることで、このオーソライザーが有効化できていると思います。

確認のためAPIエンドポイントにアクセスすると、

            
            $ curl --include https://**************.execute-api.ap-northeast-1.amazonaws.com/dev
HTTP/2 401
date: Wed, 23 Jun 2021 14:42:22 GMT
content-type: application/json
content-length: 26
x-amzn-requestid: 3adfb5c1-d2a0-457b-bd7a-057cef8ec6dd
x-amzn-errortype: UnauthorizedException
x-amz-apigw-id: BYg8RHKTtjMFpZA=
{"message":"Unauthorized"}
        
という感じにトークンなしでは弾かれるようになりました。

ここで注意が必要なのは、ルート(
/)は弾いてくれたので、それより深い階層もアクセス制限されているだろうと思って、試しに/petsにアクセスしてみますと、

            
            % curl --include https://*******.execute-api.ap-northeast-1.amazonaws.com/dev/pets
HTTP/2 200
date: Wed, 23 Jun 2021 14:45:05 GMT
content-type: application/json
content-length: 184
x-amzn-requestid: 154ff613-b414-43fd-9f5e-18529a369a3e
access-control-allow-origin: *
x-amz-apigw-id: BYhVuHswNjMFUQA=
x-amzn-trace-id: Root=1-60d348f1-1ac19a8e65df38a428e2d095
[
  {
    "id": 1,
    "type": "dog",
    "price": 249.99
  },
  {
    "id": 2,
    "type": "cat",
    "price": 124.99
  },
  {
    "id": 3,
    "type": "fish",
    "price": 0.99
  }
]
        
...ハイ、余裕でGETできる状態です。ということでオーソライザーはアクセス制限したい全てのメソッドに個別に設定しないといけません。

もしサイトの全てのページにユーザー認証を入れたい場合には、この例でいくと、

            
            /
  GET # 👈このメソッドにオーソライザを設定
  /pets
    GET # 👈このメソッドにオーソライザを設定
    OPTIONS
    POST # 👈このメソッドにオーソライザを設定
    /{petId}
      GET # 👈このメソッドにオーソライザを設定
      OPTIONS
        
とうふうに全てのGETとPOST等にオーソライザーを仕込まないといけないことに注意してください。

ユーザーのサインインとトークンの取得

正式なログイン機能を作っていない段階ですので、まずは
cognito-idp admin-initiate-authコマンドからIDトークンを取得してみます。

            
            $ aws cognito-idp admin-initiate-auth \
    --user-pool-id '[ユーザープールID値]' \
    --client-id '[アプリクライアントID]' \
    --auth-flow ADMIN_NO_SRP_AUTH \
    --auth-parameters 'USERNAME=tacokin-test,PASSWORD=[ユーザーパスワード]'
#👇有効期限付き各種トークンがレスポンスとなる
{
    "ChallengeParameters": {},
    "AuthenticationResult": {
        "AccessToken": "ey...Q",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "ey...A",
        "IdToken": "ey...Q"
    }
}
        
この生レスポンスからIdTokenの値を利用して、Curlのリクエストヘッダのうち、Authorizationに直接仕込んで再度アクセスを試みます。

            
            $ curl --include https://*******.execute-api.ap-northeast-1.amazonaws.com/dev \
    -H 'Authorization:[IDトークン]'
#👇きちんとログイン出来ている(レスポンス200OK)
HTTP/2 200
date: Wed, 23 Jun 2021 16:24:31 GMT
content-type: text/html
content-length: 1308
x-amzn-requestid: d5fc257f-513c-4c2d-abae-ff5d662d53d9
x-amz-apigw-id: BYv54ERHNjMF6IA=
<html>
    ...中略
    </body>
</html>
        
これでCognitoユーザープールを使ってAPI Gatewayにユーザ認証の機能を付与することが出来ました。

ついでにトークンがない場合のレスポンスも改めて見てみると、

            
            $ curl --include https://*******.execute-api.ap-northeast-1.amazonaws.com/dev
HTTP/2 401 date: Wed, 23 Jun 2021 16:27:38 GMTcontent-type: application/jsoncontent-length: 26
x-amzn-requestid: 70bd3137-5f6e-4cb0-a08d-78fb69a5f351
x-amzn-errortype: UnauthorizedException
x-amz-apigw-id: BYwXKHtRNjMFRhQ=
        
というように、レスポンスステータス401が返ってきます。

またトークンがあっても間違っていた時のレスポンスは、

            
            $ curl --include https://********.execute-api.ap-northeast-1.amazonaws.com/dev \
    -H "Authorization:hogehoge"
HTTP/2 403
date: Wed, 23 Jun 2021 16:27:06 GMT
content-type: application/json
content-length: 27
x-amzn-requestid: bfe17578-4445-443c-ae16-b51d93e04200
x-amzn-errortype: AccessDeniedException
x-amz-apigw-id: BYwSGHGYtjMFZpA=
        
でステータスコード403が返ってきていることも確認できます。


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

Angularにログイン機能を統合してみる

ここまででコマンドラインで行ったことをJavascriptのライブラリを使ってAngularアプリとして統合してみます。

引き続きPetStoreのサンプルAPIをベースに、Angularアプリを作ってみます。

途中ライブラリのビルドでコンパイルオプションでnodeタイプが必要となってきますので、プロジェクトの
tsconfig.app.jsonを先に確認しておきます。

            
            {
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "module": "es2015",
    "types": ["node"] //👈aws-sdkで必要
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ]
}
        
不足していれば追加しておきましょう。

ログイン用のコンポーネント作成

今回はユーザー名とパスワードを入力して、ログイン/ログアウトするだけのアプリを作成します。

Angularプロジェクトの適当なフォルダに
loginという名前でコンポーネントを作成します。

            
            $ yarn ng g component components/login --module=app
CREATE src/app/components/login/login.component.css (0 bytes)
CREATE src/app/components/login/login.component.html (20 bytes)
CREATE src/app/components/login/login.component.spec.ts (619 bytes)
CREATE src/app/components/login/login.component.ts (271 bytes)
UPDATE src/app/app.module.ts (4864 bytes)
Done in 1.11s.
        
CSSスタイル付けは省略しますが、login.component.tsを編集して、以下のような簡単な雛形を作っておきます。

            
            import { Component, OnInit } from '@angular/core';

@Component({
    selector: 'app-login',
    template: `
        <div class="login-wrapper">
            <div class="login-input">USER: <input type="text" placeholder="ユーザー名"></div>
            <div class="login-input">PASSWORD: <input type="text" placeholder="パスワード"></div>
            <button class="login-button" type="button">ログイン</button>
            <div class="login-state">
                お知らせ:{{loginInfo}}
            </div>
        </div>
    `,
    styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
    loginInfo: string;
    constructor() {}
    ngOnInit(): void {
        this.loginInfo = '現在、ログインしていません。';
    }
}
        
プロジェクトにこのコンポーネントを組み込んでビルドすると、以下のようなログインを試すだけのアプリになります。

合同会社タコスキングダム|蛸壺の技術ブログ

ではここからログインのための具体的な機能を付け加えておきます。

Cognitoをjs/tsで操作するサービス

javascript(typescript)クライアントからCognitoを操作するのに必要なnpmパッケージをインストールします。

            
            $ yarn add -S aws-sdk amazon-cognito-identity-js
        
ではプロジェクトの適当な場所にCognitoの操作をまとめたサービスをcognito.service.tsという名前で作成します。

            
            $ yarn ng g service services/cognito
CREATE src/app/services/cognito.service.spec.ts (362 bytes)
CREATE src/app/services/cognito.service.ts (136 bytes)
Done in 0.91s.
        
ここでcognito.service.tsを以下のような内容にします。

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

@Injectable({
    providedIn: 'root'
})
export class CognitoService {
    cognitoCreds: AWS.CognitoIdentityCredentials;
    private userPool: CognitoUserPool;
    constructor() {
        AWS.config.region = 'ap-northeast-1';//👈AWSサービスを配置したリージョンを指定
        this.userPool = new CognitoUserPool({
            UserPoolId: '[ユーザープールID]', //👈ユーザープールIDに書きかえ
            ClientId: '[アプリクライアントID]'//👈アプリクライアントIDに書きかえ
        });
    }

    //ログイン
    login(username: string, password: string): Promise<any> {
        const cognitoUser = new CognitoUser({
            Username: username,
            Pool: this.userPool,
            Storage: localStorage
        });
        const authenticationDetails = new AuthenticationDetails({
            Username: username,
            Password: password,
        });
        return new Promise((resolve, reject) => {
            cognitoUser.authenticateUser(authenticationDetails, {
                onSuccess: (result) => {
                    alert('Login has done!');
                    let msg = `Id token: ${result.getIdToken().getJwtToken()}\n`;
                    msg += `Access token: ${result.getAccessToken().getJwtToken()}\n`;
                    msg += `Refresh token: ${result.getRefreshToken().getToken()}`;
                    console.log(msg);
                    resolve(msg);
                },
                onFailure: (err) => {
                    alert(err.message);
                    reject(err);
                }
            });
        });
    }

    //ログイン状態確認
    isAuthenticated(): Promise<any> {
        const cognitoUser = this.userPool.getCurrentUser();
        return new Promise((resolve, reject) => {
            cognitoUser === null && resolve(cognitoUser);
            cognitoUser.getSession((err: any, session: any) => {
                err ? reject(err) : (!session.isValid() ? reject(session) : resolve(session));
            });
        });
    }

    //IDトークン取得
    getCurrentUserIdToken(): any {
        const cognitoUser = this.userPool.getCurrentUser();
        let rslt = null;
        cognitoUser != null && cognitoUser.getSession((err: any, session: any) => {
                if (err) {
                    alert(err);
                } else {
                    rslt = session.getIdToken().getJwtToken();
                }
        });
        return rslt;
    }

    //ログアウト
    logout() {
        alert('Logout');
        const currentUser = this.userPool.getCurrentUser();
        currentUser && currentUser.signOut();
    }
}
        
このサービスをlogin.component.tsに組み込んで、生の各種トークンを表示させるように修正してみます。

            
            import { Component, OnInit } from '@angular/core';
import { CognitoService } from '../../services/cognito.service';

@Component({
    selector: 'app-login',
    template: `
        <div class="login-wrapper">
            <div class="login-input">USER: <input type="text" placeholder="ユーザー名" id="username" name="username" #username></div>
            <div class="login-input">PASSWORD: <input type="password" placeholder="パスワード" id="password" name="password" #password></div>
            <button class="login-button" type="button" (click)="login(username.value, password.value)">ログイン</button>
            <div class="login-state">
                お知らせ: {{loginInfo}}
            </div>
        </div>
    `,
    styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
    loginInfo: string;

    constructor(
        private cognito: CognitoService
    ) {}

    ngOnInit(): void {
        this.loginInfo = '現在、ログインしていません。';
    }

    async login(username: string, password: string): Promise<any> {
        try {
            const result = await this.cognito.login(username, password);
            this.loginInfo = JSON.stringify(result);
        } catch(e) {
            console.log(e);
        }
    }
}
        
これでアプリを立ち上げて、ユーザー名とパスワードを打ち込んでログインしてみると、

合同会社タコスキングダム|蛸壺の技術ブログ

IDトークンなどの認証情報が取得できました。

ログイン・ログアウトを切り替える

ここまででログインができていることが確認できましたが、出来れば一つのボタンでログイン状況に応じてログイン・ログアウトを行いたいので、更に
login.component.tsを以下のように修正してみます。

            
            import { Component, OnInit } from '@angular/core';
import { CognitoService } from '../../services/cognito.service';

@Component({
    selector: 'app-login',
    template: `
        <div class="login-wrapper">
            <div class="login-input">USER: <input type="text" placeholder="ユーザー名" id="username" name="username" #username></div>
            <div class="login-input">PASSWORD: <input type="password" placeholder="パスワード" id="password" name="password" #password></div>
            <button class="login-button" type="button" (click)="login(username.value, password.value)">{{buttonTitle}}</button>
            <div class="login-state">
                お知らせ: {{loginInfo}}
            </div>
        </div>
    `,
    styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
    loginInfo: string;
    buttonTitle: string;

    constructor(
        private cognito: CognitoService
    ) {}

    ngOnInit(): void {
        this.buttonTitle = 'ログイン'
        this.loginInfo = '現在、ログインしていません。';
    }

    async login(username: string, password: string): Promise<any> {
        try {
            const isLogin = await this.cognito.isAuthenticated();
            console.log(isLogin);
            if(isLogin === null) {
                const result = await this.cognito.login(username, password);
                this.loginInfo = JSON.stringify(result);
                this.buttonTitle = 'ログアウト';
            } else {
                this.cognito.logout();
                this.buttonTitle = 'ログイン';
                this.loginInfo = 'ログアウトしました。';
            }
        } catch(e) {
            if(e === null) {
                this.cognito.logout();
                this.buttonTitle = 'ログイン';
                this.loginInfo = 'セッションの有効期限切れです。';
            }
        }
    }
}
        
ここでのポイントはcognito.service.tsのisAuthenticatedメソッドでログイン状態がチェックできるのを利用しています。

合同会社タコスキングダム|蛸壺の技術ブログ

IDトークンを使ってリソースにアクセス

ログイン/ログアウトの機能が正常に動作していることが確認できましたので、最後に取得したJWTトークンをリクエストヘッダのAuthorizationプロパティに仕込んで、リソースにアクセスしてみます。

CORSの有効化

最近のブラウザではCORS周りのセキュリティ強化を背景に、Angularアプリ開発での定石であるローカルからhttp://localhost:4200で立ち上げてからリモートのオリジンにアクセスする方法が制限されます。

最終的なプロダクトではlocalhostサーバーは使わないので考慮しなくても宜しいのですが、今回に限って言えば、
http://localhost:4200からAPI GatewayのエンドポイントにCORSアクセスさせたい、と思われると思います。

そこでAPI GatewayをCORS対応に再設定する必要があります。

API GatewayのダッシュボードからAPIを選択して、
[リソース] > /petsを選択 > [アクション] > [CORSの有効化]に進みます。

合同会社タコスキングダム|蛸壺の技術ブログ

ここで
CORSの有効化のダイアログの①の箇所にて、Access-Control-Allow-Headersの内容が以下のようになるように不足分を追加しておきます。

            
            'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,access-control-allow-origin,access-control-allow-headers,x-content-type-options'
        
あとはすべてデフォルトでOKです。CORSを有効化させたあとは、変更したリソースをデプロイし直すことを忘れずに行う必要があります。

Httpインターセプターでリクエストヘッダを置き換える

AngularアプリでHTTPリクエストヘッダを書き換えるなどの操作はHttpInterceptorクラスを実装したサービスで行うことと便利です。

ということで
get-pet-interceptor.service.tsを適当な場所に新規作成します。

            
            $ yarn ng g service interceptors/getPetInterceptor
        
早速このget-pet-interceptor.service.tsの中身を以下で修正します。

            
            import { Injectable } from '@angular/core';

import {
    HttpEvent,
    HttpHandler,
    HttpInterceptor,
    HttpRequest,
    HttpClient
} from '@angular/common/http';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { Observable } from 'rxjs';

import { CognitoService } from '../services/cognito.service';

export class Pet {
    id: number;
    type: string;
    price: number;
}

@Injectable({
    providedIn: 'root'
})
export class GetPetService {
    //👇/petsにアクセスする
    private Url = 'https://**********/.execute-api.ap-northeast-1.amazonaws.com/dev/pets';
    constructor(
        private http: HttpClient
    ) { }

    getPets(): Observable<Pet[]> {
        return this.http.get<Pet[]>(this.url_);
    }
}

///👇Authorizationヘッダ用のインターセプター
@Injectable({
    providedIn: 'root'
})
export class GetPetInterceptor implements HttpInterceptor {
    constructor(
        private cognito: CognitoService
    ) { }

    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        //👇現在のIDトークンを取得
        const authHeader = this.cognito.getCurrentUserIdToken();
        //👇オリジナルのリクエストヘッダーを複製し、IDトークンを追加したものに差替え
        const authReq = req.clone({
            headers: req.headers.set('Authorization', authHeader)
        });
        //👇変形したリクエストとして送信側へ流す
        return next.handle(authReq);
    }
}

export const GET_PET_PROVIDER = {
    provide: HTTP_INTERCEPTORS,
    useClass: GetPetInterceptor,
    multi: true
};
        
HTTPインターセプタを有効にするためには、app.module.tsへプロバイダを登録する必要があります。

            
            import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
//...
//....中略

//👇インターセプタ用のプロバイダインスタンス
import { GET_PET_PROVIDER } from './interceptors/get-pet-interceptor.service';

//....中略

@NgModule({
    declarations: [
    //....中略
    ],
    providers: [
        //....
        //👇HTTPリクエストの度にバックグラウンドで実行される
        GET_PET_PROVIDER
    ],
//....以下略
        
最終的にはyarn serveでローカルから試しても、/petsからのレスポンスを受けることが出来るようになりました。

三度、
login.component.tsを修正して、ログインしたら/petsのデータが表示されるようにしてみます。

            
            import { Component, OnInit } from '@angular/core';
import { take, tap } from 'rxjs/operators';

import { CognitoService } from '../../services/cognito.service';
import { GetPetService } from '../../interceptors/get-pet-interceptor.service';

@Component({
    selector: 'app-login',
    template: `
        <div class="login-wrapper">
            <div class="login-input">USER: <input type="text" placeholder="ユーザー名" id="username" name="username" #username></div>
            <div class="login-input">PASSWORD: <input type="password" placeholder="パスワード" id="password" name="password" #password></div>
            <button class="login-button" type="button" (click)="login(username.value, password.value)">{{buttonTitle}}</button>
            <div class="login-state">
                <p>お知らせ:</p>
                {{loginInfo}}
            </div>
        </div>
    `,
    styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
    loginInfo: string;
    buttonTitle: string;

    constructor(
        private cognito: CognitoService,
        private getpet: GetPetService
    ) {}

    ngOnInit(): void {
        this.buttonTitle = 'ログイン'
        this.loginInfo = '現在、ログインしていません。';
    }

    async login(username: string, password: string): Promise<any> {
        try {
            const isLogin = await this.cognito.isAuthenticated();
            console.log(isLogin);
            if(isLogin === null) {
                const result = await this.cognito.login(username, password);
                this.getpet.getPets().pipe(take(1)).subscribe((res: any[]) => {
                    this.loginInfo = '';
                    for (const item of res) {
                        this.loginInfo += `${JSON.stringify(item)}\n`;
                    }
                });
                this.buttonTitle = 'ログアウト';
            } else {
                this.cognito.logout();
                this.buttonTitle = 'ログイン';
                this.loginInfo = 'ログアウトしました。';
            }
        } catch(e) {
            if(e === null) {
                this.cognito.logout();
                this.buttonTitle = 'ログイン';
                this.loginInfo = 'セッションの有効期限切れです。';
            }
        }
    }
}
        
きちんとログインできたと同時に/petsにアクセスして、データを取得&表示されるようになりました。

合同会社タコスキングダム|蛸壺の技術ブログ


合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集

図解即戦力 Amazon Web Servicesのしくみと技術がこれ1冊でしっかりわかる教科書

まとめ

今回は少し長めのテクニカル解説になってしまいましたが、最後まで見ていただきまして有難うございました。

この記事の内容で、AngularとAWS API GatewayとCognitoを連携させるまでの概要が一通り網羅されたように思います。

最終的にはウェブショップなどのもう少し実用的なアプリにしてみたい気はしますが、また時間があるときに実践的な話を解説していきたいと思います。

参考サイト

Amazon Cognito ユーザープールをオーソライザーとして使用して REST API へのアクセスを制御する

Cognitoで認証されたユーザーだけがAPI Gatewayを呼び出せるオーソライザーを使ってみた

API GatewayのオーソライザーにAmazon Cognitoを使ってみた件

Cognitoの環境構築から、Angularでログインするまで

Angular+Cognitoで作るログインページ– Classmethodサーバーレス

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログ【AWS独習術】AWSをじっくり独学したい人のためのオススメ書籍&教材特集