NodeJS – Xác thực người dùng với PassportJS

4.4/5 - (33 votes)

Trong phần này chúng ta sẽ xây dựng tính năng xác thực user.

HTTP là một giao thức vô trạng thái, nghĩa là chúng ta không thể biết user đang lướt web đó có đăng nhập hay không, hay thậm chí chúng ta cũng không biết có đúng là hành động lướt web đó có do con người làm hay không.

Do đó cách xác thực thông thường đối với các ứng dụng sử dụng giao thức HTTP là gửi một đoạn token (một chuỗi id) vào cookie của trình duyệt. Chuỗi token sẽ được dùng để xác định xem người dùng ở trình duyệt đó có đang đăng nhập hay không. Và cứ mỗi lần trình duyệt truy cập đến website thì ngoài các thông tin bình thường, trình duyệt sẽ phải gửi cả chuỗi token đó, và chúng ta sẽ biết được là user nào đang đăng nhập vào với trình duyệt đó.

Node có khá nhiều module hỗ trợ xác thực user thông qua cookie, trong đó 2 module Passport (http://passportjs.org) và Everyauth (http://everyauth.com) là phổ biến nhất. Ở đây chúng ta sẽ dùng module Passport.

Cài module

Ngoài module chính là passport thì chúng ta sẽ cần thêm một số module khác nữa bao gồm connect-flash, passport-local và express-session, đầu tiên chúng ta khai báo các module này trong file package.json:

{
    "name": "notes",
    "version": "0.0.0",
    "private": true,
    "scripts": {
    "start": "node ./bin/www"
    },
    "dependencies": {
        "body-parser": "~1.15.1",
        "cookie-parser": "~1.4.3",
        "debug": "~2.2.0",
        "ejs": "~2.4.1",
        "express": "~4.13.4",
        "morgan": "~1.7.0",
        "serve-favicon": "~2.3.0",
        "async": "*",
        "sqlite3": "*",
        "mongoose": "*",
        "sequelize": "*",
        "connect-flash": "*",
        "passport": "*",
        "passport-local": "*",
        "express-session": "*"
    }
}

Sau đó chạy lệnh npm install để cài các module này vào project.

Ở đây module connect-flash có chức năng hỗ trợ hiển thị thông báo, passport là module chính dùng để xác thực, passport-local là module con trong module passport có chức năng xác thực bằng dữ liệu cục bộ (khác với xác thực thông qua các dịch vụ trung gian như Facebook, Twitter…), express-session hỗ trợ lưu trữ các chuỗi token thông qua session. Chúng ta sẽ lần lượt tìm hiểu các module này kỹ hơn.

Cấu hình app.js

Chúng ta sửa lại file app.js như sau:

var flash = require('connect-flash');
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var expressSession = require('express-session');

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var users = require('./routes/users');
var notes = require('./routes/notes');
//var models = require('./models-fs/notes');
//var models = require('./models-sqlite3/notes');
//var models = require('./models-mongoose/notes');
var models = require('./models-sequelize/notes');
var usersModels = require('./models-sequelize/users');

models.connect(require('./sequelize-params'),
    function(err) {
        if(err)
            throw err;
    });
usersModels.connect(require('./sequelize-params'),
    function(err) {
        if(err)
            throw err;
    });
users.configure({
    users: usersModels,
    passport: passport
});
notes.configure(models);
routes.configure(models);
var app = express();

passport.serializeUser(users.serialize);
passport.deserializeUser(users.deserialize);
passport.use(users.strategy);

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
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')));

app.use(expressSession({secret: 'keyboard cat'}));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());

app.use('/', routes.index);
app.use('/noteview', notes.view);
app.use('/noteadd', users.ensureAuthenticated, notes.add);
app.use('/noteedit', users.ensureAuthenticated, notes.edit);
app.use('/notedestroy', users.ensureAuthenticated, notes.destroy);
app.post('/notedodestroy', users.ensureAuthenticated, notes.dodestroy);
app.post('/notesave', users.ensureAuthenticated, notes.save);
app.use('/account', users.ensureAuthenticated, users.doAccount);
app.use('/login', users.doLogin);
app.post('/doLogin', passport.authenticate('local', {
    failureRedirect: '/login',
    failureFlash: true
}), users.postLogin);
app.use('/logout', users.doLogout);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
});

// error handlers

// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
    app.use(function(err, req, res, next) {
        res.status(err.status || 500);
        res.render('error', {
            message: err.message,
            error: err
        });
    });
}

// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
    res.status(err.status || 500);
    res.render('error', {
        message: err.message,
        error: {}
    });
});


