Sails.js + PassportでID/Password認証

Passportについて

以前、everyauth を使ってパスワード認証・SNS認証など実装していましたが、なんとなく自分には取っ付き辛く、Passport を見つけてからこちらを採用するようになりました。

Passport は一般的な ID/Password認証 の他に各SNSを使ったOAuth認証までサポートしています。
(認証と権限設定のみなので、投稿機能などの実装は SecretTokenをその他のパッケージに譲ることになります)

passportモジュールは、Passportの骨格となるパッケージとなり、その他 passport-github や passport-twitter などがそれぞれの認証機能を提供しています。

今回は、passport-local を使ってログイン処理をする例を紹介します。

  • Node.js: v0.10.26
  • Sails.js v0.10.0-rc8

記事公開時 v0.10系がrc版 ですが、今のところ基本的にv0.9系もほぼ同じ実装方法で大丈夫だと思います。

Sailsのインストールとフォルダ生成

sails コマンドを使えるようにします。

$ npm install -g sails@v0.10.0-rc8

ちなみに npm install -g sails@beta で unstableの最新版のインストールが可能です。

プロジェクトのベースとなるフォルダ類を sails new コマンドで生成します。

$ sails new sails-passport
$ cd sails-passport

sails lift もしくは node app.js でアプリケーションを起動します。

http://localhost:1337 をブラウザで開いて、起動していることを確認します。

デフォルトでは、ルートアクセスで、views/homepage.ejs を表示しており、 設定は config/routes.jsに定義しています。

これ以降 sails lift コマンドを利用しても良いのですが、コード書き換えの時に何度も再起動を行わなければならないので、再起動モジュールを導入します。

forever のインストールと実行

npm install -g forever

forever を使うことでコード変更を監視し再起動を行うのですが、監視しなくても良いファイルもあるので、それらを除外する設定を行ないます。

## Create .foreverignore (app.jsと同階層に)

**/.tmp/**
assets/**
views/**/*.ejs

.idea/**

.git/**

forever -w app.js で、コード変更時に再起動が行われるようになり、sails liftを何度も実行しなくても良くなります。

ログイン遷移処理とアクセス制限の設定

ログイン承認処理を行う画面と前後の遷移を行えるよう、sailsコマンドで controller と view を作成します。

まず、ログイン画面、ログイン認証、ログアウト処理を実行する controller を作成

$ sails generate controller auth login process logout
info: Created a new controller ("auth") at api/controllers/AuthController.js!

AuthController.js 以下、login/process/logout のプロパティが生成されました。

## View api/controllers/AuthController.js

module.exports = {

    /**
    * `AuthController.login()`
    */
    login: function (req, res) {
        return res.json({
            todo: 'login() is not implemented yet!'
        });
    },

    /// ...

ログイン画面表示

http://localhost:1337/auth/login にアクセスするとcontrollerで定義された json が画面に表示されます。 (auth/process /auth/logoutも同様)

ログインID、パスワード入力画面が必要なので、ログイン画面(view側) を作成します。

## Create views/auth/login.ejs

<form action="/auth/process" method="post">
<div class="login">
    login: <input type="text" name="username" placeholder="Input Login ID" value="" /> <br/>
    password: <input type="password" name="password" placeholder="Input Login Password" value="" /><br/>
    <button type="submit">ログイン</button>
</div>
</form>

/auth/login (api/controllers/AuthController.login) へのアクセスは view (views/auth/login.ejs)に割り当てます。
Auth(Controller)/login なので、return res.view()とすることで、(何も引数を渡さなくても) views/auth/login(.ejs) をview とします。

/auth/process (api/controllers/AuthController.process) へのアクセスは、ログイン認証が終わったものとして /dashboard へリダイレクトします。

/auth/logout (api/controllers/AuthController.logout) へのアクセスは、ログイン認証解除が終わったものとして / へリダイレクトします。

## Modify api/controllers/AuthController.js

module.exports = {

    /**
    * `AuthController.login()`
    */
    login: function (req, res) {

        // ログイン画面表示
        return res.view();
    },

    /**
    * `AuthController.process()`
    */
    process: function (req, res) {

        ////
        // ログイン承認処理
        ////

        //承認後 /dashboardへ
        return res.redirect("/dashboard");
     },

    /**
    * `AuthController.logout()`
    */
    logout: function (req, res) {
    
        ////
        // ログイン承認解除
        ////

        //解除後、topページへ
        return res.redirect("/");
    }
};

