[12 Project 學 Node.js] Project 3: User Login System – iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天
Description
- 使用者驗證登入功能
- 參考Passportjs網站,提供許多驗證方法說明
- Register Page / Login Page
這章節新使用到的Module
- mongodb: 提供 mongodb 連線接口的 module
- mongoose: 目前最多人用的mongodb物件模組化工具之一,讓操作mongodb更方便
- connect-flash: message的暫存器,暫存器裡的message使用一次後即被清空,適合用來作網站的提示資訊
- express-messages: 協助connect-flash,提供flash通知的呈現
- express-validator: express 表單驗證功能,可加入自定義的驗證
- express-session: 用來設定 session,可用來實作 login/logout 功能
- passport: 身份認證,為每種認證提供一種策略,使用時只要載入需要的策略即可
- passport-local: 用來處理本機模組的身份驗證策略
- passport-http: 用來處理HTTP的身份驗證策略
- multer: 用來upload image
Install MongoDB
接下來會使用 MongoDB 儲存 Register info.
註:
通常node.js會搭配MongoDB,因為MongoDB是使用javascript為腳本開發的,資料使用JSON格式儲存,這代表 MongoDB & Node.js 之間不需要額外的資料轉換就可以做資料交換。
下載MongoDB後安裝
MongoDB官網
安裝完畢後,到安裝路徑下準備mongodb資料和log儲存路徑
bin 資料夾內包含 Mongodb 的啟動相關程式
在bin同一層建立 data 資料夾、log資料夾
data內建立db資料夾
用系統管理員權限打開cmd
輸入以下指令啟動MongoDB Service
(如果出現NET 2185存取被拒,表示沒有用系統管理員權限啟動服務)
# 設定MongoDB服務
mongod --directoryperdb --dbpath D:\MongoDB\Server\3.4\data\db --logpath D:\MongoDB\Server\3.4\log\mongodb.log --logappend --rest --install
# 啟動服務
net start MongoDB
Command Options:
- directoryperdb: 為每個DB的資料儲存在分開的資料夾,資料夾路徑由dbpath指定
- dbpath: db資料儲存位置
- logpath: 將db log從console log移到特定檔案位置
- logappend: mongoDB預設在寫新log時會移除掉舊log檔案,使用此option可用append的方式加上新log
- rest: 啟動簡單REST API,即啟用HTTP介面
- install: 將mongoDB安裝為Windows Service,安裝完關閉視窗
詳細說明參考官網文件
也可以把這串指令存成 bat 檔,以後啟動時只要執行這個bat就好了,bat如下:
@echo off
set work_dir=D:\Setups\MongoDB\Server\3.4\
d:
cd %work_dir%bin
rem mongod -dbpath "d:\Setups\Mongodata"
mongod --directoryperdb --dbpath %work_dir%data\db --logpath %work_dir%log\mongodb.log --logappend --rest --install
net start MongoDB
順利啟動的話,可以在工作管理員的服務tab看到mongodb service
開啟cmd,切換到mongo的bin目錄下,輸入以下指令進入mongo shell
mongo
註:
建議把mongo路徑加到環境變數,以後可以直接在cmd下指令,不需要切換目錄,會方便很多
測試指令確認mongo db正常
# 查看目前使用的db name
db
# 使用/建立 customers db
use customers
# 查看DB list (這時看不到customers db,因為此db還沒有任何資料)
show db
# create customers collection in customers db
db.createCollection("customers");
# 查看DB list (此時customers db有customers collection,可以看到db了)
show dbs
建立資料庫,作為user register & login使用
# 使用/建立 nodeauth db
use nodeauth
# create users collection
db.createCollection("users");
# 檢查collection list,確認users collection存在
show collections
以下指令測試 insert/find/update/remove資料
# insert 資料到users collection
db.users.insert({name: "Grace Yu", email: "[email protected]", username: "grace", password: "1234"});
db.users.insert({name: "Yuki", email: "[email protected]", username: "yuki", password: "1234"});
# find 所有資料 (query) from users collection
db.users.find()
# 加上pretty(),用整齊展開的JSON format呈現find結果
db.users.find().pretty();
# update 資料 by 特定條件
db.users.update({username:"yuki"},{$set:{email:"[email protected]}}};
# remove 資料 by 特定條件
db.users.remove({username:"grace"});
App & Middleware Setup
DB 設定好之後,開新project folder,開始撰寫 node.js 程式,首先要加入會用到的 middleware
首先在新project folder內使用npm安裝 express和 express-generator
npm install -g express
npm install -g express-generator
自動create express目錄
express
目錄如下
- bin: 啟動server的檔案 (www)
- routes: 放置子page的js檔案
- uploads: 放置upload檔案
- views: 放置子page的jade檔案
- public: 放置網頁用到的css\javascript\jquery等檔案
和之前手動撰寫的 express website 不同
此時的 app.js 不包含啟動 server的code,而是用來設定 middleware
最後再 export 成 function,讓此 project 的其他 js 檔案可以使用 app.js 中的 middleware
打開package.json,可以看到已經定義好一些dependencies
加入幾個此project需要用到的module
{
"name": "3-nodeauth",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"body-parser": "~1.16.0",
"cookie-parser": "~1.4.3",
"debug": "~2.6.0",
"express": "~4.14.1",
"jade": "~1.11.0",
"morgan": "~1.7.0",
"serve-favicon": "~2.3.2",
"mongodb": "*",
"mongoose": "*",
"connect-flash": "*",
"express-messages": "*",
"express-validator": "*",
"express-session": "*",
"passport": "*",
"passport-local": "*",
"passport-http": "*",
"multer": "*"
}
}
使用 npm 安裝新增的模組
npm install
也可以用以下方式安裝,加上 –save options才會把安裝的module加到package.json
npm install mongodb mongoose connect-flash express-messages express-validator express-session passport passport-local passport-http multer --save
修改 app.js,加入新模組,並使用mongoonse建立DB連線
var express = require('express');
......
var bodyParser = require('body-parser');
//add new module
var session = require('express-session');
var passport = require('passport');
var expressValidator = require('express-validator');
var LocalStrategy = require('passport-local').Strategy;
var multer = require('multer');
var upload = multer({dest: './uploads'}); # setup multer upload destination
var flash = require('connect-flash');
var mongo = require('mongodb');
var mongoose = require('mongoose');
//create db connection using mongoose
var db = mongoose.connection;
var routes = require('./routes/index');
var users = require('./routes/users');
...
加入 Sessions \ Passport \ validator \ Messages Middleware
...
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// Handle Sessions
app.use(session({
secret:'secret',
saveUninitialized: true,
resave: true
}));
// Passport
app.use(passport.initialize());
app.use(passport.session());
// validator
app.use(expressValidator({
errorFormatter: function(param, msg, value) {
var namespace = param.split('.')
, root = namespace.shift()
, formParam = root;
while(namespace.length) {
formParam += '[' + namespace.shift() + ']';
}
return {
param : formParam,
msg : msg,
value : value
};
}
}));
// messages (express-messages / connect-flash)
app.use(require('connect-flash')());
app.use(function (req, res, next) {
res.locals.messages = require('express-messages')(req, res);
next();
});
app.use('/', routes);
app.use('/users', users);
...
setup 好之後,測試 server 是否可正常啟動
npm start
看到這頁面出現,而且 console 沒有 error 就算是有正常啟動了
註:
這裡要用 npm start,不能像前面的project一樣用 node app.js
因為啟動server的code是存在bin\www檔案中,而非app.js
打開 www 檔案,會看到最上方有 import app function (從app.js export而來)
http.createServer(app) function,就是用來啟動 server的指令,且套用 app 定義的 middleware
查看 package.json,可以看到 npm start 指令實際上會被轉換成 node ./bin/www
透過scripts 的設定,也可以設定快速指令來執行不同的 js
比如說撰寫了 www_test 檔案用來測試website功能,則修改package.json為
{
...
"scripts": {
"start": "node ./bin/www",
"test": "node ./bin/www_test"
},
"dependencies": {
...
之後輸入 npm run test 就可以執行 www_test
Views & Layout
使用 Jade 作出web介面
首先將bootstrap.css放到public\stylesheets資料夾
並清空style.css的內容
修改layout.jade,從前一個project的layout.jade copy過來修改
doctype html
html
head
title Welcome //修改網頁Title
//加入bootstrap
link(href='/stylesheets/bootstrap.css', rel='stylesheet')
//加入css
link(href='/stylesheets/style.css', rel='stylesheet')
body
//修改navbar樣式
nav.navbar.navbar-default(role='navigation')
.container
.navbar-header
//修改button樣式
button.navbar-toggle(type='button', data-toggle='collapse', data-target='.navbar-collapse')
span.sr-only Toggle navigation
span.icon-bar
span.icon-bar
span.icon-bar
//修改project name
a.navbar-brand(href='#') NodeAuth
//修改navbar樣式
.navbar-collapse.collapse
ul.nav.navbar-nav
//如果目前位置為此頁面,樣式設為active(選取中)
li(class=(title == 'Members' ? 'active' : ''))
//修改連結文字為Member
a(href='/') Members
li(class=(title == 'Register' ? 'active' : ''))
//修改連結文字為Register
a(href='/users/register') Register
li(class=(title == 'Login' ? 'active' : ''))
//修改連結文字為Login
a(href='/users/login') Login
//navbar右側加入logout區塊
ul.nav.navbar-nav.navbar-right
li
//加入logout連結
a(href='/users/logout') Logout
.container
block content
script(src='https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js')
script(src='javascripts/bootstrap.js')
修改index.jade,此頁面會作為user登入後的畫面,有點類似會員中心 (member area)
extends layout
block content
h1 Member Area
p Welcome to the members area
修改index.js,修改title為 Members
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Members' });
});
module.exports = router;
從layout看到有三個page,index(member) \ register \ login
接下來加入 routing,讓點選連結時可以正常切換到指定 page
修改 users.js
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
//加入register routing
router.get('/register', function(req, res, next) {
res.render('register', {title: 'Register'});
});
//加入login routing
router.get('/login', function(req, res, next) {
res.render('login', {title: 'Login'});
});
module.exports = router;
同 index.jade,register\login 也需要有自己的 jade view,因此 create register.jade 和 login.jade
首先從 register.jade 開始
extends layout
block content
h2.page-header Register
p Please register using the form below
//表單內容
form(method='post', action='/users/register', enctype='multipart/form-data')
//multipart/form-data: 表單中要upload file時使用
.form-group
label Name
input.form-control(name='name', type='text', placeholder='Enter name')
.form-group
label Email
input.form-control(name='email', type='email', placeholder='Enter email')
.form-group
label Username
input.form-control(name='username', type='text', placeholder='Enter username')
.form-group
label Password
input.form-control(name='password', type='password', placeholder='Enter password')
.form-group
label Confirm Password
input.form-control(name='password2', type='password', placeholder='Confirm password')
.form-group
label Profile Image
input.form-control(name='profileimage', type='file')
.form-group
input.btn.btn-primary(type='submit', name='submit', value='Register')
重啟 server,打開 http://localhost:3000/users/register,檢查 register 頁面的表單是否正常
沒問題的話,copy register.jade的內容到 login.jade,login頁面相對簡單,只需要留下username\password\submit button
因為不用upload file,所以 form option也不需要加入multipart/form-data
修改login.jade
extends layout
block content
h2.page-header Login
p Please login below
form(method='post', action='/users/login')
label Username
input.form-control(name='username', type='text', placeholder='Enter username')
.form-group
label Password
input.form-control(name='password', type='password', placeholder='Enter password')
.form-group
input.btn.btn-primary(type='submit', name='submit', value='Login')
重啟server,確認 login 頁面是否正常,也順便確認 navbar的連結是不是都有通
到此,View & Layout 設定完成
註冊表單 & 驗證
前面完成了register的GET
當使用者的瀏覽器發送HTTP GET request時,可以show出register頁面
當使用者填寫完資料需要submit時,就是發送HTTP POST request
因此現在要先加入 POST request
這邊會使用到Multer,通常在parse res.body時,會使用 body parser,不過 body parser 無法處理上傳檔案,因此改用multer取代
首先require multer
var multer = require('multer');
var upload = multer({dest: './uploads'});
修改user.js,在router.get後面加入 route.post
Parsing request.body 的參數後,檢查表單填寫內容是否為正確格式,並且檢查是否有上傳檔案,上傳的檔案會存在uploads路徑下
如果檢查欄位發現有錯誤訊息,會回傳errors message
...
router.get('/login', function(req, res, next) {
res.render('login', {title: 'Login'});
});
//POST request to register
router.post('/register', upload.single('profileimage'), function(req, res, next) {
//using multer
var name = req.body.name;
var email = req.body.email;
var username = req.body.username;
var password = req.body.password;
var password2 = req.body.password2;
//Form Validator
req.checkBody('name', 'Name field is required').notEmpty();
req.checkBody('email', 'Email field is required').notEmpty();
req.checkBody('email', 'Email is not valid').isEmail();
req.checkBody('username', 'Username field is required').notEmpty();
req.checkBody('password', 'Password field is required').notEmpty();
req.checkBody('password2', 'Passwords do not match').equals(req.body.password);
//console.log(req.file); //show uploaded image info.
if(req.file){
console.log('Uploading File...');
var profileimage = req.file.filename;
} else {
console.log('No File Uploaded...');
var profileimage = 'noimage.jpg'; //use default image
}
});
module.exports = router;
如果檢查有回傳任何error message,需要在網頁上顯示
因此修改 register.jade,show出error message
...
block content
h2.page-header Register
p Please register using the form below
//Show error message
if errors
each error, i in errors
div.alert.alert-danger #{error.msg}
form(method='post', action='/users/register', enctype='multipart/form-data')
...
測試看看,應填欄位為空就會出現error
Models & 使用者註冊
建立 Models 資料夾,透過mongoose 儲存 user 資料
在project root path下建立models資料夾
在models下新增user.js,撰寫User資料 schema,並export 新增user function
//using mongoose to connect mongodb
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/nodeauth');
var db = mongoose.connection;
//User Schema
var UserSchema = mongoose.Schema({
username: {
type: String,
index: true
},
password: {
type: String
},
email: {
type: String
},
name: {
type: String
},
profileimage: {
type: String
}
});
//export User schema
var User = module.exports = mongoose.model('User', UserSchema);
//export createUser function
module.exports.createUser = function(newUser, callback){
newUser.save(callback); //mongoose function to insert to DB
};
設定完 Models,接下來要在 register POST request 把資料存進DB
修改 routes\users.js,import models
var express = require('express');
var router = express.Router();
var multer = require('multer');
var upload = multer({dest: './uploads'});
//import Data Model
var User = require('../models/user');
......
修改 routes\users.js 的 Check errors 區塊
當沒有檢查到error時,呼叫createUser function insert user 資料,並切換到member area page
......
//Check Errors
var errors = req.validationErrors();
if(errors){
res.render('register', {
errors: errors
});
} else {
var newUser = new User({
name: name,
email: email,
username: username,
password: password,
profileimage: profileimage
});
User.createUser(newUser, function(err, user){
//track for error
if(err) throw err;
console.log(user);
});
res.location('/');
res.redirect('/');
}
登入成功時,通常會出現”登入成功”的message,這邊會使用connect-flash show message
繼續修改routes\user.js
...
User.createUser(newUser, function(err, user){
//track for error
if(err) throw err;
console.log(user);
});
//Show success message with flash
req.flash('success', 'You are now registered and can login');
res.location('/');
res.redirect('/');
...
修改user.jade,在block content上方顯示message
......
ul.nav.navbar-nav.navbar-right
li
a(href='/users/logout') Logout
.container
!= messages()
block content
script(src='https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js')
script(src='javascripts/bootstrap.js')
還需要替message設定css樣式,修改 stylesheets\style.css,success & error 只有顏色屬性不同
ul.success li {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
list-style: none;
}
ul.error li {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
list-style: none;
}
重啟 Server,在 Register 輸入資料後 submit,應該會切換到 Member Area,並出現 success message
至此,基本的註冊功能就完成了
使用 BCrypt.js 進行密碼雜湊計算
首先安裝 bcryptjs
npm install bcryptjs --save
在 model\user.js & app.js 兩個檔案中 require bcrypt module
var bcrypt = require('bcryptjs');
copy bcryptjs 的 Usage-Async example
在model\user.js的 createUser function 中貼上,將bcrypt.hash()中的字串改成我們要儲存的 newUser.password
加密後的password會透過bcrypt.hash()的callback回傳,將這個值assign給newUser.password
再用 save() 儲存到 mongodb
...
var User = module.exports = mongoose.model('User', UserSchema);
module.exports.createUser = function(newUser, callback){
bcrypt.genSalt(10, function(err, salt) {
bcrypt.hash(newUser.password, salt, function(err, hash) {
// Store hash in your password DB.
newUser.password = hash;
newUser.save(callback);
});
});
};
重啟 server,註冊一筆資料,可以從 console 或是 mongodb 看到剛剛註冊的密碼已經加密了
使用 Passport 進行登入驗證
這邊會使用到 Passport Module 的 Authenticate 功能
修改routes\users.js,import passport 和 passport-local strategy (從app.js copy)
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
加入login的HTTP POST request
//POST request to login
router.post('/login',
passport.authenticate('local', {failureRedirect:'/users/login', failureFlash: 'Invalid username or password'}),
function(req, res) {
req.flash('success', 'You are now logged in');
res.redirect('/');
});
加入 passport 驗證 (從passport document – authenticate copy),修改成下面這樣
其中 getUserByUsername 和 comparePassword 分別用來檢查username和password是否正確,稍後會在model\user.js定義
passport.use(new LocalStrategy(function(username, password, done){
//compare username
User.getUserByUsername(username, function(err, user){
if(err) throw err;
if(!user){
return done(null, false, {message: 'Unknown User'});
}
//compare password
User.comparePassword(password, user.password, function(err, isMatch){
if(err) throw err;
if(isMatch){
return done(null, user);
} else {
return done(null, false, {message: 'Invalid Password'});
}
});
});
}));
繼續加入 session (一樣從passport document copy,keyword: serializeUser),將 findById() 改成 getUserById(),稍後也會定義
passport.serializeUser(function(user, done) {
done(null, user.id);
});
passport.deserializeUser(function(id, done) {
User.getUserById(id, function(err, user) {
done(err, user);
});
});
註:
passport serializeUser 和 deserializeUser
會先對 user 做 serialize,確認 user 驗證通過後,passport 會儲存找到的 user.id
接下來需要用到此 User Object時,就會將 user.id 拋給 deserializeUser 去 query User Object
細節可以再參考以下說明
Passport Document – Configure -> Sessions
StackOverFlow
修改 login.jade,加上 error message check
extends layout
block content
h2.page-header Login
p Please login below
if errors
each error, i in errors
div.alert.alert-danger #{error.msg}
form(method='post', action='/users/login')
.form-group
label Username
input.form-control(name='username', type='text', placeholder='Enter username')
.form-group
label Password
input.form-control(name='password', type='password', placeholder='Enter password')
input.btn.btn-primary(type='submit', name='submit', value='Login')
修改 models\user.js,新增 getUserById, getUserByUsername, comparePassword 等方法,並export
getUserByUsername 會使用 username 去 mongo query,並回傳query到的 User object
comparePassword 用來比對輸入的password和 db query 到的 User.password 是否一致
......
var User = module.exports = mongoose.model('User', UserSchema);
module.exports.getUserById = function(id, callback){
User.findById(id, callback);
}
module.exports.getUserByUsername = function(username, callback){
var query = {username: username};
User.findOne(query, callback);
}
module.exports.comparePassword = function(candidatePassword, hash, callback){
// Load hash from your password DB.
bcrypt.compare(candidatePassword, hash, function(err, isMatch) {
callback(null, isMatch);
});
}
module.exports.createUser = function(newUser, callback){
......
重啟 Server,在 login 輸入錯誤的密碼,會出現 error message
輸入正確密碼,則會 redirect 到 Member Area,並出現登入成功的訊息
登出 & 存取控制
前面已經實作完註冊、登入、驗證功能
目前無法登出,也可以不登入就直接存取 Member Area,現在來把這塊完成
修改 routes\users.js 加入 logout routing
router.get('/logout', function(req, res){
req.logout();
 req.flash('success', 'You are now logged out');
res.redirect('/users/login');
});
修改 index.js,加入members 頁面的存取控制 function ensureAuthenticated
在進入 member area 時會檢查是否已登入,如果沒登入,則切換到 login 頁面
...
function ensureAuthenticated(req, res, next){
if(req.isAuthenticated()){
return next();
}
res.redirect('/users/login');
}
module.exports = router;
修改 routing.get,將 ensureAuthenticated function 加入 function 參數
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', ensureAuthenticated, function(req, res, next) {
res.render('index', { title: 'Members' });
});
...
重啟 server,確認點選 members 會自動切換到 login
在 login 成功登入後,才可以切到 member area
目前 login 後,還看得到 register 和 login 選單,而 logout 後,還看得到 logout 選單
因此要新增一個 global 變數來儲存登入狀態,讓選單可以正確顯示
修改 app.js,app.get(‘*’) 代表所有頁面的 get
......
app.use(function (req, res, next) {
res.locals.messages = require('express-messages')(req, res);
next();
});
//set global variable for login/logout status
app.get('*', function(req, res, next){
res.locals.user = req.user || null;
next();
});
app.use('/', routes);
app.use('/users', users);
......
修改 layout.jade,在 li 各連結前加入 if 判斷
如果現在沒有 user global 變數,代表沒有登入,則顯示 register 和 login 選單
相反地,如果現在有 user global 變數,代表有登入,則顯示 member 和 logout 選單
......
ul.nav.navbar-nav
if user
li(class=(title == 'Members' ? 'active' : ''))
a(href='/') Members
if !user
li(class=(title == 'Register' ? 'active' : ''))
a(href='/users/register') Register
li(class=(title == 'Login' ? 'active' : ''))
a(href='/users/login') Login
ul.nav.navbar-nav.navbar-right
if user
li
a(href='/users/logout') Logout
.container
......
重啟 server,測試 login 前後,選單是否正常顯示
到此,一個粗略的註冊、登入、登出驗證系統就結束囉
目前想到有一些還可以加強的地方,有時間再來實作看看
- 加上檢查 username 不能重覆的機制
- Flash Message show 出 username 或是 Name