AWS CloudFormationでCognitoユーザープールをMFAのTOTPを有効にして作成する

## 概要

Cognitoのユーザープールを作成するのに、AWS マネジメントコンソールを利用するのが面倒になり、AWS SDK for Pythonを利用して面倒さを解消したのですが、AWSにはCloudFormation(CFn)という素敵サービスがありますので、それを利用してさらに手間を省けないか検証してみました。

CFnでCognitoはすでに対応されていますが、MFA有効化してTOTPを選択するのに少し手間がかかりました。そのうちにもっと簡単に作成されるといいですね。(2018/11/09時点)

CloudFormation で Cognito
https://qiita.com/y13i/items/1923b47079bdf7c44eec

Amazon Cognito Now Supported by AWS CloudFormation
https://aws.amazon.com/jp/about-aws/whats-new/2017/04/amazon-cognito-now-supported-by-aws-cloudformation/

CognitoやMFA、TOTPってなんぞ？という方は下記をご参考ください。

Amazon Cognitoのワンタイムパスワード(TOTP)認証をNode.jsで試してみた
https://qiita.com/kai_kou/items/aa7dc0042b44649f576a

PythonでAmazon Cognitoのユーザープールを作成してみる
https://qiita.com/kai_kou/items/f1623ac1f3d7aaab23fe


## AWS CloudFormation(Cfn)とは

AWS CloudFormation （設定管理とオーケストレーション） | AWS
https://www.google.co.jp/search?q=CloudFormation

>AWS CloudFormation は、クラウド環境内のすべてのインフラストラクチャリソースを記述してプロビジョニングするための共通言語を提供します。CloudFormation では、シンプルなテキストファイルを使用して、あらゆるリージョンとアカウントでアプリケーションに必要とされるすべてのリソースを、自動化された安全な方法でモデル化し、プロビジョニングできます。このファイルは、クラウド環境における真の単一ソースとして機能します。 

CloudFormation超入門
https://dev.classmethod.jp/beginners/chonyumon-cloudformation/

>誤解を恐れつつ一言で言えば、「自動的にAWS上で作りたいものを作ってくれる」サービスです。というか、そういう環境を用意してくれるサービスです。

## CFnのテンプレート

今回作成したCFnのテンプレートです。

CFnのテンプレートはAWS マネジメントコンソールにあるデザイナーでも作成できますが、今回は利用せずに作成しました。フォーマットはJSON、YAMLが利用できますが、YAMLで作成しています。

検証用のユーザーをあわせて作成していますが、ユーザー名、パスワードはべた書きなので、あしからず。

ソースはGitHubにもアップしています。
https://github.com/kai-kou/create-cognito-user-pool-at-cloudformation