続いてログイン承認後の ダッシュボード(ログイン認証済みonly)画面を作成します。

$ sails generate controller dashboard index
info: Created a new controller ("dashboard") at api/controllers/DashboardController.js!
# Modify api/controllers/DashboardController.js

module.exports = {

    /**
    * `DashboardController.index()`
    */    
    index: function (req, res) {

        // ログインonly ダッシュボード
        return res.view();
     }

};

## Create views/dashboard/index.ejs

<div class="hello">
    <h1>Sails.js + Passport Dashboard</h1>
    <p>Hello!!!</p>
</div>

これでユーザーの状態はさておき、ページのモックとなるものは完成しました。

しかしこのままだと、だれでも /dashboard へアクセス可能なページとなりますので、だれもアクセス出来ないページに変更します。

ダッシュボードページへのアクセス制限(暫定)

ダッシュボードへのアクセスにフィルターを掛け、すべてのユーザーを / へリダイレクトする設定を行ないます。

後ほど、ログイン認証機能を実装した時に承認したユーザーのみリダイレクトを行わないように変更するためです。

ログインアクセスポリシーとして loginAuth という(任意の)名称でポリシーファイルを作成します。

## Create api/policies/loginAuth.js

module.exports = function(req, res, next) {

    // All User Redirect
    return res.redirect("/");

};

(※ もともとsessionAuth.jsがあるのでそれを利用してもよい)

続いて、ログインポリシーloginAuth を割り当てるURL(Controller) を設定します。

## Modify config/policies.js

module.exports.policies = {

    // Default policy for all controllers and actions
    // (`true` allows public access)
    '*': true,

    DashboardController: {
        // index : "loginAuth"

        '*': "loginAuth"
    }

}

※ dashboardController でも可

これにより、loginAuthポリシーが有効となり、 /dashbord/(index) へのアクセスはすべて / へリダイレクトします。
ログインポリシーついては passport の実装が終わるまで一旦置いておきます。

ログインユーザデータの設定

ログインユーザー用 User Model と User CRUD を作成します。

$ sails generate api user

info: Created a new controller ("user") at api/controllers/UserController.js!
info: Created a new model ("User") at api/models/User.js!

info: REST API generated @ http://localhost:1337/user
info: and will be available the next time you run `sails lift`.

これにより api/controllers/UserController.jsapi/models/User.js が生成されました。
http://localhost:1337/user アクセスで User への参照、更新/削除などが行える環境が整いました。

(※ 実際の運用には CSRF や Authenticate Policies の定義が必要になりますが、ここでは省略します)

CSRF
/config/csrf.js
Authenticate Policies
/config/policies.js 、 /api/policies/adminAuth.js定義など

一度試してみましょう。 http://localhost:1337/user/create?username=hoge&password=fuga へアクセスします。

するとUserデータが登録されます。

{
    "username": "hoge",
    "password": "fuga",
    "createdAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "updatedAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "id": 1
}

もう一度同じURLにアクセスすると、同じユーザー情報が登録されてしまいます。

{
    "username": "hoge",
    "password": "fuga",
    "createdAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "updatedAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "id": 2
}

http://localhost:1337/user で、確認します。

username の重複防止とパスワードの暗号化を行うために schemeとプロパティ定義を行う必要があります。
(※ development 環境下では、DBデータはローカルファイル(.tmp/localDiskDb.db)に格納されています。)

一旦、登録したユーザーデータを削除します。

http://localhost:1337/user/destroy/1
http://localhost:1337/user/destroy/2

/destroy/:id となるので http://localhost:1337/user/ へアクセスするとデータが削除されていることが確認出来ます。

Model scheme

では、改めて User model の定義に移ります。

modelのプロパティとして attributesに usernamepassword を設定。

username は必須かつユニーク項目、password は必須項目としています。

## Modify api/models/User.js

module.exports = {
    attributes: {
        username: {
            type: 'string',
            required: true,
            unique: true
        },
        password: {
            type: 'string',
            required: true
        }
    }
}

