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