24
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

N高等学校Advent Calendar 2018

Day 14

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

Last updated at Posted at 2018-12-13

N高等学校で生徒をやっておりますshow0317swです。
今回はN高等学校アドベントカレンダー14日目の記事になります。

はじめに

最初は自分が作っているサービスを紹介できたらなぁ。なんて思っていたのですが、予想通り完成してないので、この記事ではそのサービスで利用したメール認証でのサインアップに関する内容にしようと思います。
なお、例外処理や脆弱性対策は十分ではありません。ユーザー情報を扱うので実際に応用して使う場合は、HTTPSでの提供など各自で必要な対策を行ってください。
GitHubに今回のコードを置いておくので、こちらも参考にしてください。

前提

今回の環境は以下の通りです。
開発環境:

  • 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でアプリを生成します。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_tmpuseruser_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.jsuser.jsuserAuth.jsを作成します。それぞれ以下のように実装します。

_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
};
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;
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;
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に以下を追記します。

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を作成し以下のように編集します。

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に以下を追記します。

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

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

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

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にアクセスしてみます。 1
signup2.png

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

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

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と言うライブラリを利用します。
このライブラリはSMTPサーバーやGmail、Yahooメールと言ったサービスからの送信をサポートしています。
今回は簡単に使えるGmailで実装します。2
signup.jsに以下を追加します。

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部分に追記します。

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にアクセスして、先程送信用に設定したメールアドレス以外で受け取れるメールアドレスでサインアップしてください。

signup3.png

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

ここで、DBの内容を確認してみます。

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

emailusernameに間違いがなく、passwordtokenがハッシュ化されていれば問題ありません。

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

mail2.png

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

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

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

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

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を作成し、以下のように記述します。

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

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

auth2.png

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

#まとめ

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

#おわりに

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

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

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

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

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

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

24
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?