【Node.js】 SequelizeとPassportで名前とパスワードのシンプルな認証を実装する

🍱要旨

  • Node.js V8.10.0, OS X 10.13.4 で検証しています。
  • ソースコードはTypeScriptで記述しています。
  • Node.js用のORMとしてSequelizeを使用しています。データベースは簡易的にSQLiteを使用します。
  • ユーザー名とパスワード (パスワードはBCryptにより暗号化する)によって Cookieによる認証を行います。
  • express: 4.16.2
  • passport: 0.4.0
  • sequelize 4.37.4

🍈もくじ

🥁 今回作成したサンプルはこちらに置いてあります。

https://github.com/AtaruOhto/ns-sequelize-passport-auth-sample/archive/master.zip

# サンプルの動かし方
cp .env.sample .env
touch data.sqlite
yarn 
yarn run build
yarn start

🍺Sequelizeとは?

SequelizeはNode.js用の老舗のORMです。Postgres, MySQl, MariaDB,SQLiteなどのデータベースに対応しています。 Node.js用のORMとしては他にBookshelfObjection.js, waterlineなどがあります。 今回は作成するユーザーのためにSequelizeを使います。ユーザーのパスワードはBCryptで暗号化します。

🍰Passportとは?

PassportはNode.js用の認証ライブラリー (ミドルウェア) です。シンプルなユーザー名とパスワードによる認証からGoogle、FacebookやTwitterなどのSSO認証など幅広く対応しています。 今回はシンプルなユーザー名とパスワードでの認証をpassportとその認証ストラテジーであるpassport-localを使って実装します。

🍵Sequelizeの設定とユーザーの作成

ちょっと粗いですが、まずはSequelizeのインスタンスを生成して、SQLiteに接続できるようにします。 簡易的にここでは、このファイルがロードされた時点で userテーブルのレコード数が0であれば、下記のユーザーを作成します。

name: "first"
password "pass"
/* models/user.ts */

const Sequelize = require('sequelize');
import { Model } from 'sequelize';
const Op = Sequelize.Op;
import { genHash } from 'concerns/bcrypt';
const appRoot = require('app-root-path');

interface IUserAttrs {
    name: string;
    password: string;
}

interface IUserOptionalAttrs {
    name?: string;
    password?: string;
}

export type TUser = Model<'user', IUserAttrs>;

const dataFile = appRoot + '/data.sqlite';
export const sequelizeInstance = new Sequelize(
    'sample_db', null, null, {
        dialect: 'sqlite',
        storage: dataFile,
        operatorsAliases: Op
    }
);

export const UserModel = sequelizeInstance.define('user', {
    name: {type: Sequelize.STRING, allowNull: false, unique: true},
    password: {type: Sequelize.STRING, allowNull: false},
});

/* ユーザーを作成する */
export const createUser = (attrs: IUserAttrs) => (
    new Promise(async (resolve, reject) => {

        /* passwordはBCryptで暗号化する */
        attrs.password = await genHash(attrs.password).catch((error: any) => {
            reject(error);
        });

        UserModel.create(attrs).then(
            (instance: any) => {
                resolve(instance);
            }, (error: any) => {
                reject(error);
            });
    })
);

/* ユーザーを検索する */
export const findOneUser = (attrs: IUserOptionalAttrs): Promise<any> => (
    new Promise((resolve, reject) => {
        UserModel.findOne({where: attrs}).then(
            (instance: TUser) => {
                resolve(instance);
            },
            (error: any) => {
                reject(error);
            });
    })
);

/* userテーブルに何もなければユーザーを作成する */
const createFirstUserIfNotExist = async () => {
    const numOfUser = await UserModel.count().catch((error: any) => { console.error(error); });

    if (numOfUser === 0) {
        createUser({name: 'first', password: 'pass'}).catch((error: any) => { console.error(error); });
        console.log('first user created!');
    }
};

(() =>  {
    sequelizeInstance.sync().then(
        () => {
            console.log('Database synced');
            if (process.env.CREATE_USER_IF_NOT_EXITS) {
                createFirstUserIfNotExist();
            }
        },
        (error: any) => {
            console.error(error);
        });
})();