この状態で /user/create へ 必要項目を設定せず登録しようとすると "E_VALIDATION" でエラーが返ります

http://localhost:1337/user/create?test1=a&test2=b

次に必要な項目を設定し http://localhost:1337/user/create?username=hoge&password=fuga へアクセスします。

しっかりデータが登録されます。

{
    "username": "hoge",
    "password": "fuga",
    "createdAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "updatedAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "id": 3
}

同じデータを登録しようとすると usernameunique: true 制限で "E_VALIDATION" エラーが返ります。

では、再びデータを削除します。

http://localhost:1337/user/destroy/3

パスワード暗号化

``beforeCreate``, ``afterCreate`` など、Insert時の前後にhookを掛けることができるので、beforeCreate にて、登録時 ``password`` 項目をSalt化します。

暗号化モジュールのインストール

$ npm install -S bcrypt
## Modify api/models/User.js

var bcrypt = require('bcrypt');
module.exports = {
    attributes: {
        username: {
            type: 'string',
            required: true,
            unique: true
        },
        password: {
            type: 'string',
            required: true
        },
    },

    beforeCreate: function(user, cb) {
        bcrypt.genSalt(10, function(err, salt) {
            bcrypt.hash(user.password, salt, function(err, hash) {
                if (err) {
                    console.log(err);
                    cb(err);
                }else{
                    user.password = hash;
                    cb(null, user);
                }
            });
        });
    }
}

では、Userが登録可能で、hook等が有効かどうかを確認します。

http://localhost:1337/user/create?username=hoge&password=fuga

{
    "username": "hoge",
    "password": "$2a$10$UXZIwUkCx.1IUETSUXHwcuG22FKC7JbDyD1QLEK68UwqdGI0cHPvW",
    "createdAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "updatedAt": "yyyy-MM-ddTHH:mm:ss.sssZ",
    "id": 4
}

その他hook処理

次に例としてオブジェクト取得時に使用されるメソッドの toJson() をオーバーライドします。 http://localhost:1337/user による Userデータを取得(toJson)する際に ``password`` 項目を削除しています。
## Modify api/models/User.js

var bcrypt = require('bcrypt');
module.exports = {

    attributes: {
        toJSON: function() {
            console.log("toJson");
            var obj = this.toObject();
            delete obj.password;
            return obj;
        },
        username: {
            type: 'string',
            required: true,
            unique: true
        },
        password: {
            type: 'string',
            required: true
        }
    },

    beforeCreate: function(user, cb) {
        bcrypt.genSalt(10, function(err, salt) {
            bcrypt.hash(user.password, salt, function(err, hash) {
                if (err) {
                    console.log(err);
                    cb(err);
                }else{
                    user.password = hash;
                    cb(null, user);
                }
            });
        });
    }
}

Passport 承認処理設定

必要なモジュール類をパッケージをインストールします。

$ npm install -S passport passport-local
passport
Passport コアモジュール
passport-local
ID/Password認証用サブモジュール
bcrypt
暗号化モジュール

passport側の ログイン認証処理の基本動作を定義します。

## Create config/passport.js

var passport = require('passport'),
LocalStrategy = require('passport-local').Strategy,
bcrypt = require('bcrypt');


//helper functions
function findById(id, fn) {
    User.findOne(id).exec(function (err, user) {
        if (err) {
            return fn(null, null);
        } else {
            return fn(null, user);
        }
    });
}

function findByUsername(u, fn) {
    User.findOne({
        username: u
    }).exec(function (err, user) {
        // Error handling
        if (err) {
            return fn(null, null);
        // The User was found successfully!
        } else {
            return fn(null, user);
        }
    });
}

// ① passport コアモジュール 基本動作定義
passport.serializeUser(function (user, done) {
    done(null, user.id);
});

passport.deserializeUser(function (id, done) {
    findById(id, function (err, user) {
        done(err, user);
    });
});

// ② passport-local サブモジュール ID/Password認証定義
passport.use(new LocalStrategy(
    function (username, password, done) {
        process.nextTick(function () {

            findByUsername(username, function (err, user) {
                if (err)
                    return done(null, err);
                if (!user) {
                    return done(null, false, {
                        message: 'Unknown user ' + username
                    });
            }
        
            bcrypt.compare(password, user.password, function (err, res) {
                if (!res)
                    return done(null, false, {
                        message: 'Invalid Password'
                    });
                var returnUser = {
                    username: user.username,
                    id: user.id
                };
                return done(null, returnUser, {
                    message: 'Logged In Successfully'
               });
            });
        })
    });
}
));