```yaml:create-user-pool-template.yaml
Resources:
  # ユーザープールの作成
  UserPool:
    Type: "AWS::Cognito::UserPool"
    Properties:
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireUppercase: true
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
      UserPoolName:
        Ref: AWS::StackName
      MfaConfiguration: 'OFF'
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
        UnusedAccountValidityDays: 7

  # ユーザープールにアプリクライアントを作成
  UserPoolClient:
    Type: "AWS::Cognito::UserPoolClient"
    Properties:
      UserPoolId:
        Ref: UserPool
      ClientName:
        Ref: AWS::StackName
      RefreshTokenValidity: 30

  # ユーザープールでMFA(TOTP)有効化
  UserPoolMfaConfig:
    Type: Custom::CustomResource
    Properties:
      ServiceToken: !GetAtt CognitoSetUserPoolMfaConfigFunction.Arn
      UserPoolId:
        Ref: UserPool

  # 検証用のユーザーを追加
  AdminCreateUser:
    Type: Custom::CustomResource
    Properties:
      ServiceToken: !GetAtt CognitoAdminCreateUserFunction.Arn
      UserPoolId:
        Ref: UserPool
      UserName: hoge
      Password: hogeHoge7!

  # 検証用のユーザーを追加
  AdminCreateUser2:
    Type: Custom::CustomResource
    Properties:
      ServiceToken: !GetAtt CognitoAdminCreateUserFunction.Arn
      UserPoolId:
        Ref: UserPool
      UserName: hoge2
      Password: hogeHoge7!

  # ユーザープールでMFA(TOTP)有効化するLambda関数
  CognitoSetUserPoolMfaConfigFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt CognitoFunctionExecutionRole.Arn
      Code:
        ZipFile: !Sub |
          import cfnresponse
          import boto3
          def handler(event, context):
            # スタック削除時にも実行されるので、処理せずに終了させる
            if event['RequestType'] == 'Delete':
              cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              return

            # UserPoolIDを取得する
            user_pool_id = event['ResourceProperties']['UserPoolId']
            print(f'user_pool_id: {user_pool_id}')

            # MFA有効化してTOTPを指定する
            response_data = {}
            try:
              client = boto3.client('cognito-idp')
              response_data = client.set_user_pool_mfa_config(
                UserPoolId=user_pool_id,
                SoftwareTokenMfaConfiguration={
                  'Enabled': True
                },
                MfaConfiguration='ON'
              )

            except Exception as e:
              print("error: " + str(e))
              response_data = {'error': str(e)}
              cfnresponse.send(event, context, cfnresponse.FAILED, response_data)
              return

            print(response_data)
            cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
      Runtime: python3.6

  # ユーザープールにテスト用ユーザーを作成するLambda関数
  CognitoAdminCreateUserFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt CognitoFunctionExecutionRole.Arn
      Code:
        ZipFile: !Sub |
          import cfnresponse
          import boto3
          def handler(event, context):
            print(event['RequestType'])
            # スタック削除時にも実行されるので、処理せずに終了させる
            if event['RequestType'] == 'Delete':
              cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              return

            # UserPoolIDを取得する
            user_pool_id = event['ResourceProperties']['UserPoolId']
            print(f'user_pool_id: {user_pool_id}')

            # ユーザー名、パスワードを取得する
            username = event['ResourceProperties']['UserName']
            password = event['ResourceProperties']['Password']

            # 検証用のユーザーを作成する
            response_data = {}
            try:
              client = boto3.client('cognito-idp')
              response_data = client.admin_create_user(
                UserPoolId=user_pool_id,
                Username=username,
                TemporaryPassword=password,
                MessageAction='SUPPRESS'
              )

              # Datetime型のままなので文字列に変換する
              response_data['User']['UserCreateDate'] = \
                response_data['User']['UserCreateDate'].strftime('%c')
              response_data['User']['UserLastModifiedDate'] = \
                response_data['User']['UserLastModifiedDate'].strftime('%c')

            except Exception as e:
              print("error: " + str(e))
              response_data = {'error': str(e)}
              cfnresponse.send(event, context, cfnresponse.FAILED, response_data)
              return

            print(response_data)
            cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
      Runtime: python3.6

  # Lambda関数実行用のロール
  CognitoFunctionExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: "/"
      Policies:
      - PolicyName: root
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
            Resource: "arn:aws:logs:*:*:*"
          # Cognitoの操作権限を付与する
          - Effect: Allow
            Action:
              - cognito-idp:*
            Resource: "arn:aws:cognito-idp:*:*:userpool/*"
```

はい。
ざっくりとポイントだけ。

### ユーザープール作成時にTOTPを利用したMFA有効化はできない

```MfaConfiguration``` というパラメータでMFA有効化できるのですが、いまのところ有効化時にTOTPを指定するパラメータがありませんでしたので、とりあえず、```OFF``` で作成しています。(2018/11/09時点)

パラメータについては公式のドキュメントが参考になります。

AWS::Cognito::UserPool - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cognito-userpool.html

ユーザープール名```UserPoolName``` の指定は```Ref: AWS::StackName``` とすることでCFnのスタック名としています。

```yaml
  UserPool:
    Type: "AWS::Cognito::UserPool"
    Properties:
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireUppercase: true
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
      UserPoolName:
        Ref: AWS::StackName
      MfaConfiguration: 'OFF'
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
        UnusedAccountValidityDays: 7
```

### AWS Lambda-backed カスタムリソースを利用してMFAを有効化する

