avatar

目錄
Node.js 實作登入及驗證

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

Code
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() 建立策略
Code
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,帶入驗證後結果
Code
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(非必要)。
Code
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.驗證失敗訊息:當驗證失敗時,可以額外補充驗證失敗的原因和資訊
Code
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. 完整驗證策略

Code
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
Code
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
Code
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

Code
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

Code
1
require('./verification/passport')(passport)

建立驗證路由

加上 passport.authenticate() middleware 驗證使用者是否認證。

  • 路由基本使用
  • 加上使用的策略
  • 及成功或失敗,各自導向的頁面
Code
1
2
3
4
app.post('/login', passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/users/login'
}))
  • 改寫成驗證成功後,先執行 function()。再導向頁面
Code
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(若有的話)
Code
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

Code
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

Code
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

Code
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

Code
1
2
3
4
5
// 驗證是否已登入,需驗證的頁面使用
const { authenticated } = require('../verification/auth')

//前往 /userinfo 時,驗證身份後才執行 getUserInfo() 渲染頁面
router.get('/userinfo', authenticated, getUserInfo)

驗證完成後續的請求過程

  1. Session 機制會在一個用戶完成身分認證後,存下所需的用戶資料,接著產生一組對應的對應的 id,存入 cookie 後傳回用戶端。
  2. 客戶端產生請求,透過 cookie 上的 session id 至 session 中取得被序列化的用戶資訊,存放到 req.session.passport.user。
  3. passport.initialize() 被觸發:確認 passport.user 上有被序列化的使用者物件,若物件不存在,則創建一個空物件。
  4. passport.session() 被觸發:若有找到被序列化的使用者物件,則判定此請求的用戶是已經通過驗證的狀態。
  5. 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