สอนทำเว็บไซต์ด้วย Node.js, Express และ MongoDB ตอนที่ 9 - ทำระบบ Login ด้วย Passport.js

Chai Phonbopit

Software Engineer & Blogger

05 March 2020

In

สวัสดีครับ มาต่อกันที่ตอนที่ 9 กันนะครับ สำหรับตอนนี้เราจะมาเริ่มทำส่วนการ Login ด้วยการใช้ Middleware และการใช้ตัวข่วยอย่าง Passport.js กันนะครับ

โดยตัวอย่างนี้ จะเป็นกึ่งๆ Workshop นิดๆ จากเนื้อหา ตอนที่ 1-8 มารวมเป็นบทความนี้ครับ (อาจจะไปเร็วนึงนึง เพราะ assume ว่าผู้อ่าน ได้อ่าน และทำความเข้าใจเนื้อหาตอนก่อนๆมาแล้วนะครับ)

  • ใช้ Express Generator ในสร้างโปรเจ็ค
  • ใช้ Pug ในการทำ Template มีหน้า Login / Register และหน้า Home เพื่อแสดงข้อมูลถ้า Login เรียบร้อยแล้ว
  • เก็บข้อมูล User ไว้ใน MongoDB
  • ใช้งาน Passport.js (Middleware ที่ช่วยในการทำ Authentication)
  • มีการเก็บข้อมูล Session (จะได้เรียนรู้ในบทความนี้)

โดยในตัวอย่างนี้ จะเป็น Web Application แบบทั่วๆไปนะครับ คือเป็น Server Side Rendering ไม่ใช่เป็น Single Page Application นะครับ

ตัวอย่างเว็บ หลังจากทำเว็บ จะได้หน้าตาประมาณนี้นะครับ http://example-web.now.sh

ส่วนเรื่อง Single Page Application (SPA) และการทำ RESTful API สำหรับส่วน Backend เดี๋ยวจะพูดถึงในบทถัดๆไปครับ

เนื้อหาบทเรียน


Step 1 - Create Project

เริ่มต้นเราไม่รอช้า ทำการสร้างโปรเจ็คใหม่ด้วย Express Generator เลยครับ

npx express-generator --view=pug ahoy-node-passport

เราก็จะได้โปรเจ็ค Express ขึ้นมา โดยมี Pug เป็น Template ครับ (สำหรับตัวอย่างนี้ผมจะพยายามข้ามเรื่อง Style ของเว็บนะครับ ไม่ได้เน้นความสวยงาม เน้น Functionality ที่มันทำงานได้) โดยตัว UI ผมใช้เป็น Bootstrap ธรรมดาเลยนะครับ

ต่อมาที่ไฟล์ views/layout.pug ผมเพิ่ม Bootstrap css ลงไปครับ

doctype html
html
head
meta(name='viewport', content='width=device-width, initial-scale=1')
title= title
link(rel='stylesheet', href='https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css', integrity='sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm', crossorigin='anonymous')
link(rel='stylesheet', href='/stylesheets/style.css')
body
block content

ทำการสั่ง Start server

npm start

จะได้ Server รันบน Localhost ครับ http://localhost:3000

Step 2 - สร้างหน้า Login / Register

ต่อมาเราจะทำหน้า Form สำหรับ Login และ Register ครับ เป็นแบบง่ายๆ เลยคือ HTML มี input สำหรับใส่ username และ password ครับ โดย Register จะมี Optional ให้ใส่ name ตอนลงทะเบียนเพิ่มไปด้วย

สร้างไฟล์ views/register.pug ขึ้นมา

extend layout
block content
.form-container
form(class="form-signin" method="POST" action="/auth/register")
h2 Please Register
.form-group
label( for="name" class="sr-only") Name (Optional)
input(type="text" name="name" id="name" class="form-control" placeholder="Name")
.form-group
label( for="username" class="sr-only") Username
input(type="text" name="username" id="username" class="form-control" placeholder="Username" required)
.form-group
label(for="password" class="sr-only") Password
input(type="password" name="password" id="password" class="form-control" placeholder="Password" required)
button(class="btn btn-primary btn-block" type="submit") Register
a(href="/login", class="btn btn-link") Have an account?