module.exports = app;

Chúng ta sẽ thêm và sửa khá nhiều thứ.

var flash = require('connect-flash');
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var expressSession = require('express-session');

Đầu tiên là 4 dòng require() mới.

passport.serializeUser(users.serialize);
passport.deserializeUser(users.deserialize);
passport.use(users.strategy);

Ba dòng trên dùng để cấu hình module passport, trong đó serializeUser là thiết lập token cho user, deserializeUser là hủy token của user, passport.use(users.strategy) là thiết lập Strategy cho passport. Module passport gọi các cơ chế xác thực là các strategy, chẳng hạn trong phần này chúng ta dùng cách xác thực dữ liệu cục bộ thì gọi là “local strategy”. Cả 3 phương thức trên đều nhận vào tham số là một hàm, chúng ta sẽ định nghĩa các hàm tương ứng đó sau.

app.use(expressSession({secret: 'keyboard cat'}));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());

Passport sẽ lưu các token vào các biến session và dữ liệu session sẽ được lưu vào cookie. Ở đây chúng ta dùng module express-session. Hàm flash() sẽ khởi tạo module connect-flash, module này chỉ đơn giản là một module hỗ trợ hiển thị các câu thông báo. Phương thức passport.initialize() sẽ khởi tạo module passport, phương thức passport.session() sẽ bật tính năng sử dụng session, lưu ý là chúng ta phải khởi tạo express-session trước rồi mới sử dụng session trong passport.

var models = require('./models-sequelize/notes');
var usersModels = require('./models-sequelize/users');

models.connect(require('./sequelize-params'),
    function(err) {
        if(err)
            throw err;
    });
usersModels.connect(require('./sequelize-params'),
    function(err) {
        if(err)
            throw err;
    });
users.configure({
    users: usersModels,
    passport: passport
});
notes.configure(models);
routes.configure(models);

Bắt đầu từ đây chúng ta không truyền các thông tin về cơ sở dữ liệu trực tiếp nữa mà thay vào đó sẽ lưu trong một module riêng, và khi cần thì chúng ta tham chiếu đến module đó, ở đây là file sequelize-params.js được tạo trong thư mục gốc của project, chúng ta sẽ tạo file này sau. Ngoài ra chúng ta cũng sẽ tạo một file users.js dùng để lưu thông tin của user, file này cũng chứa các thông tin và hàm dùng để kết nối CSDL như file model notes.js, chúng ta cũng sẽ tạo file này sau.

app.use('/', routes.index);
app.use('/noteview', notes.view);
app.use('/noteadd', users.ensureAuthenticated, notes.add);
app.use('/noteedit', users.ensureAuthenticated, notes.edit);
app.use('/notedestroy', users.ensureAuthenticated, notes.destroy);
app.post('/notedodestroy', users.ensureAuthenticated, notes.dodestroy);
app.post('/notesave', users.ensureAuthenticated, notes.save);
app.use('/account', users.ensureAuthenticated, users.doAccount);
app.use('/login', users.doLogin);
app.post('/doLogin', passport.authenticate('local', {
    failureRedirect: '/login',
    failureFlash: true
}), users.postLogin);
app.use('/logout', users.doLogout);

Cuối cùng chúng ta thêm và sửa lại phần routing, trong đó có một số đường dẫn routing sẽ cần thêm một hàm dùng để xác nhận xem người dùng có đang đăng nhập hay không bằng hàm users.ensureAuthenticated. Đường dẫn /login sẽ được dùng để thực hiện xác thực người dùng, trong đó chúng ta gọi phương thức passport.authenticate('local'...), phương thức này sẽ gọi đến phương thức kiểm tra mà chúng ta sẽ viết thêm ở dưới.

Như đã nói ở trên, chúng ta tạo file sequelize-params.js dùng để lưu các thông tin về cơ sở dữ liệu như sau:

module.exports = {
    dbname: "notes",
        username: "<username>",
        password: "<mật khẩu>",
        params: {
            host: "127.0.0.1",
            dialect: "mysql"
        }
};

Bạn thay username và mật khẩu tương ứng với CSDL của mình.

Tạo users model

Trong thư mục models-sequelize, chúng ta tạo file users.js có nội dung như sau:

var util = require('util');
var Sequelize = require('sequelize');
var sequelize = undefined;
var User = undefined;