// ③ express middleware hook
module.exports = {
    express: {
        customMiddleware: function(app){
            // app: express() オブジェクト
            console.log("passport module initialize");
            app.use(passport.initialize());
            app.use(passport.session());
        }
    }
};

passport コアモジュール 基本動作定義

コアの処理としてデータシリアライズなどの定義をします。 この例では user.id のみをキーとして持ち回りしています。

Passport内部でのreq.user への値の出し入れ時に動作します。

passport-local サブモジュール ID/Password認証定義

ログイン処理時(後述)に実行される処理を定義します。 引数として渡された ``userneme``,``password`` を元にUserデータを確認し、必要なデータ(任意のオブジェクト)を、コールバックの引数へ渡します。

express middleware hook

expressの middleware へ passport モジュールを使用するための設定です。 ここは express を利用した場合と同じ実装です。

Passport 認証設定

ログイン処理

ログイン認証機能を実装します。

AuthController.process と config/passport.js の LocalStrategy 定義と割り当てます。

## Modify api/controllers/AuthController.js

var passport = require('passport');

module.exports = {

    //ログイン認証処理
    process: function(req, res){

        passport.authenticate('local', function(err, user, info) {

            if ((err) || (!user)) {
                return res.send({
                    message: 'login failed'
                });
            }

            req.logIn(user, function(err) {
                if (err) res.send(err);
                    return res.send({
                        message: 'login successful'
                    });
                });

            })(req, res);

        },

};

/process への postしたログインが正しく機能するかを確認します。
/user で登録した userneme: hoge & password: foga でログインしてみます。

登録したユーザに対しての正しいパスワード情報で 'Successfully' となれば passport-local の認証機能設定はOKです。

passport を利用すると、@request オブジェクトに login,logout関数が割り当てられるので、login()を実行しダッシュボードへリダイレクトします。

/**
* `AuthController.process()`
*/
process: function(req, res){
    passport.authenticate('local', function(err, user, info) {

        console.log(info);

        if ((err) || (!user)) {
            return res.send({
                message: 'login failed'
            });
        }

        // req.isAuthenticated() -> false
        // req.user -> undefined

        req.logIn(user, function(err) {
            if (err) res.send(err);

            // req.isAuthenticated() -> true
            // req.user -> user -> When new LocalStrategy, Callback user Object

            return res.redirect("/dashboard");

    })(req, res);
},

ログアウト処理

ログアウト
/**
* `AuthController.logout()`
*/
logout: function (req, res) {

    ////
    // ログイン状態解除
    ////
    req.logout();
    res.redirect('/');

}

ダッシュボードへのアクセス制限

passport を連携した @request.login()、@request.logout()の実行により、@response.isAuthenticated() の取得結果が変わります。

この結果を以ってダッシュボードへのアクセス権限である、loginAuthポリシーの処理を変更します

## Modify api/policies/loginAuth.js

module.exports = function(req, res, next) {

    if(req.isAuthenticated()) {
        // This request is authenticated
        return next();
    }

    // This request is not authenticate...
    return res.redirect("/");

};

これにより、ログイン判定処理が行われるようになります。

Routing の変更

好みにも依りますが、URLのRoutingを変更して分かりやすい遷移を行うことが出来ます。

## Modify views/auth/login.ejs

<form action="/login" method="post">
<div class="login">
    login: <input type="text" name="username" placeholder="Input Login ID" value="" /> <br/>
    password: <input type="password" name="password" placeholder="Input Login Password" value="" /><br/>
    <button type="submit">ログイン</button>
</div>
</form>
## Modify config/routes.js
module.exports.routes = {

  '/': {
    view: 'homepage'
  },
  
  'get /login': {
    controller: 'auth',
    action: 'login'
  },

  'post /login': {
    controller: 'auth',
    action: 'process'
  },

  '/logout': {
    controller: 'auth',
    action: 'logout'
  }
}

http://localhost:1337/login からログインすることが可能になりました。