โดยเมื่อ Submit form มันจะไปเรียก POST /auth/register นะครับ

ต่อมาสร้าง views/login.pug เหมือนกัน คล้ายๆกับ Register เลย เพียงแค่ลบ input name ออก และเปลี่ยน method เป็น POST /auth/login ครับ

extend layout
block content
.form-container
form(class="form-signin" method="POST" action="/auth/login")
h2 Please Login
.form-group
label( for="username" class="sr-only") Username
input(type="text" name="username" id="username" class="form-control" placeholder="Username" required)
.form-group
label(for="password" class="sr-only") Password
input(type="password" name="password" id="password" class="form-control" placeholder="Password" required)
button(class="btn btn-primary btn-block" type="submit") Login
a(href="/register", class="btn btn-link") Don't have an account?

ต่อมาใส่ CSS นิดนึง ตรงไฟล์ public/stylesheets/style.css

.form-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.form-signin {
width: 100%;
max-width: 420px;
padding: 2rem;
margin: auto;
background-color: #f3f3f3;
}

ทีนี้เราจะเพิ่ม Router ของ Express กันครับ ตัว Generator มันสร้างไว้ให้แล้ว ทีนี้ผมก็จะทำจากไฟล์เดิมเลยคือ routes/index.js

const express = require('express')
const router = express.Router()
router.get('/', function (req, res, next) {
res.render('index', { title: 'Express' })
})
router.get('/register', (req, res) => {
res.render('register')
})
router.get('/login', (req, res) => {
res.render('login')
})
module.exports = router

ไฟล์ index นี้ คือ เพิ่ม /register และ /login ให้มัน render ไฟล์ pug template ที่เราเพิ่งสร้างด้านบนครับ

ทีนี้ลองเข้าเว็บ http://localhost:3000/register และ http://localhost:3000/login เราก็จะได้หน้า Form สำหรับ Login และ Register แล้วครับ

Step 3 - รับข้อมูลจาก Form

ต่อมาเราจะทำการรับข้อมูลจาก Form ที่กรอกเข้ามาจากหน้า Login และ Register นะครับ โดยใช้ routes ที่ตัว Express Generator นั้น gen มาให้แล้ว คือ routes/users.js แต่ผมจะเปลี่ยนชื่อใหม่เป็น routes/auth.js ละกันครับ

const express = require('express')
const router = express.Router()
router.post('/register', (req, res) => {
console.log(req.body)
res.redirect('/')
})
router.post('/login', (req, res) => {
console.log(req.body)
res.redirect('/')
})
module.exports = router

โดย กำหนดเป็นแบบ .post() ครับ เพื่อรับค่าจาก Form ทีนี้ เราลอง console.log ดูค่า req.body ดูครับ

แก้ไข app.js นิดหน่อย เนื่องจากผมเปลี่ยนชื่อไฟล์ บรรทัด 6-7

var indexRouter = require('./routes/index')
var authRouter = require('./routes/auth')

และบรรทัด 22 23

app.use('/', indexRouter)
app.use('/auth', authRouter)

ทีนี้เวลาเรา Submit จะตอน Login หรือ Register ค่าก็จะถูกส่งมา เข้า route router.post('/register') เราก็จะเห็นค่า req.body ครับ ทีนี้ส่ิงที่เราจะต้องทำคือ save ค่าที่ user ส่งมา เนี่ย ลง database

จริงๆแล้ว การใช้งาน Production เราต้องมีการ validate ค่าต่างๆ ด้วยนะครับ เช่น express-validation, joi, yup เป็นต้น

Step 4 - เชื่อมต่อ MongoDB

ต่อมาเราจะทำการเชื่อมต่อ MongoDB โดยใช้ Mongoose ครับ เพื่อเก็บข้อมูล username / password ในระบบ นั่นเอง

ติดตั้ง Mongoose

npm install mongoose

ต่อมาสร้างไฟล์ db.js เพื่อไว้ connect mongodb

const mongoose = require('mongoose')
mongoose.connect('mongodb://localhost:27017/ahoy-node-passport', {
useNewUrlParser: true,
useUnifiedTopology: true
})

