【Node.js / Express】(メール検証付き) ユーザー登録APIを実装

![giphy (12).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/eb93a14a-2f9c-a08e-6d5e-95919bce653e.gif)

## 目標
- ユーザー登録ができる
- ユーザー登録時、ユーザーのメールアドレス宛にアカウント有効化を促すメールを送信
- メールに添付されたリンクを踏むことで、アカウント登録を完了とする

## 備考・前提
- メールの送信には、Gmailを使用します
- メール送信用のGoogleアカウントを用意してください
- 本記事ではORMにTypeORMを使用しています
- 以下のカラムを含む`users`テーブルを作成してください

| カラム | 型 | 保存するデータ |
| ---- | ---- | ---- |
| name | varchar | ユーザーネーム |
| email | varchar | Eメールアドレス |
| password | varchar | パスワード |
| is_verified | bool | 検証フラグ |

- 以下のカラムを含む`verification_tokens`テーブルを作成してください

| カラム | 型 | 保存するデータ |
| ---- | ---- | ---- |
| token | varchar | 検証トークン |
| user_id | uuid | 外部キー > users |


## 実装準備

### Googleアプリパスワードの生成
今回、メール送信にGmailを使用します。その際、使用するGoogleアカウントの「アプリパスワード」が必要になります。

アプリパスワードを生成するには、アカウントの2段階認証を有効にする必要があります。

Googleのトップ画面右上に表示されている自分のプロフィールアイコンを押下し、「Google アカウントを管理」を選択してください。

アカウント管理ページのサイドメニューから「セキュリティ」を選択します。

![Screen Shot 2023-12-11 at 2.21.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/8e751b17-deec-5092-1923-fc3bd39f209f.png)


