Old Sunset Days

Node.jsでExpress上でPassportを利用する

日付 タグ node.js カテゴリ node.js

目次

認証のためのミドルウェアPassportを使う

PassportはNode.jsで利用できる認証のためのミドルウェアだ。

http://www.passportjs.org/ - Passport

Passport is authentication middleware for Node.js. Extremely flexible and modular, Passport can be unobtrusively dropped in to any Express-based web application. A comprehensive set of strategies support authentication using a username and password, Facebook, Twitter, and more.

Passportに加えて、セッション情報を使えば、ユーザーがログインしている状態とログインしていない状態でページの表示を切り替えることが可能。

例えば、掲示板の投稿内容はログインしていないと見れないようなサイトの構築などで利用できる。

今回はPassportを使ったログイン処理の簡単なサンプルを実装してみることで、Passportの使い方を見てみる。

なお、今回作ったサンプルのソースコードは参照用としてGithubの以下に置いてある。 https://github.com/hugodeblog/node-passport

Passportと必要なモジュールのインストール

いつもの通り、サンプルディレクトリを作って、Expressフレームワークを入れ、その上でパスポートなど必要なモジュールのインストール。

また、テンプレートは今回も最近自分が手慣れてきたHandlebarsを使い、ES6形式のimport文を利用した形にExpressも書き換えて利用する。

$ mkdir node-passport
$ cd node-passport
$ npx express-generator@4.x --view=hbs
$ npm install
$ npm install express-session --save
$ npm install session-file-store --save
$ npm install passport --save
$ npm install passport-local --save

インストールされたモジュールはpackage.json を見ると、こんな感じである。


package.json

{
  "name": "node-passport",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./app.mjs"
  },
  "dependencies": {
    "cookie-parser": "~1.4.4",
    "debug": "~2.6.9",
    "express": "~4.16.1",
    "express-session": "^1.17.1",
    "hbs": "~4.0.4",
    "http-errors": "~1.6.3",
    "morgan": "~1.9.1",
    "passport": "^0.4.1",
    "passport-local": "^1.0.0",
    "session-file-store": "^1.5.0"
  }
}

なお、ES6形式でアプリの本体をapp.mjs にするので、 "start": "node ./app.mjs" としておいた。

今回のプログラムで主なところを抜き出すと、

./
├── app.mjs
├── approotdir.mjs
├── appsupport.mjs
├── package.json
├── routes
│   ├── index.mjs
│   └── users.mjs
└── views
|   ├── error.hbs
|   ├── index.hbs
|   ├── layout.hbs
|   ├── login-failure.hbs
|   ├── login.hbs
|   ├── signup-failure.hbs
|   ├── signup-success.hbs
|   └── signup.hbs
|

もちろん、他にもファイルがあるのだが、主に実装していくなどで扱う部分は上記である。


approotdir.mjs

import * as path from 'path';
import * as url from 'url';

const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const approotdir = __dirname;


appsupport.mjs

import { server, port } from './app.mjs';
import * as util from 'util';

/**
 * Normalize a port into a number, string, or false.
 */
export function normalizePort(val) {
    const port = parseInt(val, 10);

    if (isNaN(port)) {
        // named pipe
        return val;
    }

    if (port >= 0) {
        // port number
        return port;
    }

    return false;
}

/**
 * Event listener for HTTP server "error" event.
 */
export function onError(error) {
    if (error.syscall !== 'listen') {
        throw error;
    }

    const bind = typeof port === 'string'
        ? 'Pipe ' + port
        : 'Port ' + port;

    // handle specific listen errors with friendly messages
    switch (error.code) {
        case 'EACCES':
            console.error(`${bind} requires elevated privileges`);
            process.exit(1);
            break;
        case 'EADDRINUSE':
            console.error(`${bind} is already in use`);
            process.exit(1);
            break;
        default:
            throw error;
    }
}

/**
 * Event listener for HTTP server "listening" event.
 */
export function onListening() {
    const addr = server.address();
    const bind = typeof addr === 'string'
        ? 'pipe ' + addr
        : 'port ' + addr.port;
    console.log(`Listening on ${bind}`);
}


export function handle404(req, res, next) {
    const err = new Error('Not Found');
    err.status = 404;
    next(err);
}

export function basicErrorHandler(err, req, res, next) {
    // http://expressjs.com/en/guide/error-handling.html
    if (res.headersSent) {
        return next(err)
    }
    // set locals, only providing error in development
    res.locals.message = err.message;
    res.locals.error = req.app.get('env') === 'development' ? err : {};

    // render the error page
    res.status(err.status || 500);
    res.render('error');
}

async function catchProcessDeath() {
    console.log('shutdown ...');
    await server.close();
    process.exit(0);
}

process.on('uncaughtException', function(err) {
    console.error("Crashed - "+ (err.stack || err));
});

