nodemailerを使ってメール認証でのサインアップをNode.js+express.jsで実装する｡

N高等学校で生徒をやっております[show0317sw](https://twitter.com/show0317sw)です｡
今回は[N高等学校アドベントカレンダー](https://qiita.com/advent-calendar/2018/n-highschool "N高等学校アドベントカレンダー")14日目の記事になります｡
# はじめに
最初は自分が作っているサービスを紹介できたらなぁ｡なんて思っていたのですが､予想通り完成してないので､この記事ではそのサービスで利用したメール認証でのサインアップに関する内容にしようと思います｡
なお､例外処理や脆弱性対策は十分ではありません｡ユーザー情報を扱うので実際に応用して使う場合は､HTTPSでの提供など各自で必要な対策を行ってください｡
[GitHub](https://github.com/show0317sw/mail-auth)に今回のコードを置いておくので､こちらも参考にしてください｡
# 前提
今回の環境は以下の通りです｡
開発環境:

- Ubuntu 18.04.1 LTS
- Node.js v10.13.0

ライブラリ:

- express v4.16.4
- sequelize v4.41.2
  - pg v7.6.1
  - pg-hstore v2.3.2
- passport v0.4.0
- passport-local v1.0.0
- nodemailer v4.7.0
- uuid v3.3.2

#準備
まずは､[express-generator](https://www.npmjs.com/package/express-generator)でアプリを生成します｡viewはお好きなものを使ってください｡今回はpugでつくります｡アプリの名前は`mail-auth`としました｡

```
$ express --view=pug mail-auth && cd ./mail-auth
```

次に､プロジェクトに必要なパッケージをインストールします｡

```:yarnの場合
$ yarn install && yarn add sequelize pg pg-hstore passport passport-local nodemailer uuid
```

```:npmの場合
$ npm i && npm i -S sequelize pg pg-hstore passport passport-local nodemailer uuid
```

#データモデルの作成
###設計
登録したユーザー情報を管理するためのデータモデルを作成します｡
ユーザー情報は`user_tmp`､`user`､`user_auth`の3つのテーブルに分けました｡
以下がそれぞれのテーブルの役割と内容になります｡
`user_tmp`テーブル: フォームから受け取った情報を保存する｡認証が完了したら削除される｡

| カラム名 | データ型 | 属性 | 内容 |
|:-:|:-:|:-:|:-:|
| email | STRING | PRIMARY KEY | メールアドレス |
| username | STRING |  | ユーザー名 |
| password | STRING |  | パスワード |
| token | STRING | UNIQUE | 認証トークン |

`user`テーブル: 認証済みユーザーの情報を保存する｡

| カラム名 | データ型 | 属性 | 内容 |
|:-:|:-:|:-:|:-:|
| user_id | UUID | PRIMARY KEY | ユーザーID |
| username | STRING |  | ユーザー名 |
| email | STRING |  | メールアドレス |
| created_at | DATE |  | 作成日時 |
| updated_at | DATE |  | 更新日時 |
| deleted_at | DATE |  | 削除日時 |

`user_auth`テーブル: 認証済みユーザーのサインインに使用する｡

| カラム名 | データ型 | 属性 | 内容 |
|:-:|:-:|:-:|:-:|
| username | STRING | PRIMARY KEY | ユーザー名 |
| user_id | UUID | FOREIGN KEY | ユーザーID |
| password | STRING |  | パスワード |
| email | STRING |  | メールアドレス |
| created_at | DATE |  | 作成日時 |
| updated_at | DATE |  | 更新日時 |

DBの運用は以下のようになります｡
1. [ユーザー]フォームからユーザー情報を送信｡
2. [システム]`user_tmp`にユーザー1の情報と新たに生成したトークンをハッシュ化して保存する｡
3. [システム]2で生成したトークンを含んだURLを生成し1のメールアドレスに送信する｡
4. [ユーザー]3のURLにアクセスする｡
5. [システム]URLに含まれるトークンをハッシュ化してDBのトークンと照合する｡合致した場合は新たにユーザーIDを生成して`user`テーブルと`user_auth`テーブルにユーザー情報を追加する｡`user_tmp`テーブルからは物理削除する｡

- 認証済みユーザーのサインイン時は`user_auth`テーブルからusernameかemailとpasswordを検索してユーザーを特定する｡
- 認証済みユーザーの退会時は該当ユーザーを`user`テーブルから論理削除し､`user_auth`テーブルから物理削除する｡

###実装
データモデルを実装していきます｡今回はpostgresqlをsequelizeから使います｡
まずは`models`フォルダを作成し､その下に`_sequelize-loader.js`と各データモデル`userTmp.js`､`user.js`､`userAuth.js`を作成します｡それぞれ以下のように実装します｡

```javascript:_sequelize-loader.js
'use strict';
const Sequelize = require('sequelize');
const sequelize = new Sequelize(
  'postgres://postgres:postgres@localhost/sample_app',
  {logging: true, operatorsAliases: false, timezone: 'UTC'}
);

module.exports = {
  database: sequelize,
  Sequelize: Sequelize
};
```

```javascript:userTmp.js
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;

const UserTmp = loader.database.define('user_tmp', {
  email: {
    type: Sequelize.STRING,
    primaryKey: true
  },
  username: {
    type: Sequelize.STRING,
    allowNull: false
  },
  password: {
    type: Sequelize.STRING,
    allowNull: false
  },
  token: {
    type: Sequelize.STRING,
    allowNull: false,
    unique: true
  }
}, {
  freezeTableName: true,
  timestamps: false
});

module.exports = UserTmp;
```

```javascript:user.js
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;

const User = loader.database.define('users', {
  user_id: {
    type: Sequelize.STRING,
    primaryKey: true
  },
  username: {
    type: Sequelize.STRING,
    allowNull: false
  },
  created_at:Sequelize.DATE,
  updated_at:Sequelize.DATE,
  deleted_at:Sequelize.DATE
}, {
  freezeTableName: true,
  timestamps: true,
  createdAt: 'created_at',
  updatedAt: 'updated_at',
  deletedAt: 'deleted_at',
  paranoid: true
});

module.exports = User;
```

```javascript:userAuth.js
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;

const UserAuth = loader.database.define('user_auth', {
  username: {
    type: Sequelize.STRING,
    primaryKey: true
  },
  user_id: {
    type: Sequelize.STRING,
    allowNull: false
  },
  password: {
    type: Sequelize.STRING,
    allowNull: false
  },
  email: {
    type: Sequelize.STRING,
    allowNull: false
  },
  created_at:Sequelize.DATE,
  updated_at:Sequelize.DATE
}, {
  freezeTableName: true,
  timestamps: true,
  createdAt: 'created_at',
  updatedAt: 'updated_at'
});

module.exports = UserAuth;
```

次にリレーションの設定をします｡
`app.js`に以下を追記します｡

```javascript:app.js
// モデルの読み込み
const User = require('./models/user');
const UserAuth = require('./models/userAuth');
const UserTmp = require('./models/userTmp');

// モデルのリレーション設定
User.sync().then(() => {
  UserAuth.belongsTo(User, { foreignKey: 'user_id' });
  UserAuth.sync();
  UserTmp.sync();
});
```

以上でデータモデルの作成は完了です｡

###DBを作成
postgresqlのDBを作成します｡

```
$ sudo su - postgres
$ psql
# CREATE DATABASE sample_app;
```

#登録フォームの作成
次に､ユーザーに初期情報を入力してもらいサーバーに送信するフォームを作成します｡
`views/signup.pug`を作成し以下のように編集します｡

```pug:signup.pug
html
  head
    meta(charset="utf-8")
    meta(http-equiv="X-UA-Compatible" content="IE=edge")
    meta(name="viewport" content="width=device-width, initial-scale=1")
    title= title
  body
    if title == 'サインアップ'
      form(action="/signup", method="post")
        div
          label
            p ユーザー名:
              input(type="text", name="username")
        div
          label
            p メールアドレス:
              input(type="email", name="email")
        div
          label
            p パスワード:
              input(type="password", name="password")
        div
          button(type="submit") サインアップ
      p または
        a(href="/login") ログイン
    else
      p アカウント確認メールを送信しました｡メール内のリンクから認証を行っってください｡
```

特殊な部分はありません｡ユーザー名･メールアドレス･パスワードを`/signup`にPOSTするだけです｡
#サインアップWebAPIの実装
ここから実際の処理を書いていきます｡

###フォームをレンダリングする
フォームは`/signup`にGETアクセスしたときに表示するようにします｡まずは､このパスへのGETをルーティングします｡
`app.js`に以下を追記します｡

```javascript:app.js
// ルーターの読み込み
const signupRouter = require('./routes/signup');

// ルート設定
app.use('/signup', signupRouter );
```

次に上記で読み込むルーターを作成します｡
`~/routes/signup.js`を作成し以下のように記述します｡

```javascript:signup.js
'use strict';
const express = require('express');
const router = express.Router();

// '~/signup'にGETアクセスが来たときの処理
router.get('/', function(req, res, next){
  res.render('signup');
});

module.exports = router;
```

早速起動しみます｡

```:yarnの場合
$  DEBUG=mail-auth:* yarn run start
```

```:npmの場合
$  DEBUG=mail-auth:* npm run start
```

`Listening on port 3000`とログが表示され起動が確認できたら､chromeから[http://localhost:3000/signup](http://localhost:3000/signup)にアクセスしてみます｡ [^1]
![signup2.png](https://qiita-image-store.s3.amazonaws.com/0/244235/166eb29a-61a0-843b-2b1c-c474255e89f7.png)

このように表示されていればOKです｡

[^1]: 仮想環境で実行している方は3000番のポートフォワード設定をしてください｡

###フォームから送信された情報をDBに保存する
次に､フォームからのPOSTアクセスを処理します｡
`signup.js`に以下を追記します｡

```javascript:signup.js
// モジュールの読み込み
const crypto = require("crypto");

// モデルの読み込み
const UserTmp = require('../models/userTmp');

// '~/signup'にPOSTアクセスが来たときの処理
router.post('/', function(req, res, next){
  const email = req.body.email;
  const username = req.body.username;
  const password = hashing(req.body.password);
  const token = crypto.randomBytes(16).toString('hex');
  const hashedToken = hashing(token);
  UserTmp.upsert({
    email: email,
    username: username,
    password: password,
    token: hashedToken
  }).then(() => {
    // TODO 確認メールを送信する
    });
  });
});

// ハッシュ化関数
function hashing(data){
  const shasum = crypto.createHash('sha1');
  shasum.update(data);
  let hash = shasum.digest('hex');
  return hash;
}
```

この処理では､DB保存する情報を取り出して､新たにトークンを生成しています｡DBに保存するのはハッシュ化したトークンで､もとのトークンの方をメールで送信する予定です｡`UserTmp.upsert()`で同じメールアドレスの未認証ユーザーが居たら上書きして､居なければ新たに行を追加しています｡

###確認メールを送信する
DBにユーザー情報を保存できたらアカウント確認用のメールを送信します｡
メール送信には[nodemailer](https://nodemailer.com/about/)と言うライブラリを利用します｡
このライブラリはSMTPサーバーやGmail､Yahooメールと言ったサービスからの送信をサポートしています｡
今回は簡単に使えるGmailで実装します｡[^2]
`signup.js`に以下を追加します｡

[^2]: [公式の見解](https://nodemailer.com/usage/using-gmail/)ではGmailの利用は非推奨です｡これは､Google社が脆弱性の観点から安全性の低いアプリからのGmail利用を制限しているからです｡GoogleChromeのゲストモードやGoogleアカウントでログインしたことのないブラウザから[こちらのリンク](https://myaccount.google.com/lesssecureapps)にアクセスし､メール送信に利用するアカウントでログインし安全性の低いアプリの許可を有効にすることでこの制限を解除することができます｡自己責任で行ってください｡

```javascript:signup.js
// モジュールの読み込み
const nodemailer = require('nodemailer');

// メール送信設定
const transporter = nodemailer.createTransport({
  service: 'Gmail',
  auth: {
    user: 'yourmail@gmail.com', // Gmailアドレス
    pass: 'hogehuga' // Googleアカウントのパスワード
  }
});
const mailOptions = {
  from: 'yourmail info <yourmail@gmail.com>',
  subject: 'アカウントの確認'
};
```
実際に送信する処理は以下の通りです｡TODO部分に追記します｡

```javascript:signup.js
    mailOptions.to = email;
    mailOptions.html = '<p>以下のリンクからアカウントの確認を行ってください｡</p><br>
                        <a href="localhost:3000/auth/email/' + token + '">アカウントを確認</a>';
    transporter.sendMail(mailOptions, (err, info) => {
      if (err){
        console.log(err);
      } else {
        console.log('Message sent: ' + info.accepted);
        res.render('signup', { title: 'アカウントの確認' });
      }
    }
```

早速サインアップしてメールが送信できるか確認してみましょう｡すでに起動している場合は`Ctrl + C`でサーバーを止めてから以下のコマンドで再度起動させます｡

```:yarnの場合
$  DEBUG=mail-auth:* yarn run start
```

```:npmの場合
$  DEBUG=mail-auth:* npm run start
```

[http://localhost:3000/signup](http://localhost:3000/signup)にアクセスして､先程送信用に設定したメールアドレス以外で受け取れるメールアドレスでサインアップしてください｡

![signup3.png](https://qiita-image-store.s3.amazonaws.com/0/244235/fd41ec8a-9753-1f24-7570-7c812b6a680c.png)

サインアップボタンを押すと､このような表示になると思います｡

ここで､DBの内容を確認してみます｡

```
sample_app=# SELECT * FROM user_tmp ;
         email          |  username  |                 password                 |                  token                   
------------------------+------------+------------------------------------------+------------------------------------------
 xxxxxxxxxx@yahoo.co.jp | show0317sw | 9d4e1e23bd5b727046a9e3bab7db57bd8d6ee684 | 16c65b4ad9a33c089a19dc3e017a7443b9650940
(1 row)

```

`email`や`username`に間違いがなく､`password`と`token`がハッシュ化されていれば問題ありません｡

###認証する
終盤に近づいてきました!
先程のサインアップで認証用のURLを含んだメールを登録したメールアドレス宛に送信されています｡メールを確認してみます｡

![mail2.png](https://qiita-image-store.s3.amazonaws.com/0/244235/1156a85d-1a99-8ee3-2e47-d755f6d2786f.png)


このようなメールが送信されていると思います｡
URLから認証するために､リンクのパス`/auth/email/:token`へのGETアクセスをルーティングします｡
`app.js`に以下を追記します｡

```javascript:app.js
// ルーターの読み込み
const authRouter = require('./routes/auth');

// ルート設定
app.use('/auth', authRouter);
```

次に上記で読み込むルーターを作成します｡
`~/routes/auth.js`を作成し以下のように記述します｡

```javascript:auth.js
'use strict';
const express = require('express');
const router = express.Router();

// パッケージの読み込み
const crypto = require('crypto');
const uuid = require('uuid');

// モデルの読み込み
const UserTmp = require('../models/userTmp');
const User = require('../models/user');
const UserAuth = require('../models/userAuth');

// '~/auth/email/:token'にGETアクセスが来たときの処理
router.get('/email/:token', function(req, res, next){
  const token = hashing(req.params.token);
  UserTmp.findOne({
    where: {
      token: token
    }
  }).then(user => {
    if (user){
      const userId = uuid.v4();
      const username = user.username;
      const password = user.password;
      const email = user.email;
      User.create({
        user_id: userId,
        username: username
      }).then(()=>{
        UserAuth.create({
          username:username,
          user_id: userId,
          password: password,
          email: email
        }).then(()=>{
          UserTmp.destroy({
            where:{
              email: email
            }
          }).then(()=>{
            res.render('auth', { title: '認証成功' });
          });
        });
      });
    } else {
      res.render('auth', { title: '認証失敗' });
    }
  });
});

// ハッシュ化関数
function hashing(data){
  const shasum = crypto.createHash('sha1');
  shasum.update(data);
  let hash = shasum.digest('hex');
  return hash;
}

module.exports = router;
```

`auth.js`ではURLに含まれるトークンをハッシュ化して`user_tmp`テーブルから検索しています｡見つかったら認証成功､見つからなかったら認証失敗となります｡
認証に成功した場合､`user_tmp`に保存されていた情報を`user`テーブル､`user_auth`テーブルにそれぞれ保存し直しています｡その後､`user_tmp`テーブルからは削除しています｡

`auth`をレンダリングするようにしていますが､ファイルはまだ作ってないのでこちらも新たに作成します｡
`~/views/auth.pug`を作成し､以下のように記述します｡

```pug:auth.pug
html
  head
    meta(charset="utf-8")
    meta(http-equiv="X-UA-Compatible" content="IE=edge")
    meta(name="viewport" content="width=device-width, initial-scale=1")
    title= title
  body
    if title == '認証成功'
      p 認証に成功しました｡
        a(href="/login") ログイン
    else
      p 認証に失敗しました｡もう一度､最初からやり直してください｡
        a(href="/signup") サインアップ
```

では､サーバーを再起動して､メール内のURLにアクセスしてみます｡

![auth.png](https://qiita-image-store.s3.amazonaws.com/0/244235/98579636-c5e8-d211-f4d8-1552894ae65d.png)

認証に成功すると上記のように表示されると思います｡
なおトークン部分を変えてアクセスしてみたり､同じトークンでアクセスしたりすると認証に失敗して､以下のような表示になります｡

![auth2.png](https://qiita-image-store.s3.amazonaws.com/0/244235/04eb7806-ee49-ad5b-bf01-9c64ecd22c85.png)

以上でメール認証でのサインアップは実装できました!!
サインアップすればログインがあるのは当たり前なのでログイン用のパスが見えたりしてましたが､今回の記事の目的とはちょっと違うので､別の記事で書くことにします｡

#まとめ

- メール認証は､[トークン発行]→[メールでトークンを送信]→[トークンを照合] の3ステップ｡
- ライブラリを使えば､意外と簡単に実装できます｡便利な時代です｡
- DB設計は良く考えよう｡僕は何度も作り直してこの形に行き着きました｡やりたいことに合わせた最強のDB設計をつくろう｡

#おわりに

今回はメール認証を実際にコードを書きながら自分なりに解説してみました｡
今回は実装していませんが､トークンに時間制限を設けたりするなど､安心安全なサービスの提供のためにできることはまだまだあると思います｡こんなアイディアがあるぜ!!って方はぜひ教えてください｡

個人的な感想になりますが､実務で実装したことが無く､独学でもそれなりに動いてくれて良かったです｡やり方をいろいろと調べているうちに､ユーザー情報のDB設計のバリエーションや認証方法に様々な方法があったりと奥深さを感じ､それがとても楽しく感じました｡

最後にこの場を借りて｡N高アドカレにノリと勢いで参加しましたが執筆していく中で得たものの多さに驚いています｡アドバイスを頂いたり､共にエラーを乗り越えたりと､多くの方に支えて頂きました｡先生方やN高生のみなさんに本当にありがとうございます｡~~卒業したくない()~~

そして､この記事が誰かの役に立てば幸いです｡