module.exports.connect = function(params, callback) {
    sequelize = new Sequelize(params.dbname,
        params.username,
        params.password,
        params.params);
        User = sequelize.define('User', {
            id: {
                type: Sequelize.INTEGER,
                primaryKey: true,
                unique: true
            },
            username: {
                type: Sequelize.STRING,
                unique: true
            },
            password: Sequelize.STRING,
            email: Sequelize.STRING
        });
        User.sync().then(function() {
        callback()
    }).error(function(err) {
        callback(err);
    });
}

module.exports.findById = function(id, callback) {
    User.find({ where: { id: id} }).then(function(user) {
        if(!user) {
            callback('User ' + id + ' does not exist');
        } else {
            callback(null, {
                id: user.id,
                username: user.username,
                password: user.password,
                email: user.email
            });
        }
    });
}

module.exports.findByUsername = function(username, callback) {
    User.find({where: {username: username}}).then(function(user) {
        if(!user) {
            callback('user ' + username + ' does not exist');
        } else {
            callback(null, {
                id: user.id,
                username: user.username,
                password: user.password,
                email: user.email
           });
        } 
    });
}

module.exports.create = function(id, username, password, email, callback) {
    User.create({
        id: id,
        username: username,
        password: password,
        email: email
    }).then(function(user) {
        callback();
    }).error(function(err) {
        callback(err);
    });
}

module.exports.update = function(id, username, password, email, callback) {
    User.find({where: {id: id}}).then(function(user) {
        user.updateAttributes({
            id: id,
            username: username,
            password: password,
            email: email
        }).then(function() {
            callback();
        }).error(function(err) {
            callback(err);
        });
    });
}

File này đại diện cho phần model của user, tất cả đều tương tự như file notes.js trong cùng thư mục. Hàm connect() dùng để tạo bảng, các hàm read(), create(), update(), destroy(), titles() được dùng để xem, sửa, xóa, tạo mới một bản ghi người dùng trong cơ sở dữ liệu.

Routing

Trong thư mục routes, chúng ta tạo một file có tên users.js, khi tạo project thì express cũng có tạo một file như vậy nhưng cũng không có gì nhiều trong đó, nếu bạn có file đó thì khỏi tạo, trong file này chúng ta có thêm code như sau:

var LocalStrategy = require('passport-local').Strategy;
var users = undefined;
var passport = undefined;

exports.configure = function(params) {
    users = params.users;
    passport = params.passport;
}

module.exports.serialize = function(user, done) {
    done(null, user.id);
}

module.exports.deserialize = function(id, done) {
    users.findById(id, function(err, user) {
        done(err, user);
    });
}

module.exports.strategy = new LocalStrategy(
    function(username, password, done) {
        process.nextTick(function() {
            users.findByUsername(username, function(err, user) {
                if(err)
                    return done(err);
                if(!user) {
                    return done(null, false, {
                        message: 'Unknown user ' + username
                    });
                }
                if(user.password !== password) {
                    return done(null, false, {
                        message: 'Invalid password'
                    });
                }
                return done(null, user);
            });
        });
    }
);

module.exports.ensureAuthenticated = function(req, res, next) {
    if(req.isAuthenticated())
        return next();
    return res.redirect('/login');
}

module.exports.doAccount = function(req, res) {
    res.render('account', {
        title: 'Account information for ' + req.user.username,
        user: req.user
     });
}

module.exports.doLogin = function(req, res) {
    res.render('login', {
        title: 'Login to Note',
        user: req.user,
        message: req.flash('error')
    });
}

module.exports.postLogin = function(req, res) {
     res.redirect('/');
}

module.exports.doLogout = function(req, res) {
     req.logout();
     res.redirect('/');
}

Đoạn code trên xử lý phần định tuyến cho các url mới như /login, /account, /doLogin.

var LocalStrategy = require('passport-local').Strategy;
var users = undefined;
var passport = undefined;

exports.configure = function(params) {
    users = params.users;
    passport = params.passport;
}

Hàm configure() có chức năng tương tự như trong file routes/notes.js, đó là nhận thông tin về user hiện tại, và module passport hiện được dùng để xác thực.

module.exports.serialize = function(user, done) {
    done(null, user.id);
}

module.exports.deserialize = function(id, done) {
    users.findById(id, function(err, user) {
        done(err, user);
    });
}

Như đã nói ở trên, hàm serialize() sẽ tạo chuỗi token, ở đây chúng ta chỉ đơn giản là dùng chính id của người dùng để làm token. Hàm deserialize() sẽ hủy chuỗi token đó.

module.exports.strategy = new LocalStrategy(
    function(username, password, done) {
        process.nextTick(function() {
            users.findByUsername(username, function(err, user) {
                if(err)
                    return done(err);
                if(!user) {
                    return done(null, false, {
                        message: 'Unknown user ' + username
                    });
                }
                if(user.password !== password) {
                    return done(null, false, {
                        message: 'Invalid password'
                    });
                }
                return done(null, user);
            });
        });
    }
);

