コピペでOK!Expressにログイン機能をつくる

こんにちは!フリーランス・エンジニアの 九保すこひ です。

さてさて、前々回の記事「保存版!「sequelize」モデルの使い方実例・全53件」では、データベースの操作方法を網羅した内容をお届けしました。

そして、この記事を書いた結果、データベース関連で1つサイト開発には重要な機能を思いつきました。

それは・・・

ログイン機能

です。

やはり、ある程度の規模のサイトとなるとユーザーがログインして個人的な情報を管理できるようになっていることが多いので、この機能は外せません。

そこで!

今回は「Express」と有名なログイン認証パッケージ「Passport.js」を使ってログイン機能をつくってみたいと思います。

ぜひ皆さんのお役に立てると嬉しいです😊✨

なお、今回は少し内容が複雑な部分も多かったため、asyncawaitを使わず「できるだけシンプル」なコードでログイン機能を実装してみました。(初心者にもやさしい記事づくり、心掛けてます👍)

【追記:2020.3.3】別記事として公開していた「次回からログインを省略(Remember me)」機能と統合して加筆・修正しました。

開発環境: Node.js 8.10、Express 4.1

前提として

データベース操作するパッケージ「Sequelize」がインストールされていることが前提です。詳しくは、Node.jsにDBマイグレーション、Seed、モデルを用意する「Sequelize」をご覧ください。

なお、テンプレートエンジンは「mustache」を使います。こちらも、「Expressのインストールと基本のまとめ」が参考になると思います。

必要なパッケージをインストールする

今回は、一般的によくログイン機能として使われる、

「メールアドレス」 + 「暗号化されたパスワード」

でログインできるようにするので、以下5つ(+1)のパッケージをインストールしてください。

【ログイン機能パッケージ】

npm install --save passport
npm install --save passport-local

【暗号化パッケージ】

npm install --save bcrypt

【Session、Flashメッセージ用パッケージ】

npm install --save express-session
npm install --save connect-flash

【Cookie用パッケージ】

npm i --save cookie-parser

※なお、このパッケージは、「次回からログインを省略する」機能を使う場合のみ必要になります。

データベースを準備する

では、ユーザー情報を管理するUsersテーブルをつくっていきましょう。
以下のコマンドを実行してください。

npx sequelize model:generate --name User --attributes name:string,email:string,password:string,rememberToken:string

すると、/models/migrationsにファイルが作成されますので、そのままマイグレーションを実行しましょう。

npx sequelize db:migrate

テーブルはこうなります。

続いて、開発しやすいようにテストユーザーを用意していきましょう。まず、以下のコマンドでSeedファイルを作成してください。

npx sequelize seed:generate --name test-users

