Cognitoユーザープールのアカウント管理画面を作る

*（2017/11/6 追記）*
コメント指摘にあるように、Lambdaプロキシ統合を使えば、以下の様な煩雑な手順を踏む必要はないことがわかりました。


Cognitoユーザープールを利用してBtoBシステムの認証基盤構築をしようとしたとき、専用のアカウント管理画面が欲しいといったことがあるかもしれません。（あんまりないですかね。。。）
いろいろと知見も得られたので、検討した結果を以下にまとめてみます。おかしな点などあればご指摘いただけると嬉しいです。

# 概要

以下のような要件を実現することを目指します。

* ユーザーは管理者／一般の区別ができるようにする（アプリケーションの認可制御）
* ユーザがサインアップするのではなく、アカウント管理者がユーザー登録する（そのためのアプリ画面を作るのが目標）
* アカウント管理者のアカウントもCognitoユーザープールで管理
* よって、アカウント管理者の登録は、運用サイドで実施する（コンソールから登録するなど）

バックエンドは、API Gateway + Lambdaで構成します。
カスタムオーソライザーを適用して認証します。

[API Gateway カスタムオーソライザーの使用](http://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/use-custom-authorizer.html)

フロントエンドは今回あまりフォーカスしませんが、Angularで作ることにします。

# ユーザープールの作成

要件としてユーザーを管理者／一般で区別したいので、これをカスタム属性で実現することにします。
ユーザープールの作成時にカスタム属性を追加しておきます。（必須属性はemailだけとします。）

<img width="1000" alt="スクリーンショット 2017-10-27 14.05.55.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/70b3b5d8-1306-7203-5339-56072ba289f2.jpeg">

また、「管理者のみにユーザーの作成を許可する」にチェックします。

<img width="900" alt="スクリーンショット 2017-10-27 14.06.44.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/d0ec7f1f-d90d-f207-3ef7-c6857a1cc7d9.jpeg">

アプリクライアントを追加して、アプリクライアントIDを取得します。（AngularのWebアプリケーションで利用します）

# ログイン画面

今回フロントエンドは簡単に概要を記すに留めます。
Angularで作成するWebアプリケーションには、ログイン画面を作成します。
ログイン認証は、Cognitoに対して行います。[amazon-cognito-identity-js](https://www.npmjs.com/package/amazon-cognito-identity-js)というパッケージが提供されていますので、これを使ってCognitoにリクエストを送ります。使い方に迷った時は、[GitHub](https://github.com/aws/amazon-cognito-identity-js)のコードを読んでしまうのが早いように思います。
CognitoUserクラスのauthenticateUserメソッドで認証に成功すると、IDトークンなど[３つのトークン](http://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html)が取得できますので、このトークンをAPI GatewayのAPI呼び出しの際に使うことになります。

# カスタムオーソライザー

## 認証用Lambda関数

前述の通り認証用のLambda関数を作成します。

```js
const aws = require( 'aws-sdk' );
const cognitoProvider = new aws.CognitoIdentityServiceProvider({
    apiVersion: '2016-04-18',
    region: 'ap-northeast-1'
});

exports.handler = function(event, context, callback) {
    context.callbackWaitsForEmptyEventLoop = false; 

    const params = {
        AccessToken:event.authorizationToken
    };
    cognitoProvider.getUser(params, function(err, data) {
        if (err) {
            console.log(err);

            // IAMのDenyポリシーを返す
            callback(null, generatePolicy('user', 'Deny', event.methodArn));
        } else {
            let role;
            
            data.UserAttributes.forEach(item => {
                if (item.Name === 'custom:role') {
                    role = item.Value;
                }
            });

            // IAMのAllowポリシーを返す
            callback(null, generatePolicy(data.Username, 'Allow', event.methodArn, role));
        }
    });
}

const generatePolicy = function(principalId, effect, resource, role) {
    const authResponse = {};

    authResponse.principalId = principalId;
    if (effect && resource) {
        const statementOne = {};
        statementOne.Action = 'execute-api:Invoke';
        statementOne.Effect = effect;
        statementOne.Resource = resource;

        const policyDocument = {};
        policyDocument.Version = '2012-10-17';
        policyDocument.Statement = [];
        policyDocument.Statement[0] = statementOne;

        authResponse.policyDocument = policyDocument;
    }
    if (role) {
        authResponse.context = {
            role: role
        };
    }
    
    return authResponse;
}
```

CognitoIdentityServiceProviderのgetUserメソッドでCognitoのユーザープールからユーザー情報を取得します。
[GetUserのAPI仕様](http://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_GetUser.html)
これが取得できなければ、認証失敗と判断します。

取得されたユーザー情報には、カスタム属性も含まれているので、それも認証レスポンス（generatePolicy関数で生成しているオブジェクト）に設定します。

getUserメソッドのメソッドのパラメータには、クライアント（今回ですとAngularのアプリ）から渡されたアクセストークンを設定します。
クライアント側の実装は、以下のような感じのコードになります。リクエストヘッダー（Authorization）にログイン時に得たアクセストークンを指定しています。（Promiseでラップしているので、resolveとかrejectの呼び出しが書かれています）

```ts
const headers = new Headers({ 'Authorization': 'アクセストークン' });
const options = new RequestOptions({ headers: headers });
this.http.get('APIのURL', options)
         .map(res => res.text())
         .subscribe(
           (text) => resolve(text),
           (err) => reject(err));
```

## API Gatewayのカスタムオーソライザーに指定する

この認証用Lambda関数を、API Gatewayで作成するAPIのカスタムオーソライザーに指定します。
まず、[オーソライザー]で新しいオーソライザーを作成して、上記のLambda関数を紐付けます。

<img width="450" alt="スクリーンショット 2017-10-27 12.09.21.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/c88e6c80-5a74-edaa-bd4c-e02f37ddcf7f.jpeg">

そして、メソッドリクエストの認証に作成したオーソライザーを紐付けます。

<img width="517" alt="スクリーンショット 2017-10-27 12.23.12.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/9eaccce6-6330-5315-4179-c391a9477ea5.jpeg">

## カスタム属性を後続のLambda関数に引き渡す

認証用Lambda関数で取得したカスタム属性を、統合バックエンド（Lambda関数）に引き渡すため、統合リクエストの本文マッピングテンプレートで行います。
以下の例では、カスタム属性だけを引き渡していますが、後述するユーザ登録では、その他のデータもマッピングします。

<img width="754" alt="スクリーンショット 2017-10-27 13.23.02.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/1a30ce6c-1094-1cb1-697a-69878c12bf92.jpeg">

# ユーザー登録画面

それでは、Cognitoユーザープールに一般ユーザーを登録する機能を作成していきます。
登録画面からは、以下の3項目が入力されるものとします。

* ユーザー名
* 仮パスワード
* Eメールアドレス

なお、パスワードについては、一般ユーザーが最初にログインしたときに変更を求められます。
[AWS マネジメントコンソールでの Amazon Cognito User Pools API を使用した管理者としてのユーザーアカウントの作成](http://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/how-to-create-user-accounts.html)

後述しますが、初期パスワードをCognitoで自動生成することも出来ますので、登録画面からパスワードを入力しないようにすることも可能です。
（生成された仮パスワードが登録されたEメールアドレスに通知されます）

登録画面を一般ユーザーが参照するWebアプリケーションに含めるなら、ログイン中ユーザーが一般ユーザーなら画面遷移できないようにするとよいでしょう。

# ユーザー登録API

API Gatewayにユーザー登録API（POSTメソッド）を新規作成します。
AngularのWebアプリケーションからリクエストを受け付けられるよう、CORSの有効化を行っておきます。

## オーソライザー

上記手順でカスタムオーソライザーを設定します。

## リクエストの検証

クライアントからのリクエスト本文を検証する設定を行います。

まず、モデル（User）を追加します。
以下のようにJSONスキーマで定義します。

```json
{
    "title": "User",
    "type": "object",
    "properties": {
        "username": {
            "type": "string",
            "minLength": 8
        },
        "password": {
            "type": "string",
            "minLength": 8
        },
        "email": {
            "type": "string",
            "format": "email"
        },
    },
    "required": ["username", "password", "email"]
}
```

メソッドリクエストの設定では、[リクエストの検証]に[本文の検証]を選択して、リクエスト本文に上で作成したモデルを指定します。

<img width="950" alt="スクリーンショット 2017-10-27 15.56.44.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/c35d4714-b32e-0277-5a12-599b69075b4e.jpeg">

これで、ユーザー名やパスワードがクライアントから指定されない場合や、Eメールアドレスの形式が誤っている場合、`400 Bad Request`のレスポンスを返すようになります。

## 本文マッピングテンプレート

後続のLambda関数に必要な情報を引き渡すため統合リクエストを設定します。
上の例では、roleカスタム属性のみでしたが、登録するユーザーの情報も設定します。

```json
{
    "AuthParam": {
        "role": "$context.authorizer.role"
    },
    "RequestParam": {
        "username": $input.json("$.username"),
        "password": $input.json("$.password"),
        "email": $input.json("$.email")
    }
}
```

## ユーザー登録用Lambda関数

以下のLambda関数でユーザープールに登録を行います。

```js
const aws = require( 'aws-sdk' );
const cognitoProvider = new aws.CognitoIdentityServiceProvider({
    apiVersion: '2016-04-18',
    region: 'ap-northeast-1'
});

exports.handler = (event, context, callback) => {
    context.callbackWaitsForEmptyEventLoop = false; 
    
    // 権限チェック
    if (event.AuthParam.role !== 'ADMIN') {
        callback(new Error("Unauthorized.(401)"));
        return;
    }
    
    const params = createParam(event.RequestParam);
    cognitoProvider.adminCreateUser(params, function(err, data) {
        if (err) {
            console.log(err);
            let errorMessage;
            if (err.statusCode >= 500) {
                errorMessage = "Internal Server Error.(500)";
            } else {
                errorMessage = "Bad Request.(400)";
            }
            callback(new Error(errorMessage));
        } else {
            callback(null, data);
        }
    });
};

const createParam = function(requestParam) {
    return {
        UserPoolId: '対象のユーザープールID',
        Username: requestParam.username,
        DesiredDeliveryMediums: [
            'EMAIL',
        ],
        TemporaryPassword: requestParam.password,
        UserAttributes: [
            {
                Name: 'email',
                Value: requestParam.email
            },
            {
                Name: 'custom:role',
                Value: 'GENERAL'  // 一般ユーザーで固定する
            },
        ]
    };
};
```

CognitoIdentityServiceProviderのadminCreateUserメソッドでCognitoのユーザープールにユーザーを登録します。

登録を実行する前に、リクエストしたユーザー（Angularアプリでログインしているユーザーです）に実行権限があるのかをチェックしています。
認証用Lambda関数で取得したroleカスタム属性がevent引数に渡されてきますので（本文マッピングテンプレートでそのように設定しました）、これを評価します。

[AdminCreateUserのAPI仕様](http://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_AdminCreateUser.html)の`Errors`の記載を確認すると、エラーの種別はかなりたくさんありますが、上のコードではステータスコードで2パターンに分けてしまっています。（利用者の入力に問題がないケースも、`Bad Request`としてしまうことになりますが、そのあたりはあまり厳密に扱いません）
TemporaryPasswordを指定しない場合、Cognitoが自動生成してくれます。MessageActionに`SUPRESS `を指定してしまうと、Invitationのメールが送信されなくなってしまうので、自動生成されたパスワードを知るすべが（恐らく）なくなってしまうので、何も指定しないようにしましょう。


なお、対象のユーザープールに対してユーザー登録できるよう、実行ロールを指定してください。
以下は、最低限のポリシー設定ですが、このポリシーを紐付けたロールを指定すれば登録できるようになると思います。

<img width="1000" alt="スクリーンショット 2017-10-27 18.16.13.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/835613de-1ca4-757f-0de0-444c4b6e433e.jpeg">

## メソッドレスポンス／統合レスポンスの設定

ここまでの手順を踏めば、ユーザープールに登録することができるようになっているのですが、登録用Lambda関数のエラーがクライアントに返されるようにレスポンスのほうも設定をしておきます。

メソッドレスポンスには以下のように設定します。
レスポンスヘッダーに`Access-Control-Allow-Origin`を追加して、レスポンス本文にErrorモデルを指定します。（401／500も同様にします）

<img width="900" alt="スクリーンショット 2017-10-27 18.35.32.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/e4b52a1b-8558-4edc-c6df-c46b07209980.jpeg">

統合レスポンスには以下のように設定します。

<img width="900" alt="スクリーンショット 2017-10-27 18.42.17.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/165162/0432132a-bd24-39be-03f0-1ee65e70e40f.jpeg">

少々味気ないですが、エラーのレスポンスとして以下のようなJSONが返されるようになります。

![スクリーンショット 2017-10-27 18.47.42.jpg](https://qiita-image-store.s3.amazonaws.com/0/165162/06bf615b-34a6-2b46-a3b3-a334cd2e7224.jpeg)

これでAngularの登録画面からユーザー登録ができるようになりました。
この要領でユーザー情報の更新や削除の機能も作っていけると思います。

```ts
const headers = new Headers({ 'Authorization': 'アクセストークン' });
const options = new RequestOptions({ headers: headers });
this.http.post('APIのURL', body, options)
         .map(res => res.text())
         .subscribe(
           (text) => resolve(text),
           (err) => reject(err));
```

bodyは以下の様なinterfaceの変数です。

```ts
export interface UserAcount {
  username: string,
  password: string,
  email: string
}
```

冒頭にも書きましたが、何かお気づきの点があれば、コメントしていただけると嬉しいです。