BCrypt関連の処理は下記でおこなっています。

/* concerns/bcrypt.ts */

const bcrypt = require('bcrypt');

/* パスワードと所与の平文を比較する */
export const comparePlainWithHash = (plainText: string, encrypted: string): Promise<any> => (
    new Promise((resolve, reject) => {
        bcrypt.compare(plainText, encrypted, (error: any, isEqual: boolean) => {
            if (error) {
                reject(error);
            }
            resolve(isEqual);
        });
    })
)

/* 平文をハッシュ化する */
export const genHash = (plainText: string, saltRounds: number = 10): Promise<any> => (
    new Promise((resolve, reject) => {
        bcrypt.genSalt(saltRounds, (error: any, salt: string) => {
            if (error) {
                reject(error);
            }

            bcrypt.hash(plainText, salt, (hashError: any, encrypted: string) => {
                if (hashError) {
                    reject(hashError);
                }
                resolve(encrypted);
            });
        });
    })
)

🍡Passport: LocalStrategyを設定

ユーザー作成の処理ができたので、Passportを設定します。 passport-localを使って名前とパスワードのシンプルなログイン機能を実装します。

import { Express, Request } from 'express';
import { PassportStatic } from 'passport';
import passport from 'passport';
import {Strategy as LocalStrategy } from 'passport-local';

import { findOneUser, TUser } from 'models/user';
import { comparePlainWithHash } from 'concerns/bcrypt';

/* ログイン成功時の処理 */
const authSuccess = (done: Function, user: TUser, req: Request): Function => {
    const msg = 'LOGIN SUCCESS';
    console.log(msg + user.name);
    return done(null, user);
};

/* ログイン失敗時の処理 */
const authFailed = (done: Function, req: Request, error = ''): Function => {
    const msg = 'LOGIN FAILED';
    console.error(msg + error);
    return done(null, false);
};

/* passport-localを設定する */
const usePassportLocalStrategy = (passportStatic: PassportStatic) => {
    passportStatic.serializeUser(function (user: TUser, done: Function) {
        done(null, user);
    });

    passportStatic.deserializeUser(function (user: TUser, done: Function) {
        done(null, user);
    });

    passportStatic.use(new LocalStrategy(
        {passReqToCallback: true, }, async(req: Request, name: string, password: string, done: Function) => {

            /* nameを元にユーザーを検索する */
            const user = await findOneUser({name: name}).catch((error: any) => {
                return authFailed(done, req, error);
            });

            /* ユーザーがいなければfail */
            if (!(user)) {
                return authFailed(done, req, ` User ${name} does not exist!`);
            }

            /* 暗号化されたパスワードとpostされたパスワードが正しいかどうか比較する */
            const isPasswordValid = await comparePlainWithHash(password, user.password).catch((error: any) => {
                return authFailed(done, req, error);
            });

            /* パスワードがただしければ、認証成功 */
            return isPasswordValid ? authSuccess(done, user, req) : authFailed(done, req);
        }));
};

const usePassport = (app: Express) => {
    app.use(passport.initialize());
    app.use(passport.session());
    usePassportLocalStrategy(passport);
};

export const useAuthMiddlewares = (app: Express) => {
    usePassport(app);
};

export const getPassport = () => (
    passport
);

🥪セッション関連の設定

今回はセッションを有効にするので、セッション関連のコードを定義します。

import { Express } from 'express';
const cookieParser = require('cookie-parser');
const session = require('express-session');

const useCookieParser = (app: Express) => {
    app.use(cookieParser());
};

/*
* connection.session() MemoryStore is not
designed for a production environment.
It only for development or test use.
Consider to use Redis or other databases as a session store.
*/

const useSession = (app: Express) => {
    app.use(session(
        {
            secret: process.env.SECRET_KEY_BASE,
            resave: true,
            saveUninitialized: true,
            maxAge: 1000 * 60 * 60 * 90,
            cookie: {
                path: '/'
            }
        }
        )
    );
};

export const useSessionMiddlewares = (app: Express) => {
    useCookieParser(app);
    useSession(app);
};

