Next.jsでfirebase adminを使ってメール認証本文をカスタムする

Firebase Authenticationという大変便利なサービスがある。ユーザー認証とデータ保存が簡単にできるようになる。


https://firebase.google.cn/docs/auth?hl=ja

使い方の記事もたくさんある

- [React + Firebaseで認証機能を実装してみよう](https://qiita.com/tatsumin0206/items/a115921a5f9438b2fe9d)
- [完全版】ReactのFirebase Authentication(認証)を基礎からマスターする](https://reffect.co.jp/react/react-firebase-auth)

ユーザー登録時のよくあるUXとしては、

- メールアドレスとパスワード登録
- メールアドレスに認証リンクが届く
- クリックすると、ログイン後

これは、新規登録したいメールアドレス宛にアクティベーションメールを送信する。アクティベーションメールは、メール中にリンクを張りそのリンク先にアクセスされたら認証するというもの。有効なメールアドレスかどうかを検証するスタンダードな方法である。

Firebaseでもこの機能が提供されているのだが、**Firebaseでは、スパム防止のため認証メールのメール本文を編集できない**（Firebaseではテンプレートと呼ばれる）。

https://try2explore.com/questions/11580247

日本語のテンプレートは現在以下のようになっている。`DISPLAY_NAME`は値を渡さなければ、その行が表示されないので、「様」だけが文頭に表示されるということはないのでご安心を。

```
%DISPLAY_NAME% 様

メールアドレスを確認するには、次のリンクをクリックしてください。

https://<firebase project id>.firebaseapp.com/__/auth/action?mode=action&oobCode=code

このアドレスの確認を依頼していない場合は、このメールを無視してください。

よろしくお願いいたします。

%APP_NAME% チーム
```

このメールの文章を変更したいというのが今回の記事の内容。

Next.jsを使っておこなっていく。

# プロジェクトの作成

`create-next-app`でプロジェクトを作成する。

# APIエンドポイントを作ってクライアントサイドから疎通する

## APIサーバーを立てる

NodeのWebフレームワークを導入する。

```
yarn add express
```

`server`というフォルダを作成して、`server/index.js`をバックエンドのWeb server用のメインファイルとする。

```server/index.js
const express = require("express");
const next = require("next");

const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
const port = process.env.PORT || 3000;

app.prepare().then(() => {
  const server = express();

  server.all("*", (req, res) => {
    return handle(req, res);
  });
  server.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on localhost:${port}`);
  });
});
```

エンドポイントとしてPOSTメソッドを追加する。

```server/index.js
app.prepare().then(() => {
  const server = express();

  // 追加
  server.post("/email", (req, res) => {
    res.send("email endpoint called");
  });

  server.all("*", (req, res) => {
    return handle(req, res);
  });
  server.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on localhost:${port}`);
  });
});

```


```
curl -X POST localhost:3000/email       
```

とすると以下レスポンスが返ってくる。

```
$ curl -X POST localhost:3000/email 
email endpoint called%                          
```

ひとまず、 APIエンドポイントを用意できた。

## クライアントサイドからFetchでAPIエンドポイントを叩く

先ほど作った`server/index.js`のコードを多少変更する。

```server/index.js
  server.post("/email", (req, res) => {
    console.log("email endpoint called");
    res.json({
      message: "Hello,world",
    });
  });
```

`src/index.js`のjsxに以下のコードを追加する。

```src/index.js
        <button onClick={() => post()}>ポストする</button>
```

post関数は以下

```src/index.js
  const post = () => {
    fetch("http://127.0.0.1:3000/email", {
      headers: {
        "Content-Type": "application/json; charset=utf-8",
      },
      method: "POST",
      body: JSON.stringify({ hoge: "fuga" }),
    })
      .then((res) => {
        return res.json();
      })
      .then((data) => {
        console.log(data);
      });
  }
```

![スクリーンショット 2021-09-14 9.39.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/6754aa7f-68ea-441f-1516-1d554a35a0c1.png)

ボタンをクリックしてみるとエラーが出る。

![スクリーンショット 2021-09-14 9.39.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/980e604e-e1fc-30f0-12a6-08cb034acf7d.png)

オリジン間リソース共有(CORS: Cross-Origin Resource Sharing)の制限によりエラーが発生していることがわかる。そこで`cors`を導入する。

https://zenn.dev/luvmini511/articles/d8b2322e95ff40

```
yarn add cors
```

`cors`を利用するように変更

```server/index.js
app.prepare().then(() => {
  const server = express();

  server.use(cors());

  server.post("/email", (req, res) => {
    console.log("email endpoint called");
    res.json({
      message: "Hello,world",
    });
  })
  // .. 略
})
```

修正完了した。
クリックすると、API Endpointのjsonがクライアントサイドで取得できていることがわかる。

![スクリーンショット 2021-09-14 9.50.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/87b557d5-c08f-84b8-a95e-fefcb24ff933.png)

ここまでで、Next.jsのクライアントサイドとバックエンドがNext.jsのAPIサーバーの機能を通じで疎通するところまで確認できた。


# Firebase関連のコードを書いていく

ここから何をやっていくかざっくりと考えると

- クライアントサイドで、ログイン前/ログイン後画面を作る
    - firebaseで認証する
- バックエンドでログイン情報を受け取って、メールを取得。それを使って認証リンクを送信する

## クライアントサイド

まずクライアントサイドから取り組む。Reactでのfirebaseの導入は以下がわかりやすい。

https://reffect.co.jp/react/react-firebase-auth#Firebase

`firebase`はクライアントサイド、`firebase-admin`はバックエンドで利用する。

```
yarn add firebase firebase-admin
```

### クライアントサイドの認証情報

ルートに`firebase.js`を作成する。またFirebaseの設定情報は`.env`に保存する。


```firebase.js
import * as firebase from "firebase/app";
import "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
};

if (firebase.apps.length === 0) {
  firebase.initializeApp(firebaseConfig);
}

export const auth = firebase.auth();
```

注意点としては、環境変数名はなんでもいいわけではない。ブラウザでプロジェクトの環境変数を取り込むには、`NEXT_PUBLIC_`が必要。

> In order to expose a variable to the browser you have to prefix the variable with NEXT_PUBLIC_

https://nextjs.org/docs/basic-features/environment-variables#exposing-environment-variables-to-the-browser


### ユーザー新規登録画面/ログイン後画面を作る

Firebase Authを使うにあたって、

- ユーザー新規登録画面: `pages/index.js`
- ログイン後画面: `pages/home.js`

を作成する。ログイン後画面では、ログアウトの処理を追加する。

どうしてこういうことをするかというと、 Firebase Authでのログイン/セッション情報はブラウザのIndexedDBに保存されてる。これが残ったままFirebase Authからユーザー登録情報を削除すると、Firebaseにはユーザー情報がないのにブラウザにはそれが残ったままとなり誤作動してしまうため。

`pages/index.js`

```pages/index.jsx
import React, { useState } from "react";
import styles from "../styles/Home.module.css";

import { auth } from "./firebase";

import router from "next/router";

export default function Home() {
  const [email, setEmail] = useState("");
  const [password, setpassword] = useState("");

  const handleSubmit = (event) => {
    event.preventDefault();

    auth
      .createUserWithEmailAndPassword(email, password)
      .then(() => {
        auth.onAuthStateChanged((user) => {
          if (user) {
            // 認証メールを送る場合はこの関数を使う。
            // user.sendEmailVerification()
          }
        });
      })
      .then(() => {
        router.push("/home");
      })
      .catch((error) => {
        alert(error);
      });
  };

  return (
    <div>
      <main className={styles.main}>
        <form onSubmit={handleSubmit}>
          <label htmlFor="email">Email</label>
          <input
            type="email"
            id="email"
            aria-describedby="emailHelp"
            required
            name="email"
            onChange={(event) => setEmail(event.target.value)}
          >
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            aria-describedby="passwordHelp"
            placeholder=""
            required
            name="password"
            value={password}
            onChange={(event) => setpassword(event.target.value)}
          >
          <button type="submit">Register</button>
        </form>
      </main>
    </div>
  );
}
```

アカウント作成後の画面も作る。`pages/home.js`とする。

```pages/home.jsx
import React, { useState, useEffect } from "react";

import { auth } from "./firebase";

import router from "next/router";

export default function Home() {
  const [user, setUser] = useState(null);
  useEffect(
    () =>
      auth.onAuthStateChanged((user) => {
        if (user) {
          console.log(user);
          setUser(user);
        } else {
          router.push("/");
        }
      }),
    []
  );

  const handleLogout = () => {
    auth.signOut();
    router.push("/");
  };
  return (
    <div>
      <h1>ログイン後画面</h1>
      {user && (
        <>
          <div>{user.email}</div>
          <div>Email Verified: {user.emailVerified ? "yes" : "no"}</div>
        </>
      )}

      <button onClick={handleLogout}>ログアウト</button>
    </div>
  );
}
```

ここまでのコードでは以下のように動く。

![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/8c726627-f086-2ea9-9a09-efd00f02ba1b.gif)

Firebase Authenticationから登録されていることがわかる。

![スクリーンショット 2021-09-14 11.19.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/7fdeedf3-1f76-ad79-ff4a-307269c16205.png)

## Expressサーバーで認証リンクを送信

ここからの要件としては

- クライアントサイドからメールアドレスを受け取る
- メールアドレスから認証リンクを生成（Firebase Admin SDK）
- 認証リンクを含むメールを送信（nodemailer）



## ExpressサーバーにEmailアドレスをPOSTで渡す

Express側で認証用のメールを送信するのでPOSTでメールアドレスを送る。

クライアントサイドの、ユーザー登録に関わるコードを以下のように変更する。


```pages/index.jsx
  const handleSubmit = (event) => {
    event.preventDefault();

    auth
      .createUserWithEmailAndPassword(email, password)
      .then(() => {
        fetch("http://127.0.0.1:3000/email", {
          headers: {
            "Content-Type": "application/json; charset=utf-8",
          },
          method: "POST",
          body: JSON.stringify({ email: email }),
        })
          .then((res) => {
            return res.json();
          })
          .then((data) => {
            console.log(data);
          });
      })
      .then(() => {
        router.push("/home");
      })
      .catch((error) => {
        const errorCode = error.code;
        const errorMessage = error.message;
        alert(`error code${errorCode} ${errorMessage}`);
      });
  };

```

バックエンドのコードで、POSTメソッドのrequest bodyが拾えるかどうかデバッグする。

```server/index.js
  server.post("/email", (req, res) => {
    console.log("req");
    console.log(req.body);
    console.log("email endpoint called");
    res.json({
      message: "Hello,world",
    });
  });
```

すると以下のように出力されていて取得できていないことがわかる。

```
event - build page: /
wait  - compiling...
event - compiled successfully
req
undefined
```

これはExpressが元々そういう挙動のためである。

> **req.body**
Contains key-value pairs of data submitted in the request body. By default, it is undefined, and is populated when you use body-parsing middleware such as express.json() or express.urlencoded().

https://expressjs.com/ja/4x/api.html#req.body

https://blog.hrendoh.com/handle-json-requests-and-responses-in-express/

`application/json`と`application/x-www-form-urlencoded`をパースするミドルウェアを追加する必要がある。

```server/index.js
app.prepare().then(() => {
  const server = express();

  // 追加
  server.use(express.json()); // for parsing application/json
  server.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded

  server.use(cors());

  server.post("/email", (req, res) => {
    console.log("req");
    console.log(req.body);
    console.log("email endpoint called");
    res.json({
      message: "Hello,world",
    });
  });
  // ..略
});
```

結果として、以下のように出力されるようになった。

```
req
{ email: '<メールアドレス>' }
email endpoint called
```

## 認証リンクを発行してメールを送る

まず、認証リンクを生成したいのでこの辺のことをおこなっていく。

https://firebase.google.com/docs/admin/setup?hl=ja


Firebaseの画面から認証キーのjsonファイルをダウンロードしてプロジェクトのルートに置いておく。このファイルは特に編集しない。

`server/index.js`で認証情報を読み込ませる必要がある。

以下の記事を参考にする。環境変数の`GOOGLE_APPLICATION_CREDENTIALS`に登録してもいいが、PCに環境変数を登録したくなかったので工夫する。

https://blog.ojisan.io/firebase-admin-init/

Expressからメールを送信したい。`nodemailer`を導入する。これはNode.jsからメール送信を可能にするモジュールで以下の記事を参考にした。

https://thundermiracle.com/blog/2020-06-13-smtp-gmail-by-nodemailer/


https://tech-blog.rakus.co.jp/entry/2017/09/07/110411


```
yarn add nodemailer
```

メールサーバー（STMPサーバー）が必要なので、gmailを利用する。googleアカウントを用意してアプリパスワードを発行する。これを使うと、そのgmailアカウントが送信元となる。

https://nw.myds.me/wprdpress-ad/google-app-password/

https://support.google.com/mail/answer/185833?hl=ja



サーバー側のコードは以下。

```server/index.js
const express = require("express");
const next = require("next");

const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
const port = process.env.PORT || 3000;

const cors = require("cors");

const admin = require("firebase-admin");
const nodemailer = require("nodemailer");
const serviceAccount = require("../<認証キー>.json");

const cert = {
  projectId: serviceAccount.project_id,
  clientEmail: serviceAccount.client_email,
  privateKey: serviceAccount.private_key.replace(/\\n/g, "\n"),
};

admin.initializeApp({
  credential: admin.credential.cert(cert),
  databaseURL: "https://<firebaseのプロジェクトID>.firebaseio.com",
});

const transporter = nodemailer.createTransport({
  service: "gmail",
  port: 465,
  secure: true, // SSL
  auth: {
    user: "<gmailアドレス>", // メールアドレス
    pass: "<アプリパスワード>", // アプリパスワード
  },
});

app.prepare().then(() => {
  const server = express();
  server.use(express.json()); // for parsing application/json
  server.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded

  server.use(cors());

  server.post("/email", (req, res) => {
    console.log("req");
    console.log(req.body);
    const { email } = req.body;
    try {
      admin
        .auth()
        .generateEmailVerificationLink(email)
        .then(async (emailLink) => {
          const uid = await (await admin.auth().getUserByEmail(email)).uid;
          console.log("uid");
          console.log(uid);
          console.log("emailLink");
          console.log(emailLink);
          await await transporter.sendMail({
            from: "test",
            to: email,
            subject: "This is the title.",
            html: `Hello, please click here.<br><a href="${emailLink}">${emailLink}</a>`,
          });

          return res.status(200).json({ status: "ok", message: "Email sent." });
        })
        .catch((error) => {
          return res.json(error);
        });
    } catch (error) {
      return res.json(error);
    }
  });

  server.all("*", (req, res) => {
    return handle(req, res);
  });
  server.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on localhost:${port}`);
  });
});

