Xây dựng chức năng Verify Email và Forgot Password trong NodeJS


Tiếp tục câu chuyện bài trước, chúng ta sẽ hoàn thành chức năng cho phần authentication, đồng thời tìm hiểu cách gửi Mail trong NodeJS

Giới thiệu

Chúng ta sử dụng lại source code cũ tại đây. Các bạn theo dõi commit đễ theo dõi sự thay đổi.

Bài hôm nay quan trọng là tìm hiểu về cách gửi Mail

SMTP viết tắt của Simple Mail Transfer Protocol là giao thức truyền tải thư tín đơn giản hóa.

Tổng quan về cách mà SMTP hoạt động:

  • Khi có một email cần được gửi đi, hệ thống SMTP sẽ tự động dựa vào tên địa chỉ email đó và chuyển thông báo tới máy chủ SMTP.
  • Khi máy chủ SMTP nhận được tín hiệu, nó sẽ trao đổi giữa máy chủ SMTP và máy chủ DNS để tìm ra tên miền gốc tại Hostname trong máy chủ SMTP.
  • Sau đó, máy chủ thực hiện kiểm tra sự trùng khớp trong thông tin người dùng và thông tin email rồi dựa vào kết quả đó để cho phép gửi nhận dữ liệu.

Các bạn clone source bên trên nếu chưa tìm hiểu bài trước, hoặc code tiếp tục trên source cũ nhang. Các bạn nhớ theo dõi commit của mình.

Bài hôm nay chúng ta chủ yếu làm việc với package nodemailer

npm install nodemailer

Cắm đầu cắm mặt code

Đầu tiên chúng ta cần cập nhật file .env một xíu:

APP_NAME=MYSQL_NODEJS
APP_URL=http://localhost:3000

SESSION_SECRET=datnquit
BCRYPT_SALT_ROUND=10

MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=datnquit@gmail.com
MAIL_PASSWORD=app_password
MAIL_ENCRYPTION=TLS
MAIL_FROM_ADDRESS=datnquit@gmail.com
MAIL_FROM_NAME="${APP_NAME}"

Khi sử dụng password bạn cần chú ý:

Nếu Gmail của bạn bật bảo mật 2 lớp (Two-factor Authentication) thì bạn cần tạo ra mật khẩu ứng dụng 1 lần của gmail để có thể sử dụng được, bạn có nhập mật khẩu của gmail thì cũng không thể dùng được trong trường hợp này. Mình không khuyến khích các bạn bỏ lớp bảo mật nâng cao này đi. Bạn có thể theo dõi cách lấy app_password Tại đây (cuối bài)

Mở trình quản lý CSDL và chạy câu query để cập nhật field của bảng users nhằm xây dựng chức năng verify email

ALTER TABLE users
ADD email_verified_at TIMESTAMP

Nhằm giúp cho code ngắn gọn và đẹp hơn, ta set static file để chứa file css.
Mở file server.js và chèn thêm 1 đoạn code nho nhỏ:

...
app.use(express.static('app/public'));
...

Ta tiến hành tạo file main.css theo thư mục như sau: app/public/css/main.css

.login-form {
    width: 300px;
    margin: 0 auto;
    font-family: Tahoma, Geneva, sans-serif;
}
.login-form h1 {
    text-align: center;
    color: #4d4d4d;
    font-size: 24px;
    padding: 20px 0 20px 0;
}
.login-form input[type="password"],
.login-form input[type="text"] {
    width: 100%;
    padding: 15px;
    border: 1px solid #dddddd;
    margin-bottom: 15px;
    box-sizing:border-box;
}
.login-form input[type="submit"] {
    width: 100%;
    padding: 15px;
    background-color: #356596;
    border: 0;
    box-sizing: border-box;
    cursor: pointer;
    font-weight: bold;
    color: #ffffff;
}

Ta chuyển style trong file login.ejs chuyển ra thôi chớ không có gì mới mẻ.
Cập nhật lại các file view, thay thế các đoạn style thành <link rel="stylesheet" href="/css/main.css">.

Verify Email

Như mình đã nói phía trên, điều quan trọng bài hôm nay là tìm hiểu cách gửi mail. Vậy mình bắt tay vào viết function gửi mail trước nhang.

Ta cần tại file config theo thư mục app/config/mail.config.js:

require('dotenv/config');

module.exports = {
    MAILER: process.env.MAIL_MAILER,
    HOST: process.env.MAIL_HOST,
    PORT: process.env.MAIL_PORT,
    USERNAME: process.env.MAIL_USERNAME,
    PASSWORD: process.env.MAIL_PASSWORD,
    ENCRYPTION: process.env.MAIL_ENCRYPTION,
    FROM_ADDRESS: process.env.MAIL_FROM_ADDRESS,
    FROM_NAME: process.env.MAIL_FROM_NAME,
}