process.on('unhandledRejection', (reason, p) => {
    console.error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);
});

process.on('SIGTERM', catchProcessDeath);
process.on('SIGINT', catchProcessDeath);
process.on('SIGHUP', catchProcessDeath);

process.on('exit', () => { console.log('exiting...'); })

このあたりは、
(==> Node.js+Express+MongoDBで簡易掲示板を作る )
で見たものとほぼ同じである。


app.mjs

import { default as express } from 'express';
import { default as hbs } from'hbs';
import * as path from'path';
import { default as logger } from'morgan';
import { default as cookieParser } from'cookie-parser';
import { default as bodyParser } from'body-parser';
import * as http from 'http';
import { approotdir } from './approotdir.mjs';
const __dirname = approotdir;
import {
    normalizePort, onError, onListening, handle404, basicErrorHandler
} from './appsupport.mjs';

import { router as indexRouter } from './routes/index.mjs';
import { router as usersRouter } from './routes/users.mjs';

export const app = express();

// Viewセットアップ
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

// セッション設定
import session from 'express-session';
import sessionFileStore from 'session-file-store';
import { initPassport } from './routes/users.mjs';

const FileStore = sessionFileStore(session);
export const sessionCookieName = 'mycookie.sid';
const sessionSecret = 'keyboard hogehoge';
const sessionStore  = new FileStore({ path: "sessions" });

app.use(session({
    store: sessionStore,
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    name: sessionCookieName,
    cookie: {maxAge: 60*60*1000*24*7, secure: false}
}));
initPassport(app);

// ルーティング
app.use('/', indexRouter);
app.use('/users', usersRouter);

app.use(handle404);
app.use(basicErrorHandler);

export const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

export const server = http.createServer(app);

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

ここでちょっとコードについて見ておく。

import session from 'express-session';
import sessionFileStore from 'session-file-store';

セッションに認証情報をキャッシュしておくために、ファイル管理でのセッション情報を利用する。

それによってブラウザの表示更新ボタンを押しても、一度ログインしていると、ログインしているものと見なされて、いちいちログイン認証しなおす必要がなくなる。

ここでブラウザ側でCookie保存するファイル名と、セッションのシークレットをDefault値から変更して自分で指定している。

const FileStore = sessionFileStore(session);
export const sessionCookieName = 'mycookie.sid';
const sessionSecret = 'keyboard hogehoge';
const sessionStore  = new FileStore({ path: "sessions" });

セッション情報を使ってアプリを動かすことを宣言。

app.use(session({
    store: sessionStore,
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    name: sessionCookieName,
    cookie: {maxAge: 60*60*1000*24*7, secure: false}
}));

こちらについては後ほど説明。

import { initPassport } from './routes/users.mjs';

セッション情報を作った上で、

initPassport(app);

Passportでセッション情報を利用する。

ルーティング処理の実装

まず見ていくのはusers.mjs である。

ユーザー関連でメインとなるのが、ログインページとサインアップページ。
これらのルーティングを処理する。

ログインではユーザー名とパスワードを入力。

サインアップでもユーザー名とパスワードを入力で登録。


routes/users.mjs

import path from 'path';
import util from 'util';
import { default as express } from 'express';
import { default as passport } from 'passport';
import { default as passportLocal } from 'passport-local';
const LocalStrategy = passportLocal.Strategy;
import { sessionCookieName } from '../app.mjs';

export const router = express.Router();

export function initPassport(app) {
  app.use(passport.initialize());
  app.use(passport.session());
}

// テスト用のユーザーを作成
var userList = [
  {id: 0, username: 'me', password: '&&&me123'},
  {id: 1, username: 'you', password: '***you123'}
];

// 該当ユーザーがいるかチェックする
function findUser(username, password) {
  for(let person of userList) {
    if(person.username === username && person.password === password) {
      return {id: person.id, 'username': person.username};
    }
  }
  return undefined;
}

// ログイン用ストラテジー
passport.use(new LocalStrategy(
  async (username, password, done) => {
    console.log(`username = ${username}, passowrd = ${password}`);
    const result = findUser(username, password);
    try {
      console.log(`result=${result}`);
      if (result != undefined) {
        done(null, { id: result.id, username: result.username});
      } else {
        done(null, false, 'Invalid username or password');
      }
    } catch (e) { done(e); }
  }
));

passport.serializeUser(function(user, done) {
  console.log('serializeUser called');
  try {
    console.log(`serialized user=${user}`);
    done(null, user);
  } catch (e) { done(e); }
});

passport.deserializeUser(async (user, done) => {
  try {
    console.log(`deserialized user=${user}`);
    done(null, user);
  } catch(e) { done(e); }
});

// 認証ログイン画面
router.get('/login', function(req, res, next) {
  console.log('/login called');
  try {
    res.render('login', {title: 'ログイン', user: req.user ? req.user : undefined});
  } catch (e) { next(e); }
});