```

説明していくと、以下のコードで

- nodemailerでSTMPの設定
- firebase admin sdkの認証

をしている。

```js
const nodemailer = require("nodemailer");
const serviceAccount = require("../<認証キー>.json");

const cert = {
  projectId: serviceAccount.project_id,
  clientEmail: serviceAccount.client_email,
  privateKey: serviceAccount.private_key.replace(/\\n/g, "\n"),
};

admin.initializeApp({
  credential: admin.credential.cert(cert),
  databaseURL: "https://<firebaseのプロジェクトID>.firebaseio.com",
});

const transporter = nodemailer.createTransport({
  service: "gmail",
  port: 465,
  secure: true, // SSL
  auth: {
    user: "<gmailアドレス>", // メールアドレス
    pass: "<アプリパスワード>", // アプリパスワード
  },
});
```

Emailでの認証リンクは、`firebase.auth().generateEmailVerificationLink()`で発行できる。

https://firebase.google.com/docs/auth/admin/email-action-links


### 動作の確認

ユーザーアカウント作成すると、`home`画面に遷移する。

![スクリーンショット 2021-09-14 12.20.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/c21c7462-65da-537c-d308-22812ca376b5.png)

この間に、バックエンドのコンソールで以下のように出力される。

```
req
{ email: '<メールアドレス>' }
uid
IUPFZcyiRya5y3DIdqSs6A1CJAx1
emailLink
https://<firebase-project-id>.com/__/auth/action?mode=verifyEmail&oobCode=NI4UTUhfi9ZeHfvPg7t2dj04bC9x65TNj2HnR1Xsi1sAAAF74kqSzQ&apiKey=AIzaSyA9J-hMBpQkXViG6i2T52kIKLWX6w9cQpQ&lang=ja
```

登録時のメールアドレスに以下のようにメールが届いている。
![スクリーンショット 2021-09-14 12.19.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/7c0f5cd1-e9b7-cedb-02d2-89fff83886ed.png)

リンクをクリックすると、画面遷移してメールアドレスが認証される。

![スクリーンショット 2021-09-14 12.14.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/e18da9e0-b3a6-fa2f-0586-4947de33088e.png)



`home`画面に遷移してページをリロードしてみると、`user`オブジェクトの`emailVerified`プロパティの値が`true`になっていることがわかる。

![スクリーンショット 2021-09-14 12.20.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88357/eccf68b6-afa8-6d14-2e54-ee4b159a028c.png)

# 参考

メールでのアクティベーションについて

- [WEBサイト上のメール認証（アクティベーション）手法を考える](https://colo-ri.jp/develop/2012/07/web-how-to-mail-activation.html)
- [各サービスのメアド変更UXを比較してみた](https://blog.xin9le.net/entry/2014/03/04/190430)

firebaseまわり

- https://blog.katsubemakito.net/firebase/firebase-authentication-email-web2
- https://medium.com/firebase-developers/generating-email-action-links-with-the-firebase-admin-sdk-4b9d5e2cf914
- https://firebase.google.com/docs/auth/admin/email-action-links
- https://firebase.google.com/docs/auth/custom-email-handler
- [Node.js and Firebase: Generating Email Verification Link](https://dev.to/frantchessico/node-js-and-firebase-generating-email-verification-link-1ci8)

mailer

- https://nodejs.keicode.com/nodejs/nodemailer.php
- https://dev.to/eswari/how-to-send-email-with-attachments-in-node-js-using-nodemailer-3g4i

fetch

- https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
- https://blog.katsubemakito.net/html5/fetch1

メール認証
https://qiita.com/YamadaTakahito/items/182260a3cf240327d765
https://colo-ri.jp/develop/2012/07/web-how-to-mail-activation.html