「Google にログインする方法」セクションにある「2段階認証プロセス」に遷移して、手順に従って入力すると、2段階認証が有効になります。
> [2 段階認証プロセスを有効にする](https://support.google.com/accounts/answer/185839?hl=ja&co=GENIE.Platform%3DAndroid)

![Screen Shot 2023-12-11 at 12.29.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/40575cd9-6b09-40e6-2f0f-2918d9257d6e.png)

2段階認証が有効になると、アプリパスワードの生成が可能になります。

アカウント管理の上の検索ボックスに「アプリパスワード」と入力すると、以下のように候補が出てきます。

![Screen Shot 2023-12-11 at 2.09.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/507e1f84-cf5a-c4e4-53ef-b733e303a58f.png)

「アプリパスワード」に遷移すると、以下のようなフォームが表示されます。「App name」に任意のアプリ名を入力して、「作成」ボタンを押下してください。

![Screen Shot 2023-12-11 at 2.11.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/cb6cd15d-3847-4927-4980-c3017078bd84.png)

正常終了すると、アプリパスワードが表示されます。表示されたパスワードは後ほど使用しますので、コピーしておいてください。

:::note warn
使用しているアカウントが、Googleファミリーリンクを使用していて親アカウントの管理下にある場合、アプリパスワードの作成でエラーが発生します。
:::

> [アプリ パスワードでログインする](https://support.google.com/accounts/answer/185833?hl=ja&sjid=15052361244457678515-AP)

## 実装

### ライブラリのインストール
- [nodemailer](https://www.npmjs.com/package/nodemailer)：Node.js アプリケーションからメールを送信するライブラリ。アカウント有効化を促すメールを送信する際に使用
- [uuid](https://www.npmjs.com/package/uuid)：UUIDを生成するライブラリ。検証トークンを生成する際に使用
```bash
npm install nodemailer
npm install -D @types/nodemailer
npm install uuid
npm install -D @types/uuid
```

### ユーザー登録APIを実装

リクエストボディで渡されたユーザー情報を元にユーザーを登録し、アカウント有効化メールを送信するAPIを作成します。

```javascript
router.post("/signup", AuthController.signup);
```

`AuthController.signup`にて実処理を定義してください。大きく以下のような処理の流れになります。複数の登録処理を含む為、トランザクションを設定しています。

1. リクエストボディでユーザーネーム（`name`）、Eメール（`email`）、パスワード（`password`）を受け取り、それを元にユーザーテーブル（`users`）にデータを登録します。検証フラグ（`is_verified`）は固定で`FALSE`に設定しておきます

2. 検証トークンを作成します。作成したユーザーのIDと生成したトークンの組み合わせを、検証トークンテーブル（`verification_tokens`）に登録します

3. `createTransport`メソッドを使用してnodemailerの初期化をします。オプションの`service`にはGmailを指定し、`auth.user`に送信用メールアドレス、`auth.pass`には[Googleアプリパスワードの生成](https://qiita.com/whopper1962/items/85f11e19cf30ec30ce61#google%E3%82%A2%E3%83%97%E3%83%AA%E3%83%91%E3%82%B9%E3%83%AF%E3%83%BC%E3%83%89%E3%81%AE%E7%94%9F%E6%88%90)で生成したアプリパスワードを指定します


4. `sendMail`メソッドを使用してメール送信を実行します。オプション`from`に送信用メールアドレス、`to`に送信先メールアドレス（作成したユーザーのメールアドレス）、`subject`に件名、`text`にメール本文を指定します。メール本文には、手順2で生成したトークンを使用したリンクを添付します


```javascript
import { NewUser } from "@/types/users";
import { NextFunction, Request, Response } from "express";
import { EntityManager } from "typeorm";
import bcrypt from "bcrypt";
import { User as UserEntity } from "@/models/User.entity";
import { appDataSource } from "@/database/appDataSource";
import nodemailer from "nodemailer";
import { v4 as uuidv4 } from "uuid";
import { VerificationToken } from "@/models/VerificationToken.entity";

export class AuthController {

  static async signup(
    request: Request<any, any, NewUser>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      const newUser = request.body;
      const usersRepository = appDataSource.getRepository(UserEntity);
      const tokensRepository = appDataSource.getRepository(VerificationToken);

      await appDataSource.manager.transaction(
        async (transactionalEntityManager: EntityManager) => {
          const existsUser = await usersRepository.findOne({
            where: {
              email: newUser.email,
            },
          });
          if (existsUser) throw new Error("User already exists.");

          const hashedPassword = await bcrypt.hash(newUser.password, 10);

          const newUserEntity = usersRepository.create({
            name: newUser.name,
            email: newUser.email,
            password: hashedPassword,
            isVerified: false,
          });

          await transactionalEntityManager.save(newUserEntity);

          const verificationToken = uuidv4();

          const newTokenEntity = tokensRepository.create({
            token: verificationToken,
            user: newUserEntity,
          });

          await transactionalEntityManager.save(newTokenEntity);

          const mailTransporter = nodemailer.createTransport({
            service: "gmail",
            auth: {
              user: "sample@gmail.com", // 送信用のメールアドレス
              pass: "aaaa bbbb cccc dddd", // アプリパスワード
            },
          });

          await mailTransporter.sendMail({
            from: "sample@gmail.com", // 送信用のメールアドレス
            to: newUser.email, // 送信先メールアドレス
            subject: "アカウント有効化",
            text: `
          ${newUser.name}さん、こんにちは!
          以下のリンクからアカウント登録を完了させてください。
          http://localhost:3980/users/verify-email/${newUserEntity.id}/${verificationToken}
          `,
          });
        },
      );

      response.status(201).send("User successfully created.");
    } catch (e) {
      next(e);
    }
  }
}
```

### アカウント有効化APIを実装
アカウント有効化メールに添付されたリンクへアクセスした際に実行されるAPIを作成します。

パスパラメータでユーザーID（`userId`）と検証トークン（`verificationToken`）を受け取ります。
```javascript
router.get(
  "/users/verify-email/:userId/:verificationToken",
  AuthController.verifyEmail,
);
```
`AuthController.verifyEmail`にて実処理を定義します。大きく以下のような処理の流れになります。

1. 受け取ったパスパラメータを元に検証トークンテーブル（`verification_tokens`）から対象のトークンが存在するかチェックします
2. 対象のトークンが存在する場合、対象のユーザーの検証フラグ（`is_verified`）をTRUEに切り替えます

```javascript
import { NewUser, VerificationInfo } from "@/types/users";
import { NextFunction, Request, Response } from "express";
import { EntityManager } from "typeorm";
import bcrypt from "bcrypt";
import { User as UserEntity } from "@/models/User.entity";
import { appDataSource } from "@/database/appDataSource";
import nodemailer from "nodemailer";
import { v4 as uuidv4 } from "uuid";
import { VerificationToken } from "@/models/VerificationToken.entity";

export class AuthController {
  static async signup(
    // 略...
  }

  static async verifyEmail(
    request: Request<VerificationInfo>,
    response: Response<string>,
    next: NextFunction,
  ): Promise<void> {
    try {
      const verificationInfo = request.params;
      const usersRepository = appDataSource.getRepository(UserEntity);
      const tokensRepository = appDataSource.getRepository(VerificationToken);

      const token = await tokensRepository.findOne({
        where: {
          token: verificationInfo.verificationToken,
          user: {
            id: verificationInfo.userId,
          },
        },
      });

      if (!token)
        throw new Error(
          "Your verification link may have expired. Please click on resend for verify your Email.",
        );

      const user = await usersRepository.findOne({
        where: {
          id: verificationInfo.userId,
        },
      });
      if (!user) throw new Error("User not found.");

      if (user.isVerified)
        throw new Error("User has been already verified. Please Login");

      user.isVerified = true;
      await usersRepository.save({
        ...user,
        isVerified: true,
      });

      response.status(200).send("Your account has been successfully verified");
    } catch (e) {
      next(e);
    }
  }
}
```

## 動作確認
Postmanを使用して実際にAPIを実行してみます。リクエストボディは以下のように指定してください。
```json
{
    "name": "whopper",
    "email": "sample@gmail.com",
    "password": "password"
}
```

![Screen Shot 2023-12-12 at 7.10.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/f49f336f-b823-4451-7578-276e78df2a0a.png)

ユーザーテーブル（`users`）と検証トークンテーブル（`verification_tokens`）を確認すると、それぞれ以下のようにデータが登録されています。
この時点では、ユーザーの検証フラグ（`is_verified`）が`FALSE`であることを確認できます。
![Screen Shot 2023-12-12 at 7.15.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/11295910-e16e-725d-c85f-c3f904cfe820.png)
![Screen Shot 2023-12-12 at 7.16.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/58c90c2a-5e75-e509-02da-14b520c5404b.png)

また、指定したメールアドレスに以下のようなメールを送信されます。

![Screen Shot 2023-12-12 at 7.12.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/95696054-b43d-5471-2c42-2d059eb752fc.png)

添付されているリンクを踏むと、以下のような画面に移ります。

![Screen Shot 2023-12-12 at 7.18.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/f363d4fa-1d00-4525-9147-6baffcb83d09.png)

もう一度、登録されたユーザーを確認すると、検証フラグ（`is_verified`）が`TRUE`に切り替わっていることが確認できます。

![Screen Shot 2023-12-12 at 7.19.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/b752d5c2-3218-db2b-06cd-ba257d3d3521.png)

ユーザー登録APIが実装できました👏

![giphy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2356280/5131e4be-e10c-eb72-d353-c0fec69eaa5c.gif)