Node.js 實作登入及驗證
接續 使用 Node.js 與 RESTful API 架構來串接 mongoDB 並部署到 Heroku 的內容來修改並加入登入及驗證的功能。 
這裡會專注在登入驗證的部分,使用 Passport.js 來處理。程式會分成:驗證(verification)、路由(routes)、邏輯控制(controllers)來處理,方便管理及修改。
Passport.js
Passport 可以想成是一個處理驗證的 middleware,提供超過上百組驗證策略(Strategy)。可以依照自己的需求,使用帳號和密碼或第三方認證系統,如 Facebook、Google…等等。在這我們使用本地驗證 LocalStrategy(帳號和密碼)。
使用上大致分成幾個部分:
安裝載入模組
npm install passport passport-local --save
app.js
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 
 | // 引用 expressconst express = require('express')
 const app = express()
 // 身份認證
 const passport = require('passport')
 const session = require('express-session')
 app.use(session({
 secret: 'secretok',
 // 強制將未初始化的session存回 session store
 saveUninitialized: false,
 // 強制將session存回 session store,
 resave: false,
 }))
 // passport.user 初始化
 app.use(passport.initialize())
 // 處理 Session,呼叫 deserializeUser()。若有找到 req.user,則判定其通過驗證。
 app.use(passport.session())
 
 | 
ps. session() 需設定在 passport.session() 之前,以確保 session 能正確地被處理
建立驗證策略及序列化與反序列化的函式
在 web 應用中,驗證訊息(credentials),只在登入時被傳送,驗證成功後,會記錄在 session 並儲存 cookies 在瀏覽器內。之後的 request 都不會再帶驗證訊息,而是透過 cookie 來辨認 session,為了支援 login sessions,在 Passport 中會序列化(serialize)和反序列化(deserialize)在 session 中的使用者實例。
ps.序列化(serialize)簡單說就是把 物件轉換成可被儲存在儲存空間的資料的這個過程,例如把 JavaScript 中的物件透過 JSON.stringify() 變成字串儲存。而反序列化則是倒過來把資料轉換成程式碼中的物件,例如把 JSON 字串透過 JSON.parse() 轉換成物件。
建立驗證策略機制
- 引用 passport 及 策略
- passport.use() 建立策略
| 12
 3
 4
 5
 6
 
 | const passport = require('passport')const LocalStrategy = require('passport-local').Strategy
 
 passport.use(new LocalStrategy(
 // 驗證 code .....
 ))
 
 | 
- 設定完 Strategy 後,要加上 Verify Callback
- Verify Callback 帶入 username, password 參數,進行驗證
- 完成驗證後,呼叫 done,帶入驗證後結果
| 12
 3
 4
 5
 6
 
 | passport.use(new LocalStrategy(// Verify Callback 驗證時,呼叫此函式
 function(username, password, done) {
 // 驗證 code...
 }
 ))
 
 | 
- 使用 LocalStrategy 時,預設從 req.body 中的 username 和 password 的欄位取得資料。若要使用其他欄位,並使用 req 物件時,例如:使用 req.flash() 設定時,可加上 options(非必要)。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 
 | passport.use(new LocalStrategy({
 // 改用 email 的欄位作為帳號
 usernameField: 'email',
 // 改用 userpassword 的欄位作為密碼
 passwordField: 'userpassword'
 // 讓 varify callback 函式可以取得 req 物件
 passReqToCallback: true
 },
 // 新增 req 引數
 function(req, email, userpassword, done) {
 // 驗證 code...
 }
 ))
 
 | 
