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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| // 引用 express const 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() 建立策略
1 2 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,帶入驗證後結果
1 2 3 4 5 6
| passport.use(new LocalStrategy( // Verify Callback 驗證時,呼叫此函式 function(username, password, done) { // 驗證 code... } ))
|
- 使用 LocalStrategy 時,預設從 req.body 中的 username 和 password 的欄位取得資料。若要使用其他欄位,並使用 req 物件時,例如:使用 req.flash() 設定時,可加上 options(非必要)。
1 2 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.驗證失敗訊息:當驗證失敗時,可以額外補充驗證失敗的原因和資訊
1 2 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. 完整驗證策略
1 2 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
1 2 3 4
| passport.serializeUser(function(user, done) { // 只將用戶 id 序列化存到 session 中 done(null, user.id) })
|
passport.deserializeUser()
- 從 session 中取得 user (req.session.passport.user) 值,透過該資訊至資料庫找到完整的用戶資料,儲存在 req.user
1 2 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
1 2 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 驗證使用者是否認證。
- 路由基本使用
- 加上使用的策略
- 及成功或失敗,各自導向的頁面
1 2 3 4
| app.post('/login', passport.authenticate('local', { successRedirect: '/', failureRedirect: '/users/login' }))
|
- 改寫成驗證成功後,先執行 function()。再導向頁面
1 2 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(若有的話)
1 2 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
1 2 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
| // 引用 express const 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
1 2 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
1 2 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
1 2 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