ชื่อ Database แล้วแต่เพื่อนๆ จะตั้งเลยนะครับ ใครจะเป็นอะไรก็ได้ ไม่จำเป็นต้อง ahoy-node-passport เหมือนในบทความ

ต่อมาเพิ่มตรงนี้ลงไปในไฟล์ app.js เพื่อ import ไฟล์ที่เรา connect mongodb ไว้

require('./db')

ต่อมา สร้าง User Model ขึ้นมาที่ไฟล์ models/User.js เพื่อเอาไว้ใช้ตอน insert หรือ query

const mongoose = require('mongoose')
const Schema = mongoose.Schema
const userSchema = new Schema({
name: String,
username: {
type: String,
unique: true
},
password: String
})
const UserModel = mongoose.model('User', userSchema)
module.exports = UserModel

ทีนี้กลับไปที่ routes/auth.js ตอน login และ register เราก็รับค่า req.body มา แล้ว save ลง database เลยครับ

const express = require('express')
const router = express.Router()
const User = require('../models/User')
router.post('/register', async (req, res) => {
const user = new User(req.body)
await user.save()
res.render('index', { user })
})
router.post('/login', async (req, res) => {
const { username, password } = req.body
const user = await User.findOne({
username,
password
})
if (user) {
return res.render('index', { user })
} else {
return res.render('login', { message: 'Email or Password incorrect' })
}
})
module.exports = router

โดยสำหรับ Register เราก็จะรับ req.body มาเซฟลง Database และ Login เราก็จะรับ input มาเป้น criteria ในการ findOne() หา User ในระบบ ถ้าเจอ และ username และ password ถูก ก็แสดงว่า user login เข้าสู่ระบบได้สำเร็จ

แต่ๆ ระบบเรายังมีช่องโหว่ และมีสิ่งที่ไม่ควรทำอย่างยิ่ง!! แม้ว่าจะเป็นแค่ Development หรือทำระบบเล็กๆ หรือทำอะไรขึ้นมาเล่นๆ นั่นก็คือ Password ไม่ควรเก็บเป็น Plain Text

Step 4 - Hash Password ซะ

ข้อนี้เป็นสิ่งที่จำเป็นที่สุด !!! เน้นย้ำเลยครับ Password ที่เราเก็บในระบบ ห้ามเก็บแบบ Plain Text เด็ดขาด เราจะไม่สามารถรู้ได้เลยว่า Password ที่เก็บคืออะไร ไม่สามารถเปลี่ยน Password ให้คนอื่นได้ ทำได้อย่างเดียว คือการ Reset Password ครับ

และถ้าเราไปใช้ระบบ หรือบริการไหน ที่เราทำการขอ Forgot Password แล้วระบบส่ง Password เรากลับมาในอีเมล์ นั้นแสดงว่าระบบหรือบริการนั้นเก็บ Password เราแบบ Plain text สามารถเห็น Password เราและของคนอื่นๆในระบบได้หมดเลย เรียกได้ว่าไม่ปลอดภัยมากๆ และควรเปลี่ยน Password โดยด่วน (ไม่ว่าจะเป็น Developers, Admin หรือใครก็ตาม ก็ไม่มีสิทธิ์ หรือไม่ควรจะรู้ Password คนอื่นได้ครับ)

ส่วนการ Hash นั้น คือการเอา Password ปกติมาเข้ารหัสทงเดียว โดยได้ผลลัพธ์ที่เราไม่สามารถรู้ได้ว่า Password เราคืออะไร ส่วนความปลอดภัย ขึ้นอยู่กับ Algorithm และจำนวนการ hash ครับ

เราก็ไม่สามารถรู้ได้ว่า Password คืออะไร วิธีการที่ใช้ในการเช็คว่า Password ถูกต้องมั้ย คือการเปรียบเทียบ สิ่งที่ Input มากับค่า Password ที่มันถูก hash แล้ว ถ้ามันตรงกัน แสดงว่า Password ถูกครับ

การ Hash เราจะใช้ Library ที่ชื่อว่า bcrypt ครับ ทำการติดตั้งลงไป

npm install bcrypt

ทีนี้การ Hash Password เราจะทำได้โดยการเรียก function

const saltRounds = 10
bcrypt.hash('mypassword', saltRounds, function (err, hash) {
// Store hash in your password DB.
})