Tiếp theo chúng ta khởi tạo một đối tượng lớp LocalStrategy, khi xác thực bằng cách gọi hàm passport.authenticate() trong file app.js thì hàm trong đối tượng LocalStrategy này sẽ được gọi để thực hiện kiểm tra username và mật khẩu, ở đây chúng ta cũng kiểm tra đơn giản, chỉ là xem username có tồn tại hay không, nếu có thì kiểm tra xem password có trùng hay không, nếu tất cả đều hợp lệ thì trả về dữ liệu của user đó, nếu có vấn đề gì thì chúng ta trả về lỗi. Trên thực tế chúng ta sẽ cần làm nhiều thứ hơn như mã hóa mật khẩu, dùng salt…v.v Ngoài ra ở đây hàm process.nextTick() sẽ làm các công việc trên theo hướng bất đồng bộ.

module.exports.ensureAuthenticated = function(req, res, next) {
    if(req.isAuthenticated())
        return next();
    return res.redirect('/login');
}

Tiếp theo hàm ensureAuthenticated() là hàm kiểm tra xem người dùng có đang đăng nhập hay không khi lướt qua các trang khác, chúng ta chỉ cần gọi hàm req.isAuthenticated() là đủ, đây là hàm do module passport cung cấp.

module.exports.doAccount = function(req, res) {
    res.render('account', {
        title: 'Account information for ' + req.user.username,
        user: req.user
     });
}

Hàm doAccount() sẽ xử lý đường dẫn /account và hiển thị thông tin về user.

module.exports.doLogin = function(req, res) {
    res.render('login', {
        title: 'Login to Note',
        user: req.user,
        message: req.flash('error')
    });
}

module.exports.postLogin = function(req, res) {
     res.redirect('/');
}

Hàm doLogin() sẽ hiển thị form đăng nhập cho người dùng, hàm req.flash() sẽ hiển thị thông báo nếu người dùng đưang nhập sai. Hàm postLogin() sẽ chuyển hướng về trang '/' khi người dùng đăng nhập thành công.

module.exports.doLogout = function(req, res) {
     req.logout();
     res.redirect('/');
}

Hàm doLogout() sẽ xóa thông tin đăng nhập của người dùng, hàm req.logout() sẽ thực hiện xóa các thông tin đó, hàm này do passport thêm vào.

Sửa view

Chúng ta sửa lại file top.ejs như sau:

<html>
<head>
    <title><%= title %></title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
    <h1><%= title %></h1>
    <div class='navbar'>
        <p>
        <a href='/'>Home</a> | <a href='/noteadd'>ADD Note</a>
        <% if(user) { %>
        | <a href='/logout'>Log Out</a>
        | logged in as <a href='/account'><%= user.username %></a>
        <% } else { %>
        | <a href='/login'>Log in</a>
        <% } %>
    </p>
 </div>

Bây giờ website sẽ hiển thị thêm đường dẫn để đăng nhập, hoặc thông tin người dùng đã đăng nhập.

Tiếp theo chúng ta tạo file account.ejs trong thư mục views như sau:

<% include top %>
<p>Name: <%= user.username %> (<%= user.id %>)</p>
<p>E-Mail: <%= user.email %></p>
<% include bottom %>

File này sẽ được dùng để hiển thị thông tin chi tiết về người dùng.

Cuối cùng chúng ta tạo file login.ejs trong thư mục views như sau:

<% include top %>
<form method="POST" action="/doLogin">
    <p>User name: <input type='text' name='username' /></p>
    <p>Password: <input type='text' name='password' /></p>
    <p><input type='submit' value='Submit' /></p>
</form>
<% include bottom %>

File này sẽ hiển thị form đăng nhập cho người dùng. Khi người dùng đăng nhập thì form sẽ gửi một yêu cầu đến đường dẫn /doLogin với phương thức là POST.

Sửa routing

Chúng ta sửa lại file notes.js trong thư mục routes như sau:

var notes = undefined;
exports.configure = function(params) {
    notes = params;
}

var readNote = function(key, user, res, done) {
    notes.read(key, function(err, data) {
        if(err) {
            res.render('showerror', {
                title: "Could not read note " + key,
                error: err,
                user: user ? user : undefined
            });
            done(err);
        } else
            done(null, data);
    });
}