// ログインを試す
router.post('/login',
passport.authenticate('local', {
  successRedirect: '/', // 成功時のログイン先
  failureRedirect: 'login-failure', // 失敗時
})
);

// 認証キャッシュを削除する
router.get('/logout', function(req, res, next) {
  try {
    req.session.destroy();
    req.logout();
    res.clearCookie(sessionCookieName);
    res.redirect('login');
  } catch (e) { next(e); }
});

// ログイン失敗
router.get('/login-failure', function(req, res, next) {
  try {
    res.render('login-failure');
  } catch (e) { next(e); }
});


// サインアップページ
router.get('/signup', function(req, res, next) {
  console.log('get /signup called');
  try {
    res.render('signup', {title: 'サインアップ', user: req.user ? req.user : undefined});
  } catch (e) { next(e); }
});

// サインアップ処理
router.post('/signup', function(req, res, next) {
  try {
    var found = false;
    userList.forEach(item => {
      if(item.username == req.body.username)
      found = true;
    });
    if(found)
    res.redirect('signup-failure');
    else {
      userList.push({id: userList.length, username:req.body.username, password:req.body.password});
      console.log(userList);
      res.redirect('signup-success');
    }
  } catch (e) { next(e); }
});

// サインアップ失敗
router.get('/signup-failure', function(req, res, next) {
  try {
    res.render('signup-failure');
  } catch (e) { next(e); }
});

// サインアップ成功
router.get('/signup-success', function(req, res, next) {
  try {
    res.render('signup-success');
  } catch (e) { next(e); }
});

そして、トップページのルーティング処理は以下。
routes/index.mjs

import * as util from 'util';
import { default as express } from 'express';
export const router = express.Router();

// ホームページ
router.get('/', async (req, res, next) => {
  try {
    // 全メモリストを取得して表示
    res.render('index', {title: 'Passport Test', user: req.user ? req.user : undefined});
  } catch (err) {error(err); next(err);}
});

ビューの処理定義


views/layout.hbs

<!DOCTYPE html>
<html>
  <head>
    <title>{{title}}</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    {{{body}}}
  </body>
</html>


views/error.hbs

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>

このあたりはデフォルトで作成されたもののまま。


views/index.hbs