Bây giờ mình mới viết function nè:
app/utils/mailer.js

const nodeMailer = require('nodemailer');
const mailConfig = require('../config/mail.config');
require('dotenv/config');

exports.sendMail = (to, subject, htmlContent) => {
    const transport = nodeMailer.createTransport({
        host: mailConfig.HOST,
        port: mailConfig.PORT,
        secure: false,
        auth: {
            user: mailConfig.USERNAME,
            pass: mailConfig.PASSWORD,
        }
    })

    const options = {
        from: mailConfig.FROM_ADDRESS,
        to: to,
        subject: subject,
        html: htmlContent
    }
    return transport.sendMail(options);
}

Đã xây dựng xong rồi, bây giờ khai báo route thôi chớ còn gì nữa nè. Mở file auth.route.js:

route.get('/verify', register.verify)

Đi đến controller để khai báo function verify:
register.controller.js

exports.verify = (req, res) => {
    bcrypt.compare(req.query.email, req.query.token, (err, result) => {
        if (result == true) {
            User.verify(req.query.email, (err, result) => {
                if (!err) {
                    res.redirect('/login');
                } else {
                    res.redirect('/500');
                }
            });
        } else {
            res.redirect('/404');
        }
    })
}

Mà cái route này chạy khi nào??? Có phải là sau khi đăng kí sẽ có 1 cái email, trong đó sẽ có 1 cái đường dẫn không? Đường dẫn đó chính là cái đường dẫn mà ta vừa khai báo ở trên á. Mà để nhận được cái mail, thì ta phải gửi nó lúc đăng ký. Ta chỉnh sửa lại function register 1 xíu, ngay dòng tạo user mới, ta cập nhật nếu tạo thành công thì ta sẽ gửi mail

const mailer = require('../../utils/mailer');

... 

exports.register = (req, res) => {
...
    User.create(user, (err, user) => {
        if (!err) {
            bcrypt.hash(user.email, parseInt(process.env.BCRYPT_SALT_ROUND)).then((hashedEmail) => {
                console.log(`${process.env.APP_URL}/verify?email=${user.email}&token=${hashedEmail}`);
                mailer.sendMail(user.email, "Verify Email", `<a href="${process.env.APP_URL}/verify?email=${user.email}&token=${hashedEmail}"> Verify </a>`)
            });
            
            res.redirect('/login');
        }
    })
...
}
...

Mình xử dụng token chính là email đã mã hóa, các bạn có thể sử dụng nhiều cách khác như sử dụng cách mã hóa 1 chuỗi chứa nhiều thông tin.

Hàm verify sẽ so sánh cái email và token đó bằng thằng bcrypt.

Trong hàm verify ta có sử dụng phương thức verify của thằng model User. Vậy thì mở file app/models/user.model.js và khai báo nó thôi:

User.verify = (email, result) => {
    sql.query(
        "UPDATE users SET email_verified_at = ? WHERE email = ?",
        [new Date(), email],
        (err, res) => {
            if (err) {
                console.log("error: ", err);
                result(null, err);
                return;
            }
            if (res.affectedRows == 0) {
                result({ kind: "not_found" }, null);
                return;
            }
            result(null, { email: email });
        }
    );
}

Bây giờ bạn thử đăng ký 1 user mới xem có nhận được email không nhang, ngoài ra mình có log url verify, bạn có thể sử dụng đường dẫn đó truy cập trực tiếp chớ không cần mở mail ra.

Thật ra chỗ này nó còn 1 bước nữa, nghĩa là sau khi đăng ký, chúng ta sẽ chuyển người dùng qua 1 trang verify gì đó tùy các bạn đặt tên, để mà lỡ người dùng không nhận được email, thì có thể request lên và gửi lại email xác thực. Do mình lười quá nên không làm cái đó. Các bạn có thể tự làm thêm nhang.

Forgot Password

Câu chuyện chỗ này thì nó dài hơn phía trên 1 xíu, đầu tiên cần phải tạo route dành cho việc quên mật khẩu, và tạo route cho việc reset lại cái mới.

Mở lại file app/routes/auth.route.js chèn thêm code vào nè:

const forgotPassword = require('../controllers/auth/forgotPassword.controller');
...
    router.get('/verify', register.verify)

    .get('/password/reset', forgotPassword.showForgotForm)
    .post('/password/email', forgotPassword.sendResetLinkEmail)

    .get('/password/reset/:email', forgotPassword.showResetForm)
    .post('/password/reset', forgotPassword.reset);

Nhìn vô là ta biết là phải tạo file controler mới ròi phải không, tạo file theo đường dẫn app/controllers/auth/forgotPassword.controller.js