exports.view = function(req, res, next) { 
    if(req.query.key) {
        readNote(req.query.key, req.user, res, function(err, data) {
            if(!err) {
                res.render('noteview', {
                    title: data.title, 
                    notekey: req.query.key,
                    note: data,
                    user: req.user ? req.user : undefined
                });
            }
        });
    } else {
        res.render('showerror', {
            title: "No key given for Note", 
            error: "Must provide a Key to view a Note",
            user: req.user ? req.user : undefined
        });
    }
}

exports.save = function(req, res, next) { 
    ((req.body.docreate === "create") ? notes.create : notes.update)
    (req.body.notekey, req.body.title, req.body.body,
        function(err) { 
            if(err) {
                res.render('showerror', {
                    title: "Could not update file",
                    error: err,
                    user: req.user ? req.user : undefined
            });
        } else {
            res.redirect('/noteview?key='+req.body.notekey);
        }
    });
}
exports.add = function(req, res, next) {
    res.render('noteedit', {
        title: "Add a Note",
        docreate: true,
        notekey: "",
        note: undefined,
        user: req.user ? req.user : undefined
    });
}

exports.edit = function(req, res, next) {
    if(req.query.key) {
        readNote(req.query.key, req.user, res, function(err, data) {
            if(!err) {
                res.render('noteedit', {
                    title: data ? ("Edit " + data.title) : "Add a Note", 
                    docreate: false,
                    notekey: req.query.key,
                    note: data,
                    user : req.user ? req.user : undefined
                });
            }
        });
    } else {
        res.render('showerror', {
             title: "No key given for Note",
             error: "Must provide a Key to view a Note",
             user : req.user ? req.user : undefined
         });
     }
}

exports.destroy = function(req, res, next) {
    if(req.query.key) {
        readNote(req.query.key, req.user, res, function(err, data) {
            if(!err) {
                res.render('notedestroy', {
                    title: data.title,
                    notekey: req.query.key,
                    note: data,
                    user: req.user ? req.user : undefined
                });
            }
        });
    } else {
        res.render('showerror', {
            title: "No key given for Note", 
            error: "Must provide a Key to view a note",
            user: req.user ? req.user : undefined
        });
    }
}

exports.dodestroy = function(req, res, next) {
    notes.destroy(req.body.notekey, function(err) {
        if(err) {
            res.render('showerror', {
                title: "Could not delete Note " + req.body.notekey,
                error: err
            });
        } else {
           res.redirect('/');
        }
    });
}

Ở đây chúng ta thêm vào đối tượng user để mỗi lần hiển thị lên trình duyệt thì không bị lỗi undefined.

Tạo user

Vậy là mọi thứ đã hoàn tất, bây giờ trước khi chạy thử thì chúng ta phải có tài khoản để sử dụng, do ở đây chúng ta không thực hiện chức năng đăng ký tài khoản nên chúng ta phải làm bằng tay. Để tiện thì trong thư mục models-sequelize, chúng ta tạo một file có tên setup.js với nội dung như sau:

var users = require('./users');
users.connect(require('../sequelize-params'),
    function(err) {
        if(err)
            throw err;
        else {
             users.create('1', 
                         'phocode', 
                         '123', 
                         'admin@phocode.com',
                         function(err) {
                             if(err)
                                 throw err; 
                         });
        }
    });

Đoạn code trên sẽ tạo tài khoản và lưu vào CSDL. Để chạy thì bạn mở cmd lên trong thư mục models-sequelize rồi chạy lệnh node setup.js là được.

Vậy là xong, bây giờ chúng ta có thể chạy ứng dụng và sử dụng chức năng đăng nhập được rồi.

capture

0 0 votes
Article Rating
Subscribe
Thông báo cho tôi qua email khi
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

3 Comments
Inline Feedbacks
View all comments
Nguyễn Quang Huy
Nguyễn Quang Huy
7 năm trước

Cho mính hỏi tý tại sao khi đăng nhập sai username nó cứ redirect về trang dologin trắng trang trong khi đó đăng nhập sai password vẫn xử lý bình thường ? mà chỉ khai báo dologin là chỉ trang dạng post

vic
vic
6 năm trước

Cho minh hỏi:
Bên mình chạy nó báo lỗi này

TypeError: E:\www\nodejs7\FirstProject\views\index.ejs:1
 >> 1| <% include top %>

Minh mở file top.ejs bỏ

<% if(user) { %>
        | <a href='/logout'>Log Out</a>
        | logged in as <a href='/account'><%= user.username %></a>
        <% } else { %>
        | <a href='/login'>Log in</a>
        <% } %>

Minh thắc mắc khi chạy thi mình thấy biên user k khai báo nên nó lỗi?