ส่วนการ Compare ก็จะใช้แบบนี้

bcrypt.compare('mypassword', hash, function (err, result) {
// result == true
})

ซึ่งทั้งคู่ เป็นแบบ callback base เราสามารถใช้ hashSync และ compareSync เพื่อให้มัน hash/compare แบบ synchronus ได้เช่นกันครับ

const hash = bcrypt.hashSync(myPlaintextPassword, salt)
const result = bcrypt.compareSync(myPlaintextPassword, hash) // true or false

ทีนี้กลับมาที่ส่วนรับค่า req.body จาก User ตอนสมัครและลงทะเบียน เราต้องทำการ hash ค่า password ของ User ก่อน แบบนี้ครับ routes/auth.js

const express = require('express')
const bcrypt = require('bcrypt')
const router = express.Router()
const User = require('../models/User')
router.post('/register', async (req, res) => {
const { username, password, name } = req.body
// simple validation
if (!name || !username || !password) {
return res.render('register', { message: 'Please try again' })
}
const passwordHash = bcrypt.hashSync(password, 10)
const user = new User({
name,
username,
password: passwordHash
})
await user.save()
res.render('index', { user })
})

ส่วนของ Login ก็ทำแบบเดียวกันครับ เราจะ findOne() โดยใช้ Username เราจะได้ค่า password ที่ hash แล้วใน Database จากนั้นค่อยเอาค่าที่ hash ที่เราเก็บไว้ มาเปรียบเทียบกับที่ User ส่งมาจาก form login

const express = require('express')
const bcrypt = require('bcrypt')
const router = express.Router()
const User = require('../models/User')
router.post('/login', async (req, res) => {
const { username, password } = req.body
// simple validation
if (!username || !password) {
return res.render('register', { message: 'Please try again' })
}
const user = await User.findOne({
username
})
if (user) {
const isCorrect = bcrypt.compareSync(password, user.password)
if (isCorrect) {
return res.render('index', { user })
} else {
return res.render('login', { message: 'Username or Password incorrect' })
}
} else {
return res.render('login', { message: 'Username does not exist.' })
}
})
module.exports = router

ทีนี้ ข้อมูลที่เราเก็บส่วนที่เป็น Password ก็จะถูก Hash เรียบร้อยแล้ว ลองทำการ Register และ Login ใหม่ รวมถึงลองเช็คใน Mongo Database ดูครับ ว่าข้อมูลเราจะไม่ใช่ Plain Text แล้ว

Step 5 - เก็บ Session เมื่อ Login

ต่อมา กรณีที่เรา Login หรือ Register จะทำยังไงให้ระบบรู้ว่าเรายังอยู่ในระบบ ยังไม่ได้ Logout ไปไหน ทุกครั้งที่ Refresh หรือ ปิดแท้ป แล้วเปิดใหม่ ไม่จำเป็นต้อง Login แต่ต้องสามารถเข้าได้เลย เพราะว่ามี Session อยู่

วิธีที่ง่ายที่สุดคือ ทำ Middleware ขึ้นมาใช้เองครับ

อย่างที่รู้จากบทความตอนก่อนว่า Middleware คือ function ของ Express ที่มี request ,response และ next เราสามารถ implement อะไรก็ได้ ตามที่เราต้องการ

const isLoggedIn = (req, res, next) => {
if (!req.user) {
res.redirect('/login')
}
next()
}

เราเพียงแค่สร้าง Function ขึ้นมาครับ เป็น Middleware ที่เอาไว้เช็คว่ามี req.user มั้ย? ถ้ามีแสดงว่า User นั้น Login หรือมี Session อยู่

ต่อมา ก็แค่เอาไปใส่ใน routes/index.js แบบนี้

router.get('/', isLoggedIn, function (req, res, next) {
res.render('index', { title: 'Express' })
})

ทีนี้ พอเข้าหน้า http://localhost:3000 จะถูกเด้งไปหน้า /login ทุกครั้ง เนื่องจากไม่มี req.user

ต่อมา เราเพิ่ม req.user ตอนที่เค้า Login เรียบร้อยแล้ว หรือ Register เรียบร้อยแล้ว ที่ไฟล์ routes/auth.js