CFnにはカスタムリソースというものが用意されており、CFnが対応していないリソースを自前で管理することができます。さらにカスタムリソースを定義する際にLambda関数を利用することができます。便利ですね^^

AWS Lambda-backed カスタムリソース - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/template-custom-resources-lambda.html

>Lambda 関数とカスタム リソースを関連付けた場合、この関数はカスタム リソースが作成、更新、または削除されるたびに呼び出されます。 

こちらを利用して、CognitoのユーザープールでMFA有効化します。

#### カスタムリソースの定義

```yaml
  UserPoolMfaConfig:
    Type: Custom::CustomResource
    Properties:
      ServiceToken: !GetAtt CognitoSetUserPoolMfaConfigFunction.Arn
      UserPoolId:
        Ref: UserPool
```

```UserPoolMfaConfig``` はカスタムリソースの名称となり任意で指定できます。```Type: Custom::CustomResource``` とすることでカスタムリソースと定義します。
```ServiceToken``` に実行するLambda関数のArnを指定、```UserPoolId``` はLambda関数に引き渡すパラメータになります。ここでは```Ref: UserPool``` として、作成されたユーザープールのIDを渡しています。


#### Lambda関数の定義

```yaml
  CognitoSetUserPoolMfaConfigFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt CognitoFunctionExecutionRole.Arn
      Code:
        ZipFile: !Sub |
          (略)
      Runtime: python3.6
```