<h1>{{title}}</h1>
<p>トップページです</p>
{{#if user}}
<h2>ログインユーザー</h2>
<p>ユーザー認証されています</p>
<ul>
  <li>ユーザーID:{{user.id}}</li>
  <li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/users/logout">ログアウトする</a>
<hr>
<h2>登録ユーザーのみのコンテンツ</h2>
<p>これが見れるのは登録ユーザーのみです</p>
{{else}}
<p>ユーザー認証されていません</p>
<p>コンテンツを閲覧にするにはログインするか、ユーザー登録してください</p>
<a href="/users/login">ログイン</a><br>
<a href="/users/signup">新規登録</a>
{{/if}}

トップページでログイン認証がまだされていない場合は、ログインかサインアップへのURLを表示。

Passportでログイン認証済みの場合、パラメータでuserが定義されているので、その場合はユーザー情報を表示。


views/login.hbs

<h1>{{title}}</h1>
<p>ログインするページです</p>
{{#if user}}
<p>すでにログイン済みです</p>
<ul>
  <li>ユーザーID:{{user.id}}</li>
  <li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/">トップページに戻る</a><br>
<a href="/users/logout">ログアウトする</a>
{{else}}
<form action="/users/login" method="post">
    <div>
        <label>ユーザーID:</label>
        <input type="text" name="username" required="required"/>
    </div>
    <div>
        <label>パスワード:</label>
        <input type="password" name="password" required="required"/>
    </div>
    <div>
        <input type="submit" value="ログイン"/>
    </div>
</form>
{{/if}}

ログインページでは、ログイン認証がまだされていない場合は、ログイン入力フォームを表示。

Passportでログイン認証済みの場合、パラメータでuserが定義されているので、その場合はユーザー情報を表示。


views/login-failure.hbs

<h1>ログインに失敗しました</h1>
<a href="/">トップページに戻る</a><br>
<a href="/users/login">ログインする</a>

これはログイン認証失敗時の遷移で使うページ。


views/singup.hbs

<h1>{{ title }}</h1>
<p>ユーザー登録をするページです</p>
{{#if user}}
<p>すでにログイン済みです</p>
<ul>
  <li>ユーザーID:{{user.id}}</li>
  <li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/">トップページに戻る</a><br>
<a href="/users/logout">ログアウトする</a>
{{else}}
<form action="/users/signup" method="post">
    <div>
        <label>ユーザーID:</label>
        <input type="text" name="username" required="required"/>
    </div>
    <div>
        <label>パスワード:</label>
        <input type="password" name="password" required="required"/>
    </div>
    <div>
        <input type="submit" value="登録"/>
    </div>
</form>
{{/if}}

サインアップページでログイン認証がまだされていない場合は、サインアップ入力フォーム表示。

Passportでログイン認証済みの場合、パラメータでuserが定義されているので、その場合はトップページかログアウトするか選択。


views/singup-success.hbs

<h1>サインアップに成功しました</h1>
<p>サービスを続けるにはログインしてください</p>
<a href="/users/login">ログインする</a>

これはサインアップ成功時のページ。


views/singup-failure.hbs

<h1>サインアップに失敗しました</h1>
<p>同じ名前のユーザーがすでに存在します</p>
<a href="/">トップページに戻る</a><br>
<a href="/users/signup">サインアップする</a>

同じユーザー名で登録しようとした時にサインアップ失敗のページ。

Passportの処理の作り

コードの作りを見ていく。

主にPassport関連のモジュールインポート。

import { default as express } from 'express';
import { default as passport } from 'passport';
import { default as passportLocal } from 'passport-local';
const LocalStrategy = passportLocal.Strategy;
import { sessionCookieName } from '../app.mjs';

先ほどアプリの初期化時に出てきたinitPassportだが、Passportの初期化とセッションを使う宣言。

export function initPassport(app) {
  app.use(passport.initialize());
  app.use(passport.session());
}

そして、セッション情報でユーザー情報をシリアライズしたり、デシリアライズする部分。

passport.serializeUser(function(user, done) {
    console.log('serializeUser called');
    try {
      console.log(`serialized user=${user}`);
      done(null, user);
    } catch (e) { done(e); }
});

passport.deserializeUser(async (user, done) => {
    try {
      console.log(`deserialized user=${user}`);
      done(null, user);
    } catch(e) { done(e); }
});

Passportを使ってユーザー認証をする処理が以下。

// テスト用のユーザーを作成
var userList = [
  {id: 0, username: 'me', password: '&&&me123'},
  {id: 1, username: 'you', password: '***you123'}
];

// 該当ユーザーがいるかチェックする
function findUser(username, password) {
  for(let person of userList) {
    if(person.username === username && person.password === password) {
      return {id: person.id, 'username': person.username};
    }
  }
  return undefined;
}

// ログイン用ストラテジー
passport.use(new LocalStrategy(
    async (username, password, done) => {
      console.log(`username = ${username}, passowrd = ${password}`);
      const result = findUser(username, password);
      try {
        console.log(`result=${result}`);
        if (result != undefined) {
          done(null, { id: result.id, username: result.username});
        } else {
          done(null, false, 'Invalid username or password');
        }
      } catch (e) { done(e); }
    }
));

今回は簡易的にuserListという配列に初期のユーザー情報を入れておく。また、あとでサインアップでこの配列に新規ユーザーを追加できるようにする。

何にしても、この配列に存在するユーザー&パスワードの組み合わせかどうかをチェックしてユーザー認証を実施。成功した場合は、ユーザーIDとユーザー名を保持しておいて、ページ表示時に利用する。
これをローカルストラテジーとして登録。

ログインでPOSTで入力フォームから値が送られて来たら、上記ストラテジーに照らして、ログインの 処理を行う。

ログイン成功の場合は、トップページに。
失敗した場合は、失敗したエラーを表示するlogin-failureにページへ遷移。

// ログインを試す
router.post('/login',
    passport.authenticate('local', {
      successRedirect: '/', // 成功時のログイン先
      failureRedirect: 'login-failure', // 失敗時
    })
);

Passportで一度ログイン認証に成功していると、セッション情報に情報を保持しているので、 以降、ログインページを訪問しても、req.userにユーザー情報の値が入っている。

ユーザーがログイン状態であることがわかった場合、views/login.hbs ではログイン状態であることを表示してログインフォーム入力はさせない。

// 認証ログイン画面
router.get('/login', function(req, res, next) {
  try {
    res.render('login', {title: 'ログイン', user: req.user ? req.user : undefined});
  } catch (e) { next(e); }
});

ログアウト処理では認証情報をクリアして、ログインページへリダイレクト。

// 認証キャッシュを削除する
router.get('/logout', function(req, res, next) {
  try {
    req.session.destroy();
    req.logout();
    res.clearCookie(sessionCookieName);
    res.redirect('login');
  } catch (e) { next(e); }
});

ログイン認証の動作チェック

$ npm run start

> node-passport@0.0.0 start
> node ./app.mjs

Listening on port 3000

サーバーを起動させてhttp://localhost:3000にアクセスして動作チェックする。
以下の流れをテストした。

  1. meというすでに登録済みのユーザーでログイン
  2. hogehogeという未登録でのユーザー名でログインを試みて失敗する
  3. hogehogeという新規ユーザーを登録する