router.post('/login', async (req, res) => {
...
if (isCorrect) {
// assign user to req object.
req.user = user;
return res.render('index', { user });
}
}

assign ค่า user จาก database ไปใส่ object req ก่อนที่จะ render index ครับ ทีนี้เวลาเรา ยังไม่ได้ login มันก็จะเด้งไปหน้า login แต่ถ้า login เรียบร้อยแล้ว มันจะ redirect ไป index แล้วก็ไม่ถูก redirect มา login แล้ว เพราะเรามี req.user ครับ

ทีนี้ปัญหาก็ยังมีคือ เรา refresh มันไม่ได้จำ req.user ครับ

วิธีแก้ไขคือ ต้องพึ่ง Session ครับ วิธีง่ายๆ คือใช้ express-session

ทำการติดตั้ง

npm install express-session

จากนั้นที่ไฟล์ app.js เพิ่มนี้ลงไป

const session = require('express-session')
app.use(cookieParser())
app.use(
session({
secret: 'my_super_secret',
resave: false,
saveUninitialized: false
})
)

ตัว Middleware ของ Session จะสร้าง req.session ขึ้นมาครับ ทีนี้เราก็สามารถ assign ค่าไปที่ object นี้ได้

ทีนี้ส่วนที่ Login และ Register ที่ใช้ req.user ก็เปลี่ยนเป็น

req.session.user = user

แล้วส่วน isLoggedIn ก็เปลี่ยนเป็นแบบนี้

const isLoggedIn = (req, res, next) => {
if (!req.session.user) {
return res.redirect('/login')
}
next()
}

ทีนี้เราก็จะได้ Session แล้วครับ ที่เป็น built-in และมันจะ clear ก็ต่อเมื่อเรา restart server ครับ

Step 6 - ใช้ Passport.js มาช่วยในการ Authentication

ต่อมา เราไม่อยากเขียน Middleware และ Session เอง เราสามารถใช้ Passport.js ซึ่งเป็น Library ที่นิยมมาทำ Authentication ตัวนึงของ Node.js เลยครับ ด้วยความที่เค้า Provide function และพวก Helper ต่างๆ มาให้ ทำให้มันค่อนข้างง่ายครับ

ซึ่งจริงๆแล้ว Passport.js สามารถทำ Authentication ผ่าน Social Network อื่นๆ เช่น Twitter, Facebook, Google, Github ได้หมดเลยครับ แต่สำหรับบทความนี้ ขอเป็นตัวอย่างเฉพาะ Username / Password ละกันเนาะ

และใน Passport.js เราจะเรียกมันว่า Strategy ครับ โดยใช้ passport-local ครับ

ทำการติดตั้ง

npm install passport passport-local

ที่ไฟล์ app.js เพิ่มนี้ลงไป ค่อนข้างเยอะนิดนึง เดี๋ยวจะพยายามอธิบายครับ

const passport = require('passport')
const LocalStrategy = require('passport-local').Strategy
// passport.use()
// ใช้ LocalStrategy โดยใช้ username และ password
// ภายใน function จะใช้ User.findOne() เพื่อหา username ใน Database
// ถ้าเจอ ก็ compareSync ด้วย bcrypt หากตรง แสดงว่า login ถูกต้อง
// ก็จะ cb (คือ callback function) ส่งต่อไปให้ `req.user` จะมีค่า user
// และไป step ถัดไปคือ serialzie และ deserialize
passport.use(
new LocalStrategy((username, password, cb) => {
User.findOne({ username }, (err, user) => {
if (err) {
return cb(err)
}
if (!user) {
return cb(null, false)
}
if (bcrypt.compareSync(password, user.password)) {
return cb(null, user)
}
return cb(null, false)
})
})
)
// serializeUser และ seserialize จะใช้ร่วมกับ session เพื่อจะดึงค่า user ระหว่าง http request
// โดย serializeUser จะเก็บ ค่าไว้ที่ session
// ในที่นี้คือ cb(null, user._id_) - ค่า _id จะถูกเก็บใน session
// ส่วน derialize ใช้กรณีที่จะดึงค่าจาก session มาหาใน DB ว่าใช่ user จริงๆมั้ย
// โดยจะเห็นได้ว่า ต้องเอา username มา `User.findById()` ถ้าเจอ ก็ cb(null, user)
passport.serializeUser((user, cb) => {
cb(null, user._id_)
})
passport.deserializeUser((id, cb) => {
User.findById(id, (err, user) => {
if (err) {
return cb(err)
}
cb(null, user)
})
})
app.use(passport.initialize())
app.use(passport.session())

ส่วนต่อมาคือ ไฟล์ routes/auth.js จะใช้ Passport มาเป็น Middleware ดัง syntax แบบนี้

router.post('/login', middleware, handlers)

ก็จะได้เป็น

router.post(
'/login',
passport.authenticate('local', {
failureRedirect: '/login', // กำหนด ถ้า login fail จะ redirect ไป /login
successRedirect: '/' // ถ้า success จะไป /
}),
async (req, res) => {
const { username, password } = req.body
return res.redirect('/')
// When use passport no need this anymore!
// // simple validation
// if (!username || !password) {
// return res.render('register', { message: 'Please try again' });
// }
// const user = await User.findOne({
// username
// });
// if (user) {
// const isCorrect = bcrypt.compareSync(password, user.password);
// if (isCorrect) {
// return res.render('index', { user });
// } else {
// return res.render('login', {
// message: 'Username or Password incorrect'
// });
// }
// } else {
// return res.render('login', { message: 'Username does not exist.' });
// }
}
)

แถม Logic ที่เรา implement ก็ไม่ต้องใช้แล้ว เพราะมัน handle ที่ Passport LocalStrategy ที่ไฟล์ app.js เรียบร้อยครับ

สุดท้าย ฟังค์ชั่น isLoggedIn ที่เราใช้ตอน session เราก็เปลี่ยนมา handle req.isAuthenticated() ซึ่งเป็น helper ของ Passport กรณีที่มี session มันก็จะ return true ได้แบบนี้ ไฟล์ routes/index.js

const isLoggedIn = (req, res, next) => {
if (req.isAuthenticated()) {
next()
} else {
res.redirect('/login')
}
}

สุดท้าย ท้ายสุด ลืมส่วน Logout ครับ เรามีปุ่ม Logout และ request มาที่ /logout เราก็เพิ่ม router ส่วนนี้เลย ที่ไฟล์ routes/index.js

router.get('/logout', (req, res) => {
req.logout()
res.redirect('/')
})

ตัว Passport มี req.logout() ที่จัดการพวก session และ logout ให้เรา จากนั้นก็ redirect กลับไป / เป็นอันเรียบร้อยครบ flow การทำงาน

สรุป

ก็บทความนี้ เป็น Flow การทำ Authentication ตั้งแต่เริ่มต้น ใช้แบบธรรมดา จนมี Session ทำ Middleware เอง และสุดท้ายใช้ Library อย่าง Passport.js เข้ามาช่วย ทำให้ประหยัดเวลา ของเราไปได้มากทีเดียว

ก็หวังว่าบทความนี้เพื่อนๆ จะได้ไอเดียนำไปต่อยอด ได้รู้ว่า Session มันทำงานยังไง ได้รู้ว่า Passport มันทำงานยังไง Serialize / Deserialize เอาอะไรไปเก็บใน Session พวกนี้

และตัวอย่าง มันก็ไม่ได้สมบูรณ์นะครับ อาจจะมีบ้าง ที่ไม่ครบ เป้าหมายของบทความนี้คือเน้นให้เห็นไอเดีย การใช้งานครับ หากขาดตกบกพร่องตรงไหนไป ขออภัยด้วยครับ และหากใครติดปัญหา หรือไม่เข้าใจตรงไหน สามารถสอบถามได้ครับ หรือหากใครเจอข้อผิดพลาด สามารถแนะนำได้เช่นกันครับ

ส่วน Source Code (อยู่ part9) สามารถเข้าไปดาวน์โหลด หรือ clone ผ่าน Github ได้เลย หากใครไม่รู้จัก Git สามารถอ่านบทความนี้เพิ่มได้ครับ Git คืออะไร ? + พร้อมสอนใช้งาน Git และ Github

Source Code

ขอบคุณครับ

❤️ Happy Coding