すると、「seeders/**************-test-users.js」というファイルが作成されるので、中身を以下のようにします。

'use strict';
const bcrypt = require('bcrypt');

module.exports = {
  up: (queryInterface, Sequelize) => {
    const now = new Date();
    return queryInterface.bulkInsert('Users', [
      {
        name: '太郎',
        email: 'taro@example.com',
        password: bcrypt.hashSync('secret', bcrypt.genSaltSync(8)),
        createdAt: new Date(),
        updatedAt: new Date()
      },
    ], {});
  },

  down: (queryInterface, Sequelize) => {
    return queryInterface.bulkDelete('Users', null, {});
  }
};

コードを保存したら以下のコマンドでマイグレーションを実行します。

npx sequelize db:migrate

Usersテーブルは、このようになります。

ログイン機能をつくる

では、実際にログイン機能を作っていきましょう。

ログイン機能の本体をつくる

まずは、「Passport.js」で実際にログインする部分を「auth.js」というファイルをルートフォルダにつくり、この中で実装します。(というのも、メインのファイルに全てコードを書くと長くなってしまい保守管理がしにくくなるためです😫)

const express = require('express');
const app = express();
const bcrypt = require('bcrypt');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const User = require('./models').User;

passport.use(new LocalStrategy({
    usernameField: 'email',
    passwordField: 'password'
  }, (email, password, done) =>  {

    User.findOne({
      where: {
        email: email
      }
    })
    .then(user => {

      if(user && bcrypt.compareSync(password, user.password)) {

        return done(null, user);  // ログイン成功

      }

      throw new Error();

    })
    .catch(error => { // エラー処理

      return done(null, false, { message: '認証情報と一致するレコードがありません。' });

    });

}));

// Session
passport.serializeUser((user, done) => {

  done(null, user);

});
passport.deserializeUser((user, done) => {

  done(null, user);

});

module.exports = passport;

やっていることは、以下のとおりです。

  1. データベースから「メールアドレス」でユーザーを取得
  2. もしユーザーが存在していたら、パスワードが一致するかチェック
  3. パスワードも正しければ、ログインする

ちなみに、この3つの項目でもし失敗があれば、「エラー処理」と書かれた部分が実行されることになります。

また「session」の部分は、ログイン後にユーザーデータを取得する部分になります。

auth.jsを読み込む

では、メインとなる「app.js」に戻って先ほどつくった「auth.js」を読み込んで、さらにミドルウェアを設定をしておきましょう。

const express = require('express');
const app = express();
const passport = require('./auth');
const session = require('express-session');
const flash = require('connect-flash');

// ミドルウェア
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(flash());
app.use(session({
  secret: 'YOUR-SECRET-STRING',
  resave: true,
  saveUninitialized: true
}));
app.use(passport.initialize());
app.use(passport.session());

const authMiddleware = (req, res, next) => {
  if(req.isAuthenticated()) { // ログインしてるかチェック

    next();

  } else {

    res.redirect(302, '/login');

  }
};

はい!これでログインの本体の部分は完了です。
続いて、各ページに適用していきましょう。

ルートをつくる

今回作成するルートは、以下の3つです。

  • ログインフォーム ・・・ /login(GET)
  • ログイン実行 ・・・ /login(POST)
  • ログイン成功後のページ ・・・ /user(GET)※ログインしていないと強制的にリダイレクト

では、「app.js」を開いて以下のコードを追加してください。

// ログインフォーム
app.get('/login', (req, res) => {
  const errorMessage = req.flash('error').join('<br>');
  res.render('login/form', {
    errorMessage: errorMessage
  });
});

// ログイン実行
app.post('/login',
  passport.authenticate('local', {
    successRedirect: '/user',
    failureRedirect: '/login',
    failureFlash: true,
    badRequestMessage: '「メールアドレス」と「パスワード」は必須入力です。'
  })
);

// ログイン成功後のページ
app.get('/user', authMiddleware, (req, res) => {
  const user = req.user;
  res.send('ログイン完了!');
});

ここで重要なのが2点です。

1つ目は、「ログインフォーム」にあるreq.flash('error')です。ここでは、ログインに失敗したときのエラーメッセージをsessionから取得することになります。

そしてもうひとつが、「ログイン成功後のページ」のauthMiddlewareです。これは、先ほど設定したミドルウェアで、「/user」というページではログインをしていなければ強制的にリダイレクトされることになります。

ビューをつくる

では、先ほどのルートで設定したログインフォーム用のビューを作成しましょう。「views/login/form.mst」というファイルを作成し、中身を以下のようにします。

<!doctype html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ログイン・フォーム</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="mt-4">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">ログイン・フォーム</div>
                <div class="card-body">
                    <form method="POST" action="/login">
                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label>
                            <div class="col-md-6">
                                <input id="email" type="text" class="form-control" name="email" autofocus>
                                {{ #errorMessage }}
                                <div class="alert alert-danger">
                                    {{ errorMessage }}
                                </div>
                                {{ /errorMessage }}
                            </div>
                        </div>
                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label>
                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control" name="password">
                            </div>
                        </div>
                        <div class="form-group row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">ログイン</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
</body>
</html>

ちなみに、mustacheテンプレート・エンジンを使うためには以下のような設定にしておいてください。

const mustacheExpress = require('mustache-express');
app.engine('mst', mustacheExpress());
app.set('view engine', 'mst');
app.set('views', __dirname + '/views');

テストしてみる

では、実際に「/login」にアクセスしてブラウザで確認してみましょう。

ログインフォームが表示されました。
まずは何も入力しないで送信してみます。

うまくエラー表示されました。
では次に、間違ったログイン情報を送信してみましょう。

こちらもうまくエラーが表示されました。
では最後に、正しいログイン情報を送信してみましょう。

はい!
リダイレクトされてログイン必須ページが表示されました。

成功です😊✨

なお、「次回からログインを省略」機能は次回からログインを省略する機能をつくるをご覧ください。

ログアウト機能をつくる

ログインしたユーザーをログアウトさせるには、以下のようなルートを作ってください。

app.get('/logout', (req, res) => {
  req.logout();
  res.redirect('/login');
});

次回からログインを省略する機能を作る

準備する

まずパッケージとミドルウェア、必要な値をapp.jsに追加しておきます。

const cookieParser = require('cookie-parser');
const crypto = require('crypto');

app.use(cookieParser());

// 暗号化につかうキー
const APP_KEY = 'YOUR-SECRET-KEY';

※なお、APP_KEYは本来.envなどで一元管理するべきですが、今回は省略します。

ログインフォームをつくる

ログイン機能をつくる」でログインフォームをつくりましたが、「次回から省略」するチェックボックスがないので以下のように追加します。(太字が追加した部分です)

<!doctype html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ログイン・フォーム</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="mt-4">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">ログイン・フォーム</div>
                <div class="card-body">
                    <form method="POST" action="/login">
                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label>
                            <div class="col-md-6">
                                <input id="email" type="text" class="form-control" name="email" autofocus>
                                {{ #errorMessage }}
                                <div class="alert alert-danger">
                                    {{ errorMessage }}
                                </div>
                                {{ /errorMessage }}
                            </div>
                        </div>
                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">パスワード</label>
                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control" name="password">
                            </div>
                        </div>
                        <div class="form-group row">
                            <div class="col-md-6 offset-md-4">
                                <div class="form-check">
                                    <input class="form-check-input" type="checkbox" name="remember" value="1" id="remember">
                                    <label class="form-check-label" for="remember">
                                        次回から省略
                                    </label>
                                </div>
                            </div>
                        </div>
                        <div class="form-group row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">ログイン</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
</body>
</html>

これをブラウザで表示すると以下のようになります。

ログインする時にトークンを保存する部分

続いて同じくログイン機能をつくるでつくったルートのapp.post('/login', ...)の部分を変更してログイン時にトークンを保存するようにします。

app.post('/login',
  passport.authenticate('local', {
    failureRedirect: '/login',
    failureFlash: true,
    badRequestMessage: '「メールアドレス」と「パスワード」は必須入力です。'
  }),
  (req, res, next) => {

    if(!req.body.remember) {  // 次回もログインを省略しない場合

      res.clearCookie('remember_me');
      return next();

    }

    const user = req.user;
    const rememberToken = crypto.randomBytes(20).toString('hex'); // ランダムな文字列
    const hash = crypto.createHmac('sha256', APP_KEY)
      .update(user.id +'-'+ rememberToken)
      .digest('hex');
    user.rememberToken = rememberToken;
    user.save();

    res.cookie('remember_me', rememberToken +'|'+ hash, {
      path: '/',
      maxAge: 5 * 365 * 24 * 60 * 60 * 1000 // 5年
    });

    return next();

  },
  (req, res) => {

    res.redirect('/user');

  }
);

追加した部分でやっていることは、以下のとおりです。

  1. ログインが成功したら、自動ログインを有効にするかどうかをチェック
  2. 自動ログインする場合は、トークンとハッシュ値をつくる
  3. トークンをUserテーブルに保存
  4. トークン&ハッシュ値をCookieに保存

なお、トークンの有効期間は5年にしています。

自動ログインする部分をつくる

では、メインのトークンを使ってログインする部分をつくっていきましょう。

変更するのは、「ログイン機能をつくる」で作ったauthMiddlewareです。(太字がコードを追加した部分です)

const authMiddleware = (req, res, next) => {
  if(req.isAuthenticated()) {

    next();

  } else if(req.cookies.remember_me) {

    const [rememberToken, hash] = req.cookies.remember_me.split('|');

    User.findAll({
      where: {
        rememberToken: rememberToken
      }
    }).then(users => {

      for(let i in users) {

        const user = users[i];

        const verifyingHash = crypto.createHmac('sha256', APP_KEY)
          .update(user.id +'-'+ rememberToken)
          .digest('hex');

        if(hash === verifyingHash) {

          return req.login(user, () => {

            // セキュリティ的はここで remember_me を再度更新すべき

            next();

          });

        }


      }

      res.redirect(302, '/login');

    });

  } else {

    res.redirect(302, '/login');

  }
};

【追記:2020.02.26】every()を使ったループで処理が終了しないという不具合が発生ありましたので、通常のfor()ループに変更しました。ご不便をおかけして申し訳ございません😫

追加したコードの中でやっているのは、以下のとおりです。

  1. まずCookieのデータからトークンとハッシュ値を取得
  2. トークンを使って該当する全ユーザーを取得
  3. ハッシュ値と一致するユーザーを取得
  4. 手動でそのユーザーをログインさせる

なお、ユーザーを複数取得しているのは、万が一同じトークンを持ったユーザーが存在してもいいようにするためです。そのために、ハッシュ値はユーザーIDを含めた文字列で作成しています。

※また、本来はセキュリティの観点から自動ログインをしたらCookieのトークンは新しいものに更新するべきですが、今回はテストなので省略しています。

テストしてみる

では、実際のテストですが、先にChromeCookieを管理する方法をご紹介します。

まずページのどこでもいいので右クリックすると、以下のメニューが出るので「検証」をクリックしてください。(Linux版なので文字が違っているかもしれません😅)

DevToolが開いたら、「Application > Cookies」を選択してください。
そのサイトに保存されているCookieの一覧が表示されているので、ここから内容の確認や削除が簡単にできます。

では、今回のテストを実行します!

/loginにアクセスしてログイン情報を入力、さらに「次回から省略」にチェックを入れてログインします。

では、この時点でCookieを確認してみます。

うまくremember_meが作成されています。

では、次にこの中のログイン状態を保存しているconnect.sidを削除します。

そして、この状態でページをリロードしてみます。

はい!
通常ならログインページにリダイレクトされてしまうはずが、自動ログインされました。

成功です😊✨

なお、DevToolを確認すると新しいconnect.sidが作成されています。

お疲れ様でした!

ちなみに

例えば、「admin」「owner」「user」などユーザータイプでページの閲覧制限をするには、以下のようなミドルウェアを使うといいでしょう。

const adminAuthMiddleware = (req, res, next) => {
  if(req.isAuthenticated() && req.user.role === 'admin') {

    next();

  } else {

    res.redirect(302, '/login');

  }
};

※もちろん、データベース内にroleというフィールドを用意する必要があります。

ルートは、こうなります。

app.get('/admin', adminAuthMiddleware, (req, res) => {
  // 省略

});

おわりに

ということで、今回はExpressを使ったログイン機能を作ってみました。

今回はHTTP送信でログインを実装してみましたが、色々とsession関係でコードが複雑だったので、もしかするとAjaxを使ったログインの方がスムーズにいくかもしれません。

また、雑感としては、Laravelと比べると記述しないといけないコードが多いのでパッケージのinitialize()で必要最低限はすべて実行してくれたらうれしいなー、なんてナマケモノ・プログラマーの私は思ってしまいました(笑)

ログイン機能をすべて自前で用意することを考えると感謝感激!には間違いないないですけどね。

ではでは〜😊

この記事が役立ちましたらシェアお願いします😊✨ by 九保すこひ
また、わかりにくい部分がありましたらお問い合わせからお気軽にご連絡ください。
このエントリーをはてなブックマークに追加       follow us in feedly