- done 是 callback function,驗證完成後,呼叫 done 並將驗證結果作為參數傳入,提供給 Passport 使用。  
- done 接收三種參數:
 1.錯誤訊息:伺服器端回傳錯誤訊息時,帶入錯誤訊息 err;無錯誤訊息時,則可以帶入 null 取代
 2.使用者資料:驗證成功時,帶入使用者資料 user;驗證失敗時,則可以帶入 false 取代
 3.驗證失敗訊息:當驗證失敗時,可以額外補充驗證失敗的原因和資訊
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | // 驗證成功時,提供 Passport 該名使用者資料return done(null, user)
 
 // 驗證失敗時,不提供 Passport 任何使用者資料
 return done(null, false)
 
 // 驗證失敗時,不提供 Passport 任何使用者資料,但告知失敗原因
 return done(null, false, { message: 'Incorrect password.' })
 
 // 當伺服器端產生錯誤訊息時,提供 Passport 錯誤訊息內容
 return done(err)
 
 | 
e.g. 完整驗證策略
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 
 | const passport = require('passport')const LocalStrategy = require('passport-local').Strategy
 
 // 透過 passport.use() 建立驗證機制
 passport.use(new LocalStrategy({
 // 改以名為 email 的欄位資訊作為帳號
 // usernameField: 'email',
 // 改以名為 passwd 的欄位資訊作為密碼
 // passwordField: 'passwd',
 // 讓 varify callback 函式可以取得 req 物件
 passReqToCallback: true
 },
 // 當請 passport 要驗證時,呼叫此 callback 函式,並帶入驗證資訊驗證
 // Varify Callback: 新增 req 引數
 function (req, username, password, done) {
 // console.log(username, password)
 // 從 MongoDB 查詢使用者
 db.collection('users').findOne({ username: username }, function (err, user) {
 // console.log(user)
 // 如果伺服器端回傳錯誤訊息,提供 passport 錯誤訊息
 if (err) { return done(err) }
 // 如果沒有在資料庫裡找到該位使用者,不提供 passport 任何使用者資訊,告知失敗原因,並將錯誤資訊放內 flash() 內
 if (!user) { return done(null, false, req.flash('info', 'User not found.')) }
 // 驗證密碼
 bcrypt.compare(password, user.password, (err, isMatch) => {
 // password correct
 if (isMatch) { return done(null, user) }
 // 如果從資料庫找到了該名使用者,但密碼錯誤時,不提供 passport 任何使用者資訊,告知失敗原因
 return done(null, false, req.flash('info', 'Invalid password'))
 })
 })
 }
 ))
 
 | 
序列化與反序列化
passport.serializeUser()
- 從 strategy 得到參數值 user,可設定將哪些 user 資訊,儲存在 Session 中的 req.session.passport.user
| 12
 3
 4
 
 | passport.serializeUser(function(user, done) {// 只將用戶 id 序列化存到 session 中
 done(null, user.id)
 })
 
 | 
passport.deserializeUser()
- 從 session 中取得 user (req.session.passport.user) 值,透過該資訊至資料庫找到完整的用戶資料,儲存在 req.user
| 12
 3
 4
 5
 6
 
 | passport.deserializeUser(function(id, done) {//  為了讓 session 小一點,透過使用者 id 到資料庫尋找用戶完整資訊
 User.findById(id, function(err, user) {
 done(err, user)
 })
 })
 
 | 