🍔ルーティングの定義

Node.jsアプリのルーティングを定義していきます。 そして、Passportをミドルウェアとして、適用します。

/* config/routes.ts */

import { Express, Response, Request, NextFunction } from 'express';
import { getPassport } from 'config/auth';

export const loginRoute = '/login';
export const secretRoute = '/secret';
export const logoutRoute = '/logout';
const loginView = 'login';
const secretView = 'secret';

export const redirectToLogin = (res: Response) => {
    return res.redirect(loginRoute);
}

export const redirectUnlessSession = (req: Request, res: Response, next: NextFunction) => {
    if (!req.isAuthenticated()) {
        return res.redirect(loginRoute);
    }
    return next();
};

export const redirectIfSession = (req: Request, res: Response, next: NextFunction) => {
    if (req.isAuthenticated()) {
        return res.redirect(secretRoute);
    }
    return next();
};

export const defineRoutes = (app: Express) => {

    /* 
        ログイン画面
        認証済であれば、 /secret へリダイレクト
     */
    app.get(
        loginRoute,
        redirectIfSession,
        (req: Request, res: Response) => {
            res.render(
                loginView,
                {
                    loginPath: loginRoute
                }
            );
        });

    /* 
        認証用パラメータのpost先。
        PassportのLocalStrategyを使って認証処理を行う。
        認証失敗時 => /login へリダイレクト
        認証成功時 => /secret へリダイレクト
     */
    app.post(
        loginRoute,
        getPassport().authenticate('local', {
            successRedirect: secretRoute,
            failureRedirect: loginRoute,
            successFlash: true,
            failureFlash: true
        }),
    );

    /* 
        ログイン後にのみアクセスできる画面
        未認証であれば、/login へ     
     */
    app.get(secretRoute, redirectUnlessSession, (req: Request, res: Response) => {
        if (!(req.user)) {
            console.error('User is null');
            return redirectToLogin(res);
        }

        res.render(
            secretView,
            {
                user: req.user,
                logoutPath: logoutRoute + '?_method=DELETE'
            }
        );
    });

    /* 
        ログアウト処理
        未認証であれば、/login へ     
     */
    app.delete(logoutRoute, redirectUnlessSession, (req: Request, res: Response) => {
        req.logout();
        res.redirect(loginRoute);
    });
}

最後に起動スクリプトである、index.tsを定義します。


/* index.ts */


require('dotenv').config();
require('app-module-path').addPath(__dirname);
import express, { Express } from 'express';
import { defineRoutes } from 'config/routes';
import { useSecurityMiddlewares } from 'config/security';
import { useRequestMiddlewares } from 'config/request';
import { setViewEngine } from 'config/viewEngine';
import { useAuthMiddlewares } from 'config/auth';
import { useSessionMiddlewares } from 'config/session';

const listen = (app: Express) => {
    app.listen(process.env.APP_PORT, () => {
        console.log('Node Process is running at port : ' + process.env.APP_PORT);
    });
};

const defRoutes = (app: Express) => {
    defineRoutes(app);
};

const configureServer = (app: Express) => {
    useRequestMiddlewares(app);
    setViewEngine(app);
    useSessionMiddlewares(app);
    useSecurityMiddlewares(app);
    useAuthMiddlewares(app);
};

const startServer = () => {
    const app = express();
    configureServer(app);
    defRoutes(app);
    listen(app);
};

startServer();

🥖動作確認

動作確認をしてみましょう。

Node.jsプロセスを立ち上げて、 http://localhost:3000/login にアクセスします。

ログイン画面

下記情報を入力して、ログイン試行すると、 /secret にリダイレクトされます。 それ以外の認証情報だと、ログインが弾かれます。

ユーザー名: first
パスワード: pass

無事にログインが成功すると、下記画面が表示されます。 Logout ボタンをクリックすると、セッションを破棄して、ログアウトします。

ログイン完了

🥁 今回作成したサンプルはこちらに置いてあります。

https://github.com/AtaruOhto/ns-sequelize-passport-auth-sample/archive/master.zip

  • このエントリーをはてなブックマークに追加