เนื้อหาตอนต่อไปนี้ เราจะมาดูวิธีการใช้งาน csurl middleware
ฟังก์ชั่นของ NodeJs ที่ใช้สำหรับการป้องกัน CSRF ซึ่งเป็นการโจมตีเว็บไซต์รูปแบบหนึ่ง
สามารถหาข้อมูลและทำความเข้าใจเพิ่มเติมเกี่ยวกับ CSRF คืออะไร
ก่อนจะไปถึงแนวทางป้องกัน CSRF เราจะมาจำลองการเกิด CSRF Attack ในกรณีเบื้องต้นอย่างง่ายกัน
การจำลอง CSRF Attack
ให้เราสร้างหน้าแก้ไขรหัสผ่านในระบบสมาชิกของเราอย่างง่าย โดยมีฟอร์มสำหรับแก้ไขรหัสผ่าน และมีส่วนไฟล์ต่าง
ที่เกี่ยวข้องดังนี้
- [routes/changepassword.js] ไฟล์ router สำหรับ path:"/changepassword"
- [views/pages/changepassword.ejs] ไฟล์ template
- [views/partials/nav.ejs] เพิ่มลิ้งค์เมนู dashboard และ change password
- [models/users.js] เพิ่มฟังก์ชั่น changepassword()
- [validator/users.js] เพิ่มส่วนการ changepassword schema
โค้ดพร้อมคำอธิบายตามลำดับดังนี้
ไฟล์ changepassword.js [routes/changepassword.js]
const express = require('express') const router = express.Router() const { validation, schema } = require('../validator/users') const Users = require('../models/users') router.route('/') .all((req, res, next) => { res.locals.pageData = { title:'Change Password Page' } // ค่าที่จะไปใช้งาน ฟอร์ม ใน template res.locals.user = { password:'', confirm_password:'' } // หน้าที่จะส่งไป กรณีไม่ผ่านการตรวจสอบฟอร์ม req.renderPage = "pages/changepassword" next() }) .get((req, res, next) => { // เปิดมาหน้า change password ปกติ res.render('pages/changepassword') }) .post(validation(schema.changepassword), (req, res, next) => { // กรณีส่งข้อมูลมาทำการแก้ไขรหัสผ่าน เรียกใช้งาน users model // ทำการแก้ไขรหัสผ่าน Users.changepassword(req, res).then( (results)=>{ // แก้ไขสำเร็จ res.locals.success = { "message": "ทำการแก้ไขรหัสผ่านเรียบร้อยแล้ว" } res.render('pages/changepassword') }, (error)=>{ // เกิดข้อผิดพลาด res.locals.errors = { "message": error } res.render('pages/changepassword') } ) }) module.exports = router
ไฟล์ changepassword.ejs [views/pages/changepassword.ejs]
<!doctype html> <html> <?- include('../partials/head') -?> <body> <?- include('../partials/header') -?> <?- include('../partials/nav') -?> <div class="container"> <h1 class="text-center">CHANGE PASSWORD</h1> <? if(typeof success !== 'undefined'){ ?> <div class="form-group row"> <div class="col-7 mx-auto"> <div class="alert alert-success alert-dismissible fade show" role="alert"> <?= success.message ?> <button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> </div> </div> <? } ?> <? if(typeof errors !== 'undefined'){ ?> <div class="form-group row"> <div class="col-7 mx-auto"> <div class="alert alert-warning alert-dismissible fade show" role="alert"> <?= errors.message ?> <button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> </div> </div> <? } ?> <form class="mt-3" action="/changepassword" method="POST" novalidate> <div class="form-group row"> <div class="col-7 mx-auto"> <input type="password" class="form-control" name="password" value="<?= user.password ?>" placeholder="Your password"> </div> </div> <div class="form-group row"> <div class="col-7 mx-auto"> <input type="password" class="form-control" name="confirm_password" value="<?= user.confirm_password ?>" placeholder="Confirm your password"> </div> </div> <div class="form-group row"> <div class="col-7 mx-auto"> <button type="submit" class="btn btn-primary btn-block mx-auto"> Change Password</button> </div> </div> </form> </div> <?- include('../partials/footer') -?> </body> </html>
ไฟล์ nav.ejs [views/partials/nav.ejs]
<nav class="text-center"> THIS IS NAV <br> <? if(!session.isLogined || session.isLogined == false){ ?> <a href="/login">Login</a> | <a href="/register">Register</a> <? }else{ ?> <a href="/me">Dashboard</a> | <a href="/changepassword">Change Password</a> | <a href="/me/logout">Logout</a> <? } ?> </nav>
ไฟล์ users.js [models/users.js]
const db = require('../config/db') const bcrypt = require('bcrypt') // ใช้งาน bcrypt module const saltRounds = 10 // กำหนดค่า salt const Users = { email_exists:((req, res) => { return new Promise((resolve, reject)=>{ res.locals.user = req.body db.then((db)=>{ db.collection('users') .find({ email:req.body.email }) .toArray( (error, results) => { if(!error){ if(results.length==0){ resolve(true) }else{ reject('อีเมลนี้ถูกใช้งานแล้ว') } }else{ reject('เกิดข้อผิดพลาด กรุณาลองใหม่') } }) }) }) }), login:((req, res) => { return new Promise((resolve, reject)=>{ res.locals.user = req.body db.then((db)=>{ db.collection('users') .find({ email:req.body.email }) .toArray( (error, results) => { if(!error){ if(results.length > 0){ let hash = results[0].password let password = req.body.password bcrypt.compare(password, hash).then((result)=>{ if(result == true) resolve(results) if(result == false) reject('รห้สผ่านไม่ถูกต้อง กรุณาลองใหม่') }) }else{ reject('อีเมล หรือ รห้สผ่านไม่ถูกต้อง กรุณาลองใหม่') } }else{ reject('เกิดข้อผิดพลาด กรุณาลองใหม่') } }) }) }) }), register:((req, res) => { return new Promise((resolve, reject)=>{ res.locals.user = req.body db.then((db)=>{ db.collection('lastid') .findOneAndUpdate({id:1}, { $inc: { user_id: 1 }},(error, results)=>{ if(!error){ let password = req.body.password bcrypt.hash(password, saltRounds).then((hash)=>{ let insertID = results.value.user_id+1 let user = { "_id": insertID, "name": req.body.name, "email": req.body.email, "password": hash } db.collection('users') .insertOne(user, (error, results) => { if(!error){ resolve(results) }else{ reject('เกิดข้อผิดพลาด กรุณาลองใหม่') } }) }) }else{ reject('เกิดข้อผิดพลาด กรุณาลองใหม่') } }) }) }) }), userinfo:((req, res) => { return new Promise((resolve, reject)=>{ res.locals.user = req.body db.then((db)=>{ // เชื่อมต่อฐานข้อมูล db.collection('users') .find({ _id:req.session.userID }) .toArray( (error, results) => { if(!error){ if(results.length > 0){ resolve(results) }else{ reject('ไม่พบข้อมูลผู้ใช้') } }else{ reject('เกิดข้อผิดพลาด กรุณาลองใหม่') } }) }) }) }), changepassword:((req, res) => { return new Promise((resolve, reject)=>{ res.locals.user = req.body db.then((db)=>{ // เชื่อมต่อฐานข้อมูล let password = req.body.password // รับค่ารห้สผ่านใหม่ที่จะแก้ไข bcrypt.hash(password, saltRounds).then((hash)=>{ // เข้ารหัส รหัสผ่านใหม่ และเก็บใน user object ในรูปแบบข้อมูลฟิลด์ และค่าที่จัพเดท let user = { "password": hash } db.collection('users') // ทำการอัพเดทข้อมูล .updateOne({ _id:req.session.userID }, { $set: user }, (error, results) => { if(!error){ //อัพเดทสำเร็จ ส่งกลับข้อมูลที่อัพเดท resolve(results) }else{ reject('เกิดข้อผิดพลาด กรุณาลองใหม่') } }) }) }) }) }) } module.exports = Users
ไฟล์ users.js [validator/users.js]
const Joi = require('@hapi/joi') const validation = (schema) =>{ return ((req, res, next) => { // ทำการตรวจสอบความถูกต้องของข้อมูล req.body ที่ส่งมา Joi.validate(req.body, schema, function (error, value) { // กรณีเกิด error ข้อมูลไม่ผ่านการตรวจสอบ if(error) { res.locals.errors = { "message": error.details[0].message } res.locals.user = req.body return res.render(req.renderPage) } if(!error) next() }) }) } // กำหนดชุดรูปแบบ schema const schema = { register : Joi.object().keys({ name: Joi.string().min(3).max(30).required(), email: Joi.string().email({ minDomainSegments: 2 }).required(), password:Joi.string().min(6).max(15).required(), confirm_password: Joi.any().valid(Joi.ref('password')).required() .error(errors => {return{ message:'ยืนยันรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่'}}) }), login : Joi.object().keys({ email: Joi.string().email({ minDomainSegments: 2 }).required(), password:Joi.string().min(6).max(15).required(), remember:Joi.any(), }), changepassword : Joi.object().keys({ password:Joi.string().min(6).max(15).required(), confirm_password: Joi.any().valid(Joi.ref('password')).required() .error(errors => {return{ message:'ยืนยันรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่'}}) }) } module.exports = { validation, schema }
จากนั้นเพิ่มส่วนของการใช้งาน การเปลี่ยนรหัสผ่านไปในไฟล์ app.js ในสองส่วนนี้ตามลำดับ
// ส่วนของการเรียกใช้งาน router module ต่างๆ const changePasswordRouter = require('./routes/changepassword')
และ
// ส่วนของการกำหนด routes path app.use('/changepassword', authorize('/login', true), changePasswordRouter)
เสร็จแล้วเมื่อทดสอบรัน เราก็สามารถที่จะเข้าไปแก้ไขรหัสผ่าน ในหน้า change password ที่ path:"/changepassword"
การทำงานของหน้านี้ ก็ง่าย ตามรูปตัวอย่างด้านบน คือ เป็นฟอร์มที่มีช่องให้กรอกรหัสผ่านใหม่ และก็ช่องกรอกยืนยันรหัส
ผ่านใหม่อีกครั้ง ซึ่งเป็นรูปแบบอย่างง่าย และก็ถือว่าเป็นรูปแบบที่ไม่รัดกุมเท่าไหร่ แต่เราสร้างเพื่อทดสอบจำลอง CSRF
ถ้าจำได้แต่ต้น เรากำหนดรหัสผ่าน ให้กับ user ที่ชื่อ John Doe เป็น 111111 เราจะจำลองเหตุการณ์ว่า นาย A จ้องจะ
เข้าใช้งานในล็อกอินของคนที่ชื่อ John Doe ซึ่งเขาอาจจะทราบอีเมลของคนๆ นี้อยู่แล้ว จากแหล่งข้อมูลอื่นใด และสิ่งที่เขา
ต้องการเพิ่มคือรหัสผ่าน แต่นาย A จะรู้รหัสผ่านของ John นั่นยากมาก แต่เขารู้ว่า ระบบการแก้ไขรหัสผ่านของเว็บที่ John
ใช้งานอยู่ มีช่องโหว่ที่เขาจะทำการ CSRF attack เพื่อมาแก้ไขรหัสผ่านของ John โดยนาย A อาจจะสมัครสมาชิกเข้า
มาทดสอบระบบของเว็บไซต์ก็ได้ สิ่งที่นาย A รู้เกี่ยวกับระบบแก้ไขรหัสผ่านคือ
มีการส่ง POST Request ข้อมูลที่เป็น รหัสผ่าน และ ยืนยันรหัสผ่าน เข้ามายัง path:"/changepassword" สมมติว่าเป็น
เว็บไซต์มี url ดังนี้ http://localhost:3000/changepassword ข้อมูลฟอร์ม ที่ส่งมาก็มี password และ confirm_password
และนาย A ไปสร้างฟอร์ม ที่ไหนก็ได้ อาจจะรันที่เครื่องหรือเว็บไซต์อื่น มาในรูปแบบดังนี้
<form action="http://localhost:3000/changepassword" method="POST"> <input name="password" value="hackpassword"> <input name="confirm_password" value="hackpassword"> <button type="submit" >Submit</button> </form>
ถามว่าถ้านาย A ส่งข้อมูลตามฟอร์มนี้ แล้วรหัสผ่านของ John จะถูกแก้ไขเลยไหม คำตอบก็คือ ไม่
ในขั้นตอนการแก้ไขรหัสผ่าน สิ่งที่อ้างอิงว่าแก้ไขรหัสผ่านของ คนๆใด นั้นระบบ อ้างอิงจาก session.userID
หรือก็คือ _id ของสมาชิกนั้นที่กำลังล็อกอินอยู่ และค่านั้นก็เก็บที่ฝั่ง server นาย A ไม่สามารถส่งค่านั้นไปได้
และค่านั้น จะมีก็ต่อเมื่อ John กำลังล็อกอินอยู่ สิ่งที่นาย A คิดต่อมา ก็คือ ข้อมูลต้องถูกส่งตอนที่ John กำลัง
ล็อกอินอยู่ หรือก็คือ อาศัยการคงอยู่ของ session ฝั่งของ John สำหรับโจมตี ถึงแม้ว่า John ไม่ได้เปิดหน้า
แก้ไขรหัสผ่านอยู่ก็ตาม ถึงตรงนี้ นาย A ไม่มีทางรู้แน่นอนว่า John จะล็อกอินตอนไหน และจะใช้งาน เว็บไซต์นั้นนาน
หรือจะคง session ไว้นานเท่าไหร่ แต่ที่นาย A ต้องทำสำหรับการโจมตีคือ เตรียมคำสั่งหรือ script ที่พร้อมจะทำงาน
ทันที อาจจะส่งเป็นอีเมล หลอกให้คลิก หรือ อาจจะสร้างเป็นลิ้งค์ไว้ แล้วแอบฝั่งไว้ในเว็บดังกล่าว สมมติเช่นในเว็บมีเว็บบอร์ด
หรือเว็บมีให้กำหนด url หรือลิ้งค์ส่วนตัว หรืออะไรก็แล้วแต่ ที่นาย A จะสามารถส่ง John ไปยังหน้าที่รันคำสั่งโจมตีได้
อย่างเช่น สมมติว่านาย A สร้างหน้าโจมตีไว้ที่ลิ้งค์ http://mywebsite.com/hello.html
<!doctype html> <html> <head> <meta charset="utf-8"> <title>Hello</title> </head> <body onload="document.forms[0].submit()"> <form action="http://localhost:3000/changepassword" method="POST" > <input name="password" value="hackpassword"> <input name="confirm_password" value="hackpassword"> <button type="submit" >Submit</button> </form> </body> </html>
เมื่อ John เปิดมาหน้าดังกล่าวข้างต้น โดยที่ยังล็อกอินอยู่ ฟอร์มก็จะทำการ submit เพื่อไปแก้ไขรหัสผ่านของ John
รูปแบบฟอร์มข้างต้น นาย A อาจจะใช้ในรูปแบบ Ajax ก็ได้ ทำให้ทำงานอยู่เบื้องหลัง John ก็ยิ่งไม่รู้อีกว่าเกิดอะไรขึ้น
ทั้งที่มีการแก้ไขรหัสผ่านของ John เรียบร้อยแล้ว
รหัสผ่านของ John ก่อนกดลิ้งค์ที่มีการโจมตีแบบ CSRF
และหลังถูกเปลี่ยนรหัสผ่านเป็น "hackpassword"
ตอนนี้รหัสผ่านของ John ถูกแอบเปลี่ยนเป็นค่าใหม่เรียบร้อยแล้ว เมื่อทำการล็อกอินใหม่ สิ่งที่เกิดขึ้นคือ ไม่สามารถ
ล็อกอินด้วยรหัสผ่าน "111111" ได้ เพราะถูกเปลี่ยนเป็น "hackpassword" แล้ว
และนี้คือเหตุผลของการป้องกันการโจมตีแบบ CSRF attack ที่เราจะใช้งานเนื้อหานี้
เราจะเข้าไปใช้งาน John ด้วย "hackpassword" และแก้ไขกลับเป็น "111111"
แนวทางป้องกัน CSRF Attack
หลักการคร่าวๆ คือเราจะทำการแทรก input hidden ไปในทุกๆ ฟอร์ม ที่ต้องการป้องกันการโจมตีรูปแบบ
CSRF Attack โดยให้มีค่าเป็นค่า random ที่ตัว csurf module จะ generate หรือสร้างมาให้ โดยใช้คำสั่ง req.csrfToken() ซึ่งเราขอเรียกว่า csrfToken
ทุกครั้งที่มี Request มายังหน้าฟอร์มข้อมูล ค่า csrfToken จะถูกสร้างขึ้นมาใหม่ และเก็บไว้ใน input hidden
และเมื่อผู้ใช้ส่งข้อมูลไปใช้งาน เช่น POST Request ข้อมูลไปบันทึกหรือใช้งานต่อ ค่า csrfToken ก็จะถูกส่งไปตรวจสอบยัง server ว่าเป็นค่าที่ถูกต้องหรือไม่ ซึ่งหากเป็นการพยายามส่งค่าจากที่อื่นๆ ที่ไม่ได้ใช้ผ่านเว็บไซต์เราโดยตรง ก็จะไม่สามารถรู้
ได้ว่า ค่า csrfToken ที่ผู้ใช้จะส่งไปก้บฟอร์มนั้น เป็นค่าอะไร ทำให้ข้อมูลจากฟอร์มนั้นๆ ไม่ผ่านการตรวจสอบ
สำหรับในโปรเจ็คระบบสมาชิกของเรา จะมีส่วนของการ POST Request ในฟอร์มเบื้องต้นอยู่ 3 จุด คือ ในขั้นตอน
การล็อกอินเข้าสู่ระบบ ขั้นตอนการสมัครสมาชิก และล่าสุดขั้นตอนการแก้ไขรหัสผ่าน เมื่อเรามีการใช้งาน Csurf middleware
ฟังก์ชั่น ทั้ง 3 ส่วนข้างต้น ก็จะได้รับการป้องกัน CSRF Attack
การติดตั้ง Csurf Module
npm install csurf --save
การใช้งาน Csurf Module
ในการใช้งาน csurf เราต้องเลือกว่าที่จะใช้งาน ในรูปแบบ cookie หรือ session ในที่นี้ เราจะใช้งาน csurf
ในรูปแบบของ session โดยในขั้นตอนการติดตั้ง middleware ฟังก์ชั่น เราต้องกำหนดการใช้งาน ให้อยู่หลังจาก
ส่วนของการกำหนด session ดังนี้
ในไฟล์ app.js เรียกใช้งาน csurf module โดยกำหนด
const csrf = require('csurf')
ไว้ด้านบน จากนั้นในส่วนของการใช้งาน ก็กำหนดต่อจากการใช้งาน session ดังนี้
//app.set('trust proxy', 1) // trust first proxy app.use(session({ name:'sid', // ถ้าไม่กำหนด ค่าเริ่มต้นเป็น 'connect.sid' secret: 'my ses secret', store:store, resave: true, saveUninitialized: true })) app.use(useSession) app.use(csrf()) // ส่วนการใช้งาน csurf middleware
เราไม่ได้กำหนด option อะไรเพิ่มเติมในขั้นตอนการเรียกใช้งาน ดังนั้น ค่าต่างๆ จะเป็นค่า default เป็นหลัก
ตัวแปร session ที่ชื่อ csrfSecret จะถูกบันทึกไว้ในฐานข้อมูล MongoDB ที่เราเก็บ session ไว้ใน mySession collection
ค่านี้ จะเป็นค่าฝั่ง server ที่เอาไว้ตรวจสอบกับค่าฝั่ง client
ฝั่ง client เมื่อ Request มายังหน้าฟอร์มต่างๆ เราจะใช้คำสั่ง req.csrfToken() สร้าง csrfToken ขึ้นมาแล้ว
ส่งเข้าไปในฟอร์ม ผ่านตัวแปร res.locals.csrfToken
res.locals.csrfToken = req.csrfToken()
ค่า csrfToken จะถูกนำไปกำหนดในไฟล์ template ไว้ในฟอร์ม ให้กับ input hidden ที่ชื่อ "_csrf" เป็นดังนี้
<input type="hidden" name="_csrf" value="<?= csrfToken ?>">
โดยเราจะแทรกไว้ต่อจาก tag เปิดของฟอร์ม <form> ในลักษณะดังนี้
<form action="" method="POST"> <input type="hidden" name="_csrf" value="<?= csrfToken ?>"> ...... ..... </form>
ประยุกต์ Csurf กับระบบสมาชิก
เมื่อเราได้แนวทาง และวิธีการกำหนดต่างๆ แล้ว ต่อไปก็มาดูวิธีนำมาใช้งานในระบบสมาชิกของเรา โดยจะกำหนด
ในไฟล์ login.js , register.js และ changepassword.js ดังนี้
ไฟล์ login.js บางส่วน [routes/login.js]
router.route('/') .all((req, res, next) => { // ตัวแปรที่กำหนดด้วย res.locals คือค่าจะส่งไปใช้งานใน template res.locals.pageData = { title:'Login Page' } // ค่าที่จะไปใช้งาน ฟอร์ม ใน template res.locals.user = { email:req.session.email || '', password:req.session.password || '', remember:req.session.remember || '' } // generate csrfTOken res.locals.csrfToken = req.csrfToken() // กำหนดหน้าที่ render กรณี error ไม่ผ่านการตรวจสอบข้อมูล req.renderPage = "pages/login" next() })
ไฟล์ register.js บางส่วน [routes/register.js]
router.route('/') .all((req, res, next) => { // ตัวแปรที่กำหนดด้วย res.locals คือค่าจะส่งไปใช้งานใน template res.locals.pageData = { title:'Register Page' } // ค่าที่จะไปใช้งาน ฟอร์ม ใน template res.locals.user = { name:'', email:'', password:'', confirm_password:'' } // generate csrfTOken res.locals.csrfToken = req.csrfToken() // กำหนดหน้าที่ render กรณี error ไม่ผ่านการตรวจสอบข้อมูล req.renderPage = "pages/register" next() })
ไฟล์ changepassword.js บางส่วน [routes/changepassword.js]
router.route('/') .all((req, res, next) => { res.locals.pageData = { title:'Change Password Page' } // ค่าที่จะไปใช้งาน ฟอร์ม ใน template res.locals.user = { password:'', confirm_password:'' } // generate csrfTOken res.locals.csrfToken = req.csrfToken() // กำหนดหน้าที่ render กรณี error ไม่ผ่านการตรวจสอบข้อมูล req.renderPage = "pages/changepassword" next() })
ลักษณะการทำงานก็คือ เมื่อเปิดหน้า ฟอร์มขึ้นมา ก็ให้สร้าง csrfToken สำหรับส่งไปใช้งานใน template
โดยต่อไปให้เราแทรก
<input type="hidden" name="_csrf" value="<?= csrfToken ?>">
เข้าไปในไฟล์ template
หน้าล็อกอิน login.ejs [views/pages/login.ejs]
หน้าสมัครสมาชิก register.ejs [views/pages/register.ejs]
หน้าแก้ไขรหัสผ่าน changepassword.ejs [views/pages/changepassword.ejs]
โดยแทรกต่อไว้ใต้ tag เปิดของ <form> ตามตัวอย่างที่แนะนำได้บน สมมติเช่น
ในฟอร์มหน้าล็อกอิน จะได้เป็นดังนี้
........ ...... <form class="mt-3" action="/login" method="POST" novalidate> <input type="hidden" name="_csrf" value="<?= csrfToken ?>"> <div class="form-group row"> <div class="col-7 mx-auto"> <input type="email" class="form-control" name="email" value="<?= user.email ?>" placeholder="Your email"> </div> </div> ........ ......
และสุดท้าย อย่าลืมว่า ระบบสมาชิกของเรา มีการใช้งานการตรวจสอบความถูกต้องของข้อมูลโดยใช้งาน middleware
ฟังก์ชั่นที่เราสร้างขึ้นชื่อว่า validation() ซึ่งอยู่ในไฟล์ users.js [validator/users.js]
โดยเมื่อเรามีการเพิ่ม element เข้าไปในฟอร์ม เราต้องไปกำหนดค่าเพิ่มเข้าไปใน schema ด้วย จะได้เป็นดังนี้
ไฟล์ users.js บางส่วน [validator/users.js]
// กำหนดชุดรูปแบบ schema const schema = { register : Joi.object().keys({ name: Joi.string().min(3).max(30).required(), email: Joi.string().email({ minDomainSegments: 2 }).required(), password:Joi.string().min(6).max(15).required(), confirm_password: Joi.any().valid(Joi.ref('password')).required() .error(errors => {return{ message:'ยืนยันรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่'}}), _csrf:Joi.any() }), login : Joi.object().keys({ email: Joi.string().email({ minDomainSegments: 2 }).required(), password:Joi.string().min(6).max(15).required(), remember:Joi.any(), _csrf:Joi.any() }), changepassword : Joi.object().keys({ password:Joi.string().min(6).max(15).required(), confirm_password: Joi.any().valid(Joi.ref('password')).required() .error(errors => {return{ message:'ยืนยันรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่'}}), _csrf:Joi.any() }) }
รูปแบบ _csrf:Joi.any() เป็นการกำหนดว่า name ที่ชื่อ "_csrf" เป็นค่าใดๆ ก็ได้ แต่จำเป็นต้องส่งค่าไป
เนื้องจากค่าของ _csrf เป็นค่าได้ที่จากการ generate ด้วยคำสั่ง req.csrfToken() เราจึงไม่ต้องสนใจรูปแบบของข้อมูล
เป็นอันเรียบร้อย พร้อมทดสอบการทำงานของระบบป้องกัน CSRF Attack
การทดสอบการป้องกัน CSRF Attack
เมื่อเรามายังหน้าล็อกอิน และ ทำการ inspector ดูโค้ด จะพบ input hidden ที่ชื่อ _csrf มีค่า csrfToken
ที่ถูก generate และพร้อมจะส่งไปตรวจสอบที่ฝั่ง server ดังรูป
เราไปดูค่าที่้ฝั่ง server ที่เป็น session และได้บันทึกลงฐานข้อมูลในชื่อ csrfSecret
จะเห็นว่าค่าทั้งสองคือ _csrf ที่ส่งไปในตัวแปร req.body._csrf ไม่ได้เหมือนกับ ตัวแปร session csrfSecret
ที่อยู่ในตัวแปร req.session.csrfSecret ที่บันทึกอยู่ในฐานข้อมูล กล่าวคือ การเปรียบเทียบว่าค่าที่ส่ง กับค่าที่ตรวจสอบ
ไม่ได้เปรียบเทียบว่าค่าเท่ากัน หรือค่าเดียวกันโดยตรง แต่มีส่วนจัดการ การตรวจสอบความถูกต้องของข้อมูลทั้งสองอยู่นั่นเอง
คล้ายๆ กับเปรียบเทียบรหัสผ่านที่เข้ารหัส กับรหัสผ่านที่ผู้ใช้กรอก ในเนื้อหาการใช้งาน bcrypt ในตอนที่ผ่านมา
ในเบื้องต้นเมื่อเราทำการล็อกอิน หากการตั้งค่าการใช้งาน และการกำหนดต่างๆ เกี่ยวกับ cusrf module ไม่มีอะไร
ผิดพลาด เราก็จะสามารถเข้าสู่ระบบได้ไม่มีปัญหา
เราลอง logout และลองแก้ไข ผ่านหน้า inspector ใน 2 กรณีดังนี้คือ ไม่มี input hidden และ กรณีมี input hidden
แต่ค่าที่ส่งไป เป็นค่าอื่น ที่เราแก้ไข
กรณีที่ 1 เราทำการลบ input hidden ออกไป
กรณีที่ 2 เราทำการแก้ไข ค่าของ input hidden จากเดิมเป็นค่าที่ ถูก generate มาเป็นค่าที่เรากำหนดเอง
เมื่อทดสอบกดส่งข้อมูล เพื่อทำการล็อกอินเข้าระบบ ก็จะขึ้นแสดง error ดังรูปด้านล่าง
ขึ้นแสดงเป็น invalid csrf token นั่นก็คือระบบป้องกัน CSRF Attack ของเราทำงานได้ ถูกต้อง กล่าวคือ หากมีใคร
พยายามจะส่งข้อมูลใดๆ มายัง server ของเรา โดยไม่ได้มีค่า csrfToken ตามที่ server กำหนด ก็จะไม่สามารถนำเข้า
ข้อมูลมายัง server เราได้ นั่นเอง
ก่อนจบเกี่ยวกับการป้องกัน CSRF attack ขอเพิ่มเติมในส่วนของการจัดการ error ที่เกิดขึ้นกรณีไม่ผ่านการตรวจสอบ
csrfToken ซึ่งในตัวอย่างเราจะเห็นว่า error จะถูกส่งต่อไปยังหน้าจัดการ error ที่เรากำหนดไว้ อยู่แล้ว แต่ถ้าเราต้องการ
ให้แสดงเป็นหน้าอื่น หรือรูปแบบที่แตกต่างออกไป เราก็สามารถเพิ่มเข้าไปในส่วนของการจัดการ error ในไฟล์ app.js
ดังนี้
ไฟล์ app.js
const express = require('express') // ใช้งาน module express const app = express() // สร้างตัวแปร app เป็น instance ของ express const path = require('path') // เรียกใช้งาน path module const cookieParser = require('cookie-parser') const session = require('express-session') const store = require('./config/storeDb') const { authorize } = require('./config/auth') const { useSession } = require('./config/session') const csrf = require('csurf') const createError = require('http-errors') // เรียกใช้งาน http-errors module const port = 3000 // port // ส่วนของการใช้งาน router module ต่างๆ const indexRouter = require('./routes/index') const loginRouter = require('./routes/login') const registerRouter = require('./routes/register') const dashboardRouter = require('./routes/dashboard') const changePasswordRouter = require('./routes/changepassword') // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.set('view options', {delimiter: '?'}); // app.set('env','production') app.use(express.json()) app.use(express.urlencoded({ extended: false })) app.use(cookieParser()) app.use(express.static(path.join(__dirname, 'public'))) //app.set('trust proxy', 1) // trust first proxy app.use(session({ name:'sid', // ถ้าไม่กำหนด ค่าเริ่มต้นเป็น 'connect.sid' secret: 'my ses secret', store:store, resave: true, saveUninitialized: true })) app.use(useSession) app.use(csrf()) // เรียกใช้งาน indexRouters app.use('/', indexRouter) app.use('/login', authorize('/me', false), loginRouter) app.use('/register', authorize('/me', false), registerRouter) app.use('/me', authorize('/login', true), dashboardRouter) app.use('/changepassword', authorize('/login', true), changePasswordRouter) // ทำงานทุก request ที่เข้ามา app.use(function(req, res, next) { var err = createError(404) next(err) }) // ส่วนจัดการ CSRF error app.use(function (err, req, res, next) { // ถ้าไม่ใช้ CSRF error ให้ข้ามไปส่วนจัดการ error ปกติ if (err.code !== 'EBADCSRFTOKEN') return next(err) // ถ้าเป็น CSRF token errors ก็เช่น // res.status(403) // res.send('form tampered with') // ในที่นี้ เราให้ไปยังหน้าฟอร์มนั้น และแสดงข้อความ Invalid csrf token // โดยใช้ req.originalUrl เพื่อลิ้งค์ไปยัง path ของหน้าฟอร์มที่ error res.cookie('flash_message', 'Invalid csrf token',{maxAge:3000}) return res.redirect(req.originalUrl) }) // ส่วนจัดการ error app.use(function (err, req, res, next) { // กำหนด response local variables res.locals.pageData = { title:'Error Page' } res.locals.message = err.message res.locals.error = req.app.get('env') === 'development' ? err : {} // กำหนด status และ render หน้า error page res.status(err.status || 500) // ถ้ามี status หรือถ้าไม่มีใช้เป็น 500 res.render('pages/error') }) app.listen(port, function() { console.log(`Example app listening on port ${port}!`) })
หวังว่าเนื้อหานี้ จะเป็นแนวทางสำหรับประยุกต์ใช้งานต่อไปได้ เนื้อหาในตอนหน้าจะเป็นอะไร จะยังอยู่ในส่วนของ
ระบบสมาชิกหรือไม่ รอติดตาม