將驗證策略及序列化與反序列化的函式整合後,再引入 app.js 使用  
./verification/passport.js
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 
 | // 用帳密來做驗證const LocalStrategy = require('passport-local').Strategy
 const FacebookStrategy = require('passport-facebook').Strategy
 const GoogleStrategy = require('passport-google-oauth20').Strategy
 const mongoose = require('mongoose')
 const db = require('../connections/mongoDB_connect')
 // 加解密
 const bcrypt = require('bcryptjs')
 //
 const ObjectID = require('mongodb').ObjectID
 
 module.exports = passport => {
 // local strategy
 // 透過 passport.use() 建立驗證機制
 passport.use(new LocalStrategy({
 // 改以名為 email 的欄位資訊作為帳號
 // usernameField: 'email',
 // 改以名為 passwd 的欄位資訊作為密碼
 // passwordField: 'passwd',
 // 讓 varify callback 函式可以取得 req 物件
 passReqToCallback: true
 },
 // 當請 passport 要驗證時,呼叫此 callback 函式,並帶入驗證資訊驗證
 // Varify Callback: 新增 req 引數
 function (req, username, password, done) {
 // console.log(username, password)
 // 從 MongoDB 查詢使用者
 db.collection('users').findOne({ username: username }, function (err, user) {
 // console.log(user)
 // 如果伺服器端回傳錯誤訊息,提供 passport 錯誤訊息
 if (err) { return done(err) }
 // 如果沒有在資料庫裡找到該位使用者,不提供 passport 任何使用者資訊,告知失敗原因,並將錯誤資訊放內 flash() 內
 if (!user) { return done(null, false, req.flash('info', 'User not found.')) }
 // 驗證密碼
 bcrypt.compare(password, user.password, (err, isMatch) => {
 // password correct
 if (isMatch) { return done(null, user) }
 // 如果從資料庫找到了該名使用者,但密碼錯誤時,不提供 passport 任何使用者資訊,告知失敗原因
 return done(null, false, req.flash('info', 'Invalid password'))
 })
 })
 }
 ))
 
 // 從 strategy 得到輸入值 user,可設定將哪些 user 資訊,儲存在 Session 中的 req.session.passport.user
 passport.serializeUser(function (user, done) {
 // 只將用戶 id 序列化存到 session 中,mongoDB 的 id 欄位為 _id
 done(null, user)
 })
 
 // 輸入 user (req.session.passport.user) 值,從 session 中取得該資料,儲存在 req.user
 passport.deserializeUser(function (user, done) {
 // 為了讓session小一點,透過使用者 id 到 MongoDB 資料庫尋找用戶完整資訊
 // const objid = { _id: ObjectID(id) }
 // db.collection('users').findById(id, function (err, user) {
 //     done(err, user)
 //     // console.log(req.user)
 // })
 done(null, user)
 })
 }
 
 | 
app.js
| 1
 | require('./verification/passport')(passport)
 | 
建立驗證路由
加上 passport.authenticate() middleware 驗證使用者是否認證。
- 路由基本使用
- 加上使用的策略
- 及成功或失敗,各自導向的頁面
| 12
 3
 4
 
 | app.post('/login', passport.authenticate('local', {successRedirect: '/',
 failureRedirect: '/users/login'
 }))
 
 | 
- 改寫成驗證成功後,先執行 function()。再導向頁面
| 12
 3
 4
 5
 6
 7
 8
 
 | app.post('/login', passport.authenticate('local', { failureRedirect: '/login' }),
 // 驗證成功時,呼叫函式,且 req.user 帶有該使用者資訊
 function(req, res) {
 // 想執行的動作
 res.redirect('/')
 }
 )
 
 | 
- 如果內建的驗證請求不符合使用,可以客制化 callback 來處理
- passport.authenticate('<strategyName>', callback<err, user, info>)
- 這範例,是在 Express 中的路由中去執行,而不是當 middleware 使用。因此這是使用 closure 來在 callback 中取得 req 和 res。如果認證失敗,user 會被設成 false;如果例外發生;err 會被設定;info 則可以拿到 strategy 中 verify callback 所提供的更多訊息
- 要注意的地方是,使用客制化的 callback 時,需要透過 req.logIn(‘user’, callback) 來建立 session,並且回傳 response。若使用者登入成功,user 會被指定到 req.user。req.logout() 則會移除 req.user 這個屬性,並同時清除 login session(若有的話)
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | app.post('/login', {passport.authenticate('local', function (err, user, info) {
 if (err) { return next(err); }
 if (!user) {
 return res.status(200).send({
 success: false,
 message: "帳密錯誤"
 })
 }
 req.logIn(user, function (err) {
 if (err) { return next(err); }
 return res.status(200).send({
 success: true,
 message: "登入成功",
 uid: '',
 })
 // res.redirect('/')
 });
 })(req, res, next);
 })
 
 | 