const User = require('../../models/user.model');
const bcrypt = require('bcrypt');
const mailer = require('../../utils/mailer');

exports.showForgotForm = (req, res) => {
    res.render('auth/passwords/email');
}

exports.sendResetLinkEmail = (req, res) => {
    if (!req.body.email) {
        res.redirect('/password/reset')
    } else {
        User.findByEmail(req.body.email, (err, user) => {
            if (!user) {
                res.redirect('/password/reset')
            } else {
                bcrypt.hash(user.email, parseInt(process.env.BCRYPT_SALT_ROUND)).then((hashedEmail) => {
                    mailer.sendMail(user.email, "Reset password", `<a href="${process.env.APP_URL}/password/reset/${user.email}?token=${hashedEmail}"> Reset Password </a>`)
                    console.log(`${process.env.APP_URL}/password/reset/${user.email}?token=${hashedEmail}`);
                })
                res.redirect('/password/reset?status=success')
            }
        })
    }
}

exports.showResetForm = (req, res) => {
    if (!req.params.email || !req.query.token) {
        res.redirect('/password/reset')
    } else {
        res.render('auth/passwords/reset', { email: req.params.email, token: req.query.token})
    }
}

exports.reset = (req, res) => {
    const { email, token, password } = req.body;
    console.log(email, token, password);
    if (!email || !token || !password) { 
        res.redirect('/password/reset');
    } else {
        bcrypt.compare(email, token, (err, result) => {
            console.log('compare', result);
            if (result == true) {
                bcrypt.hash(password, parseInt(process.env.BCRYPT_SALT_ROUND)).then((hashedPassword) => {
                    User.resetPassword(email, hashedPassword, (err, result) => {
                        if (!err) {
                            res.redirect('/login');
                        } else {
                            res.redirect("/500");
                        }
                    })
                })
            } else {
                res.redirect('/password/reset');
            }
        })
    }
}

Ở đây mình cũng hash email để làm token. Kiểu kiểu của nó cũng như phần trên verify à nên cũng không có gì khó cả, nếu không hiểu chỗ nào có thể để lại comment bên dưới hoặc liên hệ với mình theo cách kênh thông tin mình có để ở cuối trang lun nhang!

Vẫn chưa xong đâu nhang. Trong model User mình cũng có dùng phương thức resetPassword vậy nên ta cũng phải khai báo nó vào app/models/user.model.js:

...

User.resetPassword = (email, password, result) => {
    sql.query(
        "UPDATE users SET password = ? WHERE email = ?",
        [password, email],
        (err, res) => {
            if (err) {
                console.log("error: ", err);
                result(null, err);
                return;
            }
            if (res.affectedRows == 0) {
                result({ kind: "not_found" }, null);
                return;
            }
            result(null, { email: email });
        }
    );
};
...

Và giờ là 2 cái view cho 2 cái form forgot password và reset password:

Tạo file theo đường dẫn app/views/auth/passwords/email.ejs

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Forgot password</title>
    <link rel="stylesheet" href="/css/main.css">
</head>
<body>
    <div class="login-form">
        <h1>Forgot Form</h1>
        <form action="/password/email" method="POST">
            <input type="text" name="email" placeholder="Email" 
                <% if (typeof email != "undefined" && email) { %> value="<%= email %>"<% } %>  required>
            <input type="submit">
        </form>
    </div>
</body>
</html>

File còn lại app/views/auth/passwords/reset.ejs

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Reset Password</title>
    <link rel="stylesheet" href="/css/main.css">
</head>
<body>
    <div class="login-form">
        <h1>Reset Form</h1>
        <form action="/password/reset" method="POST">
            <input type="hidden" value="<%= token %>" name="token">
            <input type="text" name="email" placeholder="Email" value="<%= email %>" required readonly>
            <input type="password" name="password" placeholder="Enter your password" required>
            <input type="submit">
        </form>
    </div>
</body>
</html>

Kết luận

Một số chức năng cơ bản để các bạn hiểu được cách nó làm việc, còn muốn kĩ hơn thì các bạn có thể trao đổi thêm với mình, hoặc tìm hiểu những trang khác về các xây dựng authentication, hoặc tìm hiểu package nào đó. Nhưng thử code để hiểu nó như thế nào rồi hẵn sử dụng package nhang.

Mình không khuyến khích các bạn code tay toàn bộ các chức năng, không sử dụng các module mà chưa thành thạo cũng như hiểu về nó.

Nguồn tham khảo:

Rất mong được sự ủng hộ của mọi người để mình có động lực ra những bài viết tiếp theo.
{\__/}
( ~.~ )
/ > ♥️ I LOVE YOU 3000

JUST DO IT!


Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Nguyễn Quang Đạt !
Comments
  TOC