実行するLambda関数は作成済みの関数も指定できますが、同一テンプレート(スタック)内で作成することもできます。(すごい！便利！

今回はAWS SDKを利用するだけでよかったので、ソースも含めて定義していますが、もし他のライブラリをインポートしたい場合は、ソースをZIP圧縮してS3へアップしたものを指定することができます。通常のAWS Lambda関数をデプロイする方法と同じです。

Lambda関数の実行に必要なロールも同一テンプレート(スタック)内で作成して指定ができます。

```Handler: index.handler``` としていますが、```index``` に関してはFCnで関数を作成する場合、ファイル名が```index.py``` となるので、変更不可のようです。


Lambda関数の詳細については下記が参考になります。

AWS Lambda 関数コード - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html

Lambda-backedカスタムリソースの雛形
https://www.weblog-beta.com/posts/custom-resource/

#### Lamnda関数の実装

```python
import cfnresponse
import boto3
def handler(event, context):
  # スタック削除時にも実行されるので、処理せずに終了させる
  if event['RequestType'] == 'Delete':
    cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
    return

  # UserPoolIDを取得する
  user_pool_id = event['ResourceProperties']['UserPoolId']
  print(f'user_pool_id: {user_pool_id}')

  # MFA有効化してTOTPを指定する
  response_data = {}
  try:
    client = boto3.client('cognito-idp')
    response_data = client.set_user_pool_mfa_config(
      UserPoolId=user_pool_id,
      SoftwareTokenMfaConfiguration={
        'Enabled': True
      },
      MfaConfiguration='ON'
    )

  except Exception as e:
    print("error: " + str(e))
    response_data = {'error': str(e)}
    cfnresponse.send(event, context, cfnresponse.FAILED, response_data)
    return

  print(response_data)
  cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
```

Lambda関数はCFnのスタック作成時だけでなく、更新や削除時にも実行されます。
今回は削除するケースに対応するのに、```if event['RequestType'] == 'Delete':``` で、削除時には処理をスキップするようにしています。

例外のハンドリングなど他にも考慮すべき点がありますが、下記が参考になります。

CloudFormationでLambdaを実行する
https://prgrmmbl.com/2018/07/01/Lambda-With-CloudFormation.html

Lambda-backed Custom Resourceのcfn-responseモジュールを利用する上での注意点
https://dev.classmethod.jp/cloud/aws/note-about-using-delete-response-with-cfn-response-module-in-lambda-backed-custom-resource/

Cognitoのユーザープールの操作にはPythonで提供されているAWS SDKの```boto3``` を利用しています。SDKの利用に関しては下記をご参考ください。

PythonでAmazon Cognitoのユーザープールを作成してみる
https://qiita.com/kai_kou/items/f1623ac1f3d7aaab23fe


## 作成する

CFnで上記テンプレートからスタックを作成してみます。

### AWS CLIで作成する

AWS CLIを利用して、CFnのスタックを作成してみます。
上記のテンプレートを適当なディレクトリに保存して実行します。

AWS CLIのインストールについては下記が参考になります。

AWS CLIのインストール
https://qiita.com/yuyj109/items/3163a84480da4c8f402c

```sh
> aws --version
aws-cli/1.16.27 Python/3.6.6 Darwin/17.7.0 botocore/1.12.17

> mkdir 任意のディレクトリ
> cd 任意のディレクトリ
> touch create-user-pool-template.yaml
> vi create-user-pool-template.yaml
```

create-stack - AWS CLI 1.16.51 Command Reference
https://docs.aws.amazon.com/cli/latest/reference/cloudformation/create-stack.html

AWS CloudformationをAWS CLIから使ってみる
https://techte.co/2018/01/25/cloudformation/

```sh
> aws cloudformation create-stack \
  --region ap-northeast-1 \
  --stack-name cognito-totp-mfa-user-pool \
  --template-body file://create-user-pool-template.yaml \
  --capabilities CAPABILITY_IAM
  
{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:xxxxx:stack/cognito-totp-mfa-user-pool/3d3591e0-e3ca-11e8-bdc2-503a6ff78e2a"
}
```

```create-stack``` でスタックを作成します。
ロールを作成する場合、```--capabilities CAPABILITY_IAM``` と指定しておかないと、```Requires capabilities : [CAPABILITY_IAM]``` と怒られるので、ご注意ください。

Requires capabilities : [CAPABILITY_IAM]
https://github.com/awslabs/serverless-application-model/issues/51


```describe-stacks``` でスタックの情報が確認できます。

```sh
> aws cloudformation describe-stacks \
  --stack-name cognito-totp-mfa-user-pool

{
    "Stacks": [
        {
            "StackId": "arn:aws:cloudformation:ap-northeast-1:xxxxx:stack/cognito-totp-mfa-user-pool/752fbbe0-e3d2-11e8-8ab5-50a686699882",
            "StackName": "cognito-totp-mfa-user-pool",
            "CreationTime": "2018-11-09T03:49:28.365Z",
            "RollbackConfiguration": {},
            "StackStatus": "CREATE_IN_PROGRESS",
            "DisableRollback": false,
            "NotificationARNs": [],
            "Capabilities": [
                "CAPABILITY_IAM"
            ],
            "Tags": [],
            "EnableTerminationProtection": false
        }
    ]
}
```

AWS マネジメントコンソールでもスタックが作成ことが確認できます。
<img width="1270" alt="CloudFormation_-_Stack-2.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/ef93b8c5-e2ec-f75d-bdc2-ef312a09cd70.png">
<img width="1272" alt="CloudFormation_-_Stack_cognito-totp-mfa-user-pool.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/aa577743-d01b-8518-0174-ac381c0a6662.png">


### AWS マネジメントコンソールで作成する

最近CloudFormationの操作UIが新しくなりプレビュー版が利用できるようになったみたいです。LambdaのUIっぽくて良い感じです。せっかくなので新しいUIで試してみます。

<img width="1275" alt="CloudFormation_-_Stack-3.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/43f050b4-ea15-351f-684a-d1fcb67dbc36.png">
ローカルからファイルを指定するとS3にアップロードされます。
<img width="1270" alt="CloudFormation_-_Stack_create.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/6a997070-4f51-96d1-2d24-9575b783f0ea.png">
<img width="1270" alt="CloudFormation_-_Stack_create-2.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/6053bab7-8ea1-d633-8770-16f155970c70.png">
<img width="1276" alt="スクリーンショット 2018-11-09 12.07.44.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/03724b33-bd8e-6715-b259-bbe71a647928.png">
<img width="1276" alt="スクリーンショット_2018-11-09_12_07_53.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/3518fda5-4f6c-55e6-abfa-90dbd17dc575.png">
<img width="1276" alt="スクリーンショット 2018-11-09 12.08.29.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/f04b43b0-7940-bbf1-1a80-1b4a77aaa72f.png">
最後に、テンプレート内でロール作成の定義があるから気をつけてねと確認がでてきます。
AWS CLIで作成したときの```--capabilities CAPABILITY_IAM``` と同じ確認ですね。
<img width="1275" alt="スクリーンショット_2018-11-09_12_08_38.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/e81f7dd7-3673-436f-3505-e22c662967a8.png">




## ユーザープールの確認

Cognitoでユーザープールが作成されたか確認してみます。
<img width="1270" alt="ユーザープール_-_Amazon_Cognito-2.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/747c25a4-0024-8026-eb14-912aeb51b3e7.png">
<img width="1275" alt="スクリーンショット 2018-11-09 12.10.54.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/5c9ec493-ce4f-3eee-3850-7ef9f67ce38e.png">
<img width="1273" alt="スクリーンショット 2018-11-09 12.11.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/31342ee1-a7d6-8926-3885-ba9643e073f0.png">
はい。
ちゃんとMFA有効化されて、ユーザーも作成されています。

やったぜ。

最後に、スタックを削除して、関連するリソースが削除されることを確認しておきます。
注意点としては、テンプレートで定義した各リソースは削除されますが、S3にアップロードされたテンプレートファイルや、Lambda関数のログは残ったままになりますので、そちらは手動で削除することになります。(未確認)

```sh
> aws cloudformation delete-stack \
  --stack-name cognito-totp-mfa-user-pool

> aws cloudformation describe-stacks \
  --stack-name cognito-totp-mfa-user-pool

An error occurred (ValidationError) when calling the DescribeStacks operation: Stack with id cognito-totp-mfa-user-pool does not exist
```

## まとめ

CFnを利用することで、AWS マネジメントコンソールやスクリプトを書くことなくAWSのリソースを作成・管理できることがわかりました。カスタムリソースを利用することで、より柔軟なリソース管理もできるので、応用がとても効きます。

いままで使ったことがなかったのですが、環境構築の再現性も非常に高いので、AWSを利用するのに必須といっていいかもしれません。

## 参考

CloudFormation で Cognito
https://qiita.com/y13i/items/1923b47079bdf7c44eec

Amazon Cognito Now Supported by AWS CloudFormation
https://aws.amazon.com/jp/about-aws/whats-new/2017/04/amazon-cognito-now-supported-by-aws-cloudformation/

Amazon Cognito Resource Types Reference
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-reference-cognito.html

Lambda-backedカスタムリソースの雛形
https://www.weblog-beta.com/posts/custom-resource/

Amazon Cognitoのワンタイムパスワード(TOTP)認証をNode.jsで試してみた
https://qiita.com/kai_kou/items/aa7dc0042b44649f576a

PythonでAmazon Cognitoのユーザープールを作成してみる
https://qiita.com/kai_kou/items/f1623ac1f3d7aaab23fe

AWS CloudFormation （設定管理とオーケストレーション） | AWS
https://www.google.co.jp/search?q=CloudFormation

CloudFormation超入門
https://dev.classmethod.jp/beginners/chonyumon-cloudformation/

AWS::Cognito::UserPool - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cognito-userpool.html

AWS Lambda-backed カスタムリソース - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/template-custom-resources-lambda.html

AWS Lambda 関数コード - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html

Lambda-backedカスタムリソースの雛形
https://www.weblog-beta.com/posts/custom-resource/

CloudFormationでLambdaを実行する
https://prgrmmbl.com/2018/07/01/Lambda-With-CloudFormation.html

Lambda-backed Custom Resourceのcfn-responseモジュールを利用する上での注意点
https://dev.classmethod.jp/cloud/aws/note-about-using-delete-response-with-cfn-response-module-in-lambda-backed-custom-resource/

AWS CLIのインストール
https://qiita.com/yuyj109/items/3163a84480da4c8f402c

Requires capabilities : [CAPABILITY_IAM]
https://github.com/awslabs/serverless-application-model/issues/51

create-stack - AWS CLI 1.16.51 Command Reference
https://docs.aws.amazon.com/cli/latest/reference/cloudformation/create-stack.html