ป้องกัน CSRF Attack ด้วย csurf ใน Express สำหรับ ระบบสมาชิก

เขียนเมื่อ 4 ปีก่อน โดย Ninenik Narkdee
csrf csurf nodejs expressjs

คำสั่ง การ กำหนด รูปแบบ ตัวอย่าง เทคนิค ลูกเล่น การประยุกต์ การใช้งาน เกี่ยวกับ csrf csurf nodejs expressjs

ดูแล้ว 6,069 ครั้ง


เนื้อหาตอนต่อไปนี้ เราจะมาดูวิธีการใช้งาน 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">&times;</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">&times;</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

    โดยให้ทำการติดตั้ง csurf ด้วยคำสั่ง
 
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}!`)
})
 
    หวังว่าเนื้อหานี้ จะเป็นแนวทางสำหรับประยุกต์ใช้งานต่อไปได้ เนื้อหาในตอนหน้าจะเป็นอะไร จะยังอยู่ในส่วนของ
ระบบสมาชิกหรือไม่ รอติดตาม


กด Like หรือ Share เป็นกำลังใจ ให้มีบทความใหม่ๆ เรื่อยๆ น่ะครับ



อ่านต่อที่บทความ









เนื้อหาที่เกี่ยวข้อง






เนื้อหาพิเศษ เฉพาะสำหรับสมาชิก

กรุณาล็อกอิน เพื่ออ่านเนื้อหาบทความ

ยังไม่เป็นสมาชิก

สมาชิกล็อกอิน



( หรือ เข้าใช้งานผ่าน Social Login )




URL สำหรับอ้างอิง











เว็บไซต์ของเราให้บริการเนื้อหาบทความสำหรับนักพัฒนา โดยพึ่งพารายได้เล็กน้อยจากการแสดงโฆษณา โปรดสนับสนุนเว็บไซต์ของเราด้วยการปิดการใช้งานตัวปิดกั้นโฆษณา (Disable Ads Blocker) ขอบคุณครับ