這裡是將 passport.authenticate() 寫在邏輯控制,再給路由引用。
./controllers/user.js
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 
 | // 引用 expressconst express = require('express')
 const router = express()
 // 登入驗證
 const passport = require('passport');
 // 連接 mongoDB
 const db = require('../connections/mongoDB_connect')
 // 加解密
 const bcrypt = require('bcryptjs')
 // 查詢條件是 id 時,要轉換成 objectID
 const ObjectID = require('mongodb').ObjectID
 
 module.exports = {
 getLogin: (req, res) => {
 res.render('login', {
 error: req.flash('info')
 });
 },
 postLogin: function (req, res, next) {
 passport.authenticate('local', function (err, user, info) {
 if (err) { return next(err); }
 if (!user) {
 return res.status(200).send({
 success: false,
 message: "帳密錯誤"
 })
 }
 req.logIn(user, function (err) {
 if (err) { return next(err); }
 return res.status(200).send({
 success: true,
 message: "登入成功",
 uid: '',
 })
 // res.redirect('/')
 });
 })(req, res, next);
 },
 getLogout: function (req, res) {
 req.logOut()
 req.session.destroy(() => {
 res.clearCookie('connect.sid')
 res.status(200).send({
 success: true,
 message: '已登出'
 })
 // res.redirect('/')
 })
 }
 ...
 }
 
 | 
./routes/user.js
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | const express = require('express');const router = express.Router();
 // Include user controller method 解構賦值來使用
 const { getLogin, postLogin, getSignup, postSignup, getUserInfo, getLogout } = require('../controllers/user')
 // 驗證是否已登入,需驗證的頁面使用
 const { authenticated } = require('../verification/auth')
 
 router.get('/login', getLogin)
 
 router.post('/login', postLogin)
 
 router.get('/signup', getSignup)
 
 router.post('/signup', postSignup)
 
 router.get('/userinfo', authenticated, getUserInfo)
 
 router.get('/logout', getLogout)
 
 module.exports = router;
 
 | 
建立 req.isAuthenticated()
在 req 物件上可以使用的 passport method,可以拿來做 middleware,來確認使用者是否通過驗證的狀態,獲得瀏覽該頁面的權限。
./verification/auth.js
| 12
 3
 4
 5
 6
 7
 
 | module.exports = {authenticated: (req, res, next) => {
 if (req.isAuthenticated()) { return next() }
 req.flash('warning_msg', '請先登入才能此用')
 res.redirect('/user/login')
 }
 }
 
 | 
使用:
./routes/user.js
| 12
 3
 4
 5
 
 | // 驗證是否已登入,需驗證的頁面使用const { authenticated } = require('../verification/auth')
 
 //前往 /userinfo 時,驗證身份後才執行 getUserInfo() 渲染頁面
 router.get('/userinfo', authenticated, getUserInfo)
 
 | 
驗證完成後續的請求過程
- Session 機制會在一個用戶完成身分認證後,存下所需的用戶資料,接著產生一組對應的對應的 id,存入 cookie 後傳回用戶端。
- 客戶端產生請求,透過 cookie 上的 session id 至 session 中取得被序列化的用戶資訊,存放到 req.session.passport.user。
- passport.initialize() 被觸發:確認 passport.user 上有被序列化的使用者物件,若物件不存在,則創建一個空物件。
- passport.session() 被觸發:若有找到被序列化的使用者物件,則判定此請求的用戶是已經通過驗證的狀態。
- passport.session() 呼叫 deserializeUser() — 透過用戶 資料前往資料庫找到使用者完整資料,放置在 req.user 上供往後使用。
 
參考:
https://ithelp.ithome.com.tw/articles/10228464?sc=rss.iron
https://pjchender.github.io/2017/09/26/node-passport-學習筆記(learn-to-use-passport-js)/
https://medium.com/麥克的半路出家筆記/筆記-透過-passport-js-實作驗證機制-11cf478f421e
https://andyyou.github.io/2017/04/11/express-passport/
http://sj82516-blog.logdown.com/posts/1249667/nodejs-passportjs-cors-in-the-certification-process-and-development
https://segmentfault.com/a/1190000005783325
https://segmentfault.com/a/1190000005783306
https://dotblogs.com.tw/daniel/2017/04/08/110915