【React/Go】ユーザー認証をCognito + Amplifyで構築してみた ~ユーザ登録/削除編~

## はじめに
Reactで作成したWebアプリケーションのユーザー認証部分をCognito + Amplifyフレームワークで構築してみました。構築の基本部分については「【React】ユーザー認証をCognito + Amplifyで構築してみた」の[構築準備編](https://qiita.com/maika_kamada/items/1289b8926208d35751b1)と[構築完成編](https://qiita.com/maika_kamada/items/bd55a9fa3dd551d63b94)をご覧ください。
本記事は、アプリケーションからユーザーを登録、削除する方法についてまとめています。

### 完成画面
今回は、アプリケーションからユーザーを登録すると、ユーザープールとDBそれぞれにユーザーが登録されて、画面には「ユーザーを登録しました。」というアラートが出力されるようにします。

![スクリーンショット 2020-12-10 19.15.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/31cc4848-5eee-33cc-d9ad-0467db2a60e4.png)


[Submit]をクリックすると↓↓↓

**画面**
![スクリーンショット 2020-12-10 19.15.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/b6ed2b23-78eb-1103-0c72-2a815be972ae.png)



**Cognitoユーザープール管理画面**
![スクリーンショット 2020-12-06 18.55.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/ae7aa65f-6f90-eef0-c974-0bb6823c90c9.png)

**DB（userテーブル）**※一部抜粋

```
+----+-----------+------------------+
| id | user_name | email            |
+----+-----------+------------------+
|  2 | test      | test@example.com |
+----+-----------+------------------+
```


## 方法検討
### 要件
構築方法を考えるにあたり、条件は以下の通りです。

+ 静的コンテンツをS3に置いている
+ アプリケーション部分はLambda + RDS Proxy + RDSで実装している
+ ユーザーデータはCognitoユーザープール以外に、RDSに保存している
+ **NATゲートウェイはコストが高いので使いたくない**

### 現在の構成図（ユーザー認証付加前）
ユーザー認証付加前のアプリケーション部分の構成図は下記の通りです。

![Cognito-before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/adbb0043-195e-4af7-05c1-ac06d759f895.png)


### VPC Lambdaによる弊害
ここで、LambdaをVPC内に設置していることで、Cognitoにアクセスできないことに気付きました。パブリックサブネットに置いているんだから、アクセスできると勝手に思っていました。

[AWS開発者ガイド](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-vpc.html)によると、次のように説明されています。

> プライベートリソースにアクセスするには、関数をプライベートサブネットに接続します。関数にインターネットアクセスが必要な場合は、ネットワークアドレス変換 (NAT) を使用します。関数をパブリックサブネットに接続しても、インターネットアクセスやパブリック IP アドレスは提供されません。

Lambda関数をパブリックサブネットに接続しても、インターネットアクセスやパブリック IP アドレスは提供されないんです。NATゲートウェイを使用する場合にもLambda関数はプライベートサブネットに置くべきだそうです。パブリックサブネットにLambdaを置いておくメリットはなさそうなので、**VPC Lambdaはプライベートサブネットに置きましょう！！！**

### 結論
この条件に沿ってアプリケーションの登録、削除処理を考えた結果、VPC Lambdaをプライベートサブネットに移動させ、NATゲートウェイは使いたくないので、強引にLambdaからLambdaを呼び出すことにしました。

### シーケンス図
シーケンス図を書くと次のようになります。

![スクリーンショット 2020-12-15 20.00.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/fa718fb8-6371-27c7-22d4-705f29862175.png)


### 構成図（ユーザー認証付加後）
構成は下図の通りになりました。

![Cognito-after (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/6c18d1f3-5688-5431-e38c-6e928d7dceb9.png)


## 手順
下記の流れで進めていきます。

1. RDSを更新するLambda関数：Lambda(VPC) の作成
2. Cognitoを更新するLambda関数：Lambda(非VPC) の作成
3. API Gatewayの作成
4. フロントの実装

## ユーザーを登録する
### 1. DBを更新するLambda関数：Lambda(VPC) の作成
Lambda(非VPC)の作成時につけるIAMロールにLambda(VPC)のarnが必要なので、先にLambda(VPC)から作成します。
VPC内に設置してRDSに情報を書き込むLambdaを作成していきます。このLambdaに関しては、RDSにデータが保存できれば良く、特に既存のLambdaと変わりないので割愛します。
[祝GA‼︎【Go】Lambda + RDS 接続にRDS Proxyを使ってみた](https://qiita.com/maika_kamada/items/6eb6a40c17b4b8995acb)の「8. Lambda関数の作成」を参考に作成しました。

#### ソースコード
ソースコードはこのような感じです。
※↓クリックするとソースコードが見れます。


<details><summary>ソースコード</summary><div>

```lambda_vpc.go

package main

import (
	"database/sql"
	"fmt"
	"github.com/aws/aws-lambda-go/lambda"
	_ "github.com/go-sql-driver/mysql"
	"os"
)

type MyEvent struct {
	UserName string `json:"userName"`
	Email    string `json:"email"`
}

// os.Getenv（）でLambdaの環境変数を取得
var dbEndpoint = os.Getenv("dbEndpoint")
var dbUser = os.Getenv("dbUser")
var dbPass = os.Getenv("dbPass")
var dbName = os.Getenv("dbName")

func RDSConnect() (*sql.DB, error) {
	connectStr := fmt.Sprintf(
		"%s:%s@tcp(%s:%s)/%s?charset=%s",
		dbUser,
		dbPass,
		dbEndpoint,
		"3306",
		dbName,
		"utf8",
	)
	db, err := sql.Open("mysql", connectStr)
	if err != nil {
		return nil, err
	}
	return db, nil
}

func RDSProcessing(event MyEvent, db *sql.DB) (interface{}, error) {

	tx, err := db.Begin()
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	// ユーザーテーブルに情報を登録
	stmt, err := tx.Prepare("INSERT INTO user(user_name, email) VALUES (?, ?) ")
	if err != nil {
		return nil, err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(event.UserName, event.Email); err != nil {
		return nil, err
	}

	if err := tx.Commit(); err != nil {
		return nil, err
	}

	response := "正常に処理が完了しました。"
	return response, nil
}

func run(event MyEvent) (interface{}, error) {
	fmt.Println("RDS接続 start!")
	db, err := RDSConnect()
	if err != nil {
		fmt.Println("DBの接続に失敗しました。")
		panic(err.Error())
	}
	fmt.Println("RDS接続 end!")
	fmt.Println("RDS処理 start!")
	response, err := RDSProcessing(event, db)
	if err != nil {
		fmt.Println("DB処理に失敗しました。")
		panic(err.Error())
	}
	fmt.Println("RDS処理 end!")
	return response, nil
}

/**************************
   メイン
**************************/
func main() {
	lambda.Start(run)
}
```

</div></details>

### 2. Cognitoを更新するLambda関数：Lambda(非VPC) の作成
VPCの外に置いて、Cognitoユーザープールへの登録とRDSを更新するLambda(VPC)を実行するLambdaを作成していきます。
#### IAMロール
下記の2つの権限をつけたポリシーを作成してアタッチします。

+ Cognitoユーザープールにユーザーを登録/削除する権限
+ Lambda(VPC)を実行する権限


```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "cognito-idp:AdminDeleteUser",
                "cognito-idp:AdminCreateUser"
            ],
            "Resource": "＜Cognitoのarn＞"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "lambda:InvokeFunction",
            "Resource": "＜Lambda(VPC)のarn＞"
        }
    ]
}
```


#### ソースコード

```lambda_no_vpc.go

package main

import (
	"encoding/json"
	"fmt"
	"os"

	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
	"github.com/aws/aws-sdk-go/service/cognitoidentityprovider/cognitoidentityprovideriface"
	l "github.com/aws/aws-sdk-go/service/lambda"
	"github.com/aws/aws-sdk-go/service/lambda/lambdaiface"
)

type MyEvent struct {
	UserName string `json:"userName"`
	Email    string `json:"email"`
}

func AddCognitoUser(svc cognitoidentityprovideriface.CognitoIdentityProviderAPI, event MyEvent) error {

	// 登録時にユーザーにメール送信
	desiredDeliveryMediums := []*string{aws.String("EMAIL")}

	// メールアドレスとメールアドレス検証済みを設定
	userAttributes := []*cognitoidentityprovider.AttributeType{
		{
			Name:  aws.String("email"),
			Value: aws.String(event.Email),
		},
		{
			Name:  aws.String("email_verified"),
			Value: aws.String("true"),
		},
	}

	// ユーザープールの設定
	// os.Getenv（）でLambdaの環境変数を取得
	userPoolId := aws.String(os.Getenv("userPoolId"))

	// ユーザー名の設定
	username := aws.String(event.UserName)

	// Inputの作成
	input := &cognitoidentityprovider.AdminCreateUserInput{}
	input.DesiredDeliveryMediums = desiredDeliveryMediums
	input.UserAttributes = userAttributes
	input.UserPoolId = userPoolId
	input.Username = username

	// 処理実行
	result, err := svc.AdminCreateUser(input)
	if err != nil {
		fmt.Println(err.Error())
		return err
	}

	fmt.Println(result)
	return nil
}

func AddDbUser(svc lambdaiface.LambdaAPI, event MyEvent) error {

	// ValidateLambdaに送る情報の作成
	jsonBytes, _ := json.Marshal(event)

	// Inputの作成
	input := &l.InvokeInput{}
	input.FunctionName = aws.String(os.Getenv("arn"))
	input.Payload = jsonBytes
	input.InvocationType = aws.String("RequestResponse") // 同期実行

	// 処理実行
	result, err := svc.Invoke(input)
	if err != nil {
		fmt.Println(err.Error())
		return err
	}

	fmt.Println(result)
	fmt.Println(string(result.Payload))

	return nil
}

func run(event MyEvent) (interface{}, error) {
	fmt.Println("Cognito登録 start!")
	// セッション作成
	csvc := cognitoidentityprovider.New(session.Must(session.NewSession()))

	if err := AddCognitoUser(csvc, event); err != nil {
		fmt.Println("ユーザー登録に失敗しました。")
		panic(err.Error())
	}
	fmt.Println("Cognito登録 end!")

	fmt.Println("db登録 start!")
	// セッションの作成
	lsvc := l.New(session.Must(session.NewSession()))

	if err := AddDbUser(lsvc, event); err != nil {
		fmt.Println("ユーザー登録に失敗しました。")
		panic(err.Error())
	}
	fmt.Println("db登録 end!")

	fmt.Println("end!")
	response := "正常に処理が完了しました。"
	return response, nil
}

/**************************
   メイン
**************************/
func main() {
	lambda.Start(run)
}
```


### 3. API Gatewayの作成

REST APIでPOSTメソッドを作成し、Lambda(非VPC)を紐付けます。
特に特別な設定は不要なので省略します。

### 4. フロントの実装
登録画面を作成します。今回は、ユーザー名とメールアドレスが必須項目なので、その2つを登録できる入力欄と登録ボタンを簡単に作成しています。登録が完了すると「ユーザーを登録しました。」というアラートが出ます。

#### axiosのインストール
API Gatewayを叩くのに`axios`を使うために、プロジェクトに`axios`を追加します。

```
$ yarn add axios
```

#### ソースコード

`axios`の使い方は[axiosライブラリを使ってリクエストする](https://qiita.com/reflet/items/d5658d5d69e8e1ccd489)を参考にしました。

```RegistrationForm.js

import React from "react";
import axios from "axios";


function RegistrationForm() {
    const API_ADD_URL = "＜API Gatewayで取得したURL＞"
    const [userName, setUserName] = React.useState("");
    const [email, setEmail] = React.useState("");

    const handleNameChange = event => {
        setUserName(event.target.value);
    };

    const handleEmailChange = event => {
        setEmail(event.target.value);
    }

    const handleSubmit = event => {
        axios.post(API_ADD_URL, {userName: userName, email: email})
            .then((response) => {
                if(response.data === "正常に処理が完了しました。"){
                    alert("ユーザーを登録しました。")
                    console.log(response);
                } else {
                    throw Error(response.data.errorMessage)
                }
            }).catch((response) => {
            alert("登録に失敗しました。もう一度登録してください。");
            console.log(response);
        });
        event.preventDefault();
    }

    return (
        <div>
            <h2>ユーザー登録</h2>
            <form onSubmit={handleSubmit} >
                <label >
                    ユーザー名:
                    <input type="text" value={userName} onChange={handleNameChange} /><br/>
                </label>
                <label >
                    Eメール:
                    <input type="text" value={email} onChange={handleEmailChange} /><br/>
                </label>
                <input type="submit" value="Submit" />
            </form>
        </div>
    );
}

export default RegistrationForm;
```


## ユーザーを削除する
ユーザーから削除する場合も、基本的に登録するのと同じです。ユーザープールから削除するにはSDKの`AdminDeleteUser`を使用します。

## 実行結果
無事冒頭の[完成画面](#完成画面)のように動くようになりました！

![fh4o1-lwya2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/9045fd07-079b-8d59-e1e2-5e9ae46cf16a.gif)



## おわりに
LambdaからLambdaを実行することで、NATゲートウェイを使わずにCognitoとDBの両方にユーザーを登録することができました！今考えると、Cognitoユーザープールに登録するのはAmplifyで[Admin Queries API](https://docs.amplify.aws/cli/auth/admin)を使うようにして、DBに保存するのは既存のようにLambdaを呼び出すようにするのでも良かったかなとも思います！
次回は、サインインページにある、使用しないアカウント作成ボタンを消したいと思います！