如何使用Vue和Node构建轻量级发票应用程序:JWT身份验证和发送发票

来自菜鸟教程
跳转至:导航、​搜索

介绍

在本系列的前几部分中,我们研究了如何创建发票应用程序的用户界面,该用户界面允许用户创建和查看现有发票。 在本系列的最后一部分中,您将在客户端上设置持久用户会话并为发票配置单个视图。

先决条件

要充分遵循本文,您需要以下内容:

  • 节点安装在您的机器上。
  • NPM 安装在你的机器上。
  • 已阅读本系列的 firstsecond 部分。

要确认您的安装,请运行以下命令:

node --version
npm --version

如果你得到他们的版本号作为结果,那么你很高兴。

第 1 步 — 使用 JWToken 在客户端上保持用户会话

为了验证我们的应用程序是安全的并且只有授权用户可以发出请求,我们将使用 JWTokens。 JWTokens 或 JSON Web 令牌由三部分字符串组成,其中包含请求的标头、有效负载和签名。 它的核心思想是为每个经过身份验证的用户创建一个令牌,以便在向后端服务器执行请求时使用。

要开始,请切换到 invoicing-app 目录。 之后,安装 jsonwebtoken 节点模块,该模块将用于创建和验证我们的 JSON Web 令牌:

cd invoicing-app 
npm install jsonwebtoken nodemon --save

nodemon 是一个节点模块,一旦发生文件更改,它会重新启动服务器。

现在,通过添加以下内容来更新 server.js 文件:

服务器.js

    
    // import node modules
    [...]
    const jwt = require("jsonwebtoken");
    
    // create express app
    [...]
    app.set('appSecret', 'secretforinvoicingapp'); // this will be used later

接下来要做的是调整 /register/login 路由以创建令牌并在用户成功注册或登录后将它们传回。 为此,请将以下内容添加到您的 server.js 文件中:

服务器.js

    // edit the /register route
    app.post("/register", multipartMiddleware, function(req, res) {
      // check to make sure none of the fields are empty
      [...]
      bcrypt.hash(req.body.password, saltRounds, function(err, hash) {
        // create sql query 
        [...]
        db.run(sql, function(err) {
          if (err) {
            throw err;
          } else {
            let user_id = this.lastID;
            let query = `SELECT * FROM users WHERE id='${user_id}'`;
            db.all(query, [], (err, rows) => {
              if (err) {
                throw err;
              }
              let user = rows[0];
              delete user.password;
              //  create payload for JWT
              const payload = {
                user: user 
              }
              // create token
              let token = jwt.sign(payload, app.get("appSecret"), {
                expiresInMinutes: "24h" // expires in 24 hours
              });
              // send response back to client
              return res.json({
                status: true,
                token : token
              });
            });
          }
        });
        db.close();
      });
    });
    
    [...]

/login 路线执行相同的操作:

服务器.js

    app.post("/login", multipartMiddleware, function(req, res) {
      //  connect to db 
      [...]
      db.all(sql, [], (err, rows) => {
        // attempt to authenticate the user
        [...]
        if (authenticated) {
          //  create payload for JWT
          const payload = { user: user };
          // create token
          let token = jwt.sign( payload, app.get("appSecret"),{
            expiresIn: "24h" // expires in 24 hours
          });
          return res.json({
            status: true,
            token: token
          });
        }
        
        return res.json({
          status: false,
          message: "Wrong Password, please retry"
        });
      });
    });

既然这已经完成了,接下来要做的就是测试它。 使用以下命令运行您的服务器:

nodemon server.js

您的应用现在将在成功登录和注册时创建令牌。 下一步是验证传入请求的令牌。 为此,请在要保护的路由上方添加以下中间件:

服务器.js

    
    [...]
    // unprotected routes
    
    [...]
    // Create middleware for protecting routes
    app.use(function(req, res, next) {
      // check header or url parameters or post parameters for token
      let token =
        req.body.token || req.query.token || req.headers["x-access-token"];
      // decode token
      if (token) {
        // verifies secret and checks exp
        jwt.verify(token, app.get("appSecret"), function(err, decoded) {
          if (err) {
            return res.json({
              success: false,
              message: "Failed to authenticate token."
            });
          } else {
            // if everything is good, save to request for use in other routes
            req.decoded = decoded;
            next();
          }
        });
      } else {
        // if there is no token
        // return an error
        return res.status(403).send({
          success: false,
          message: "No token provided."
        });
      }
    });
    
    // protected routes 
    [...]

SignUp.vue 文件中,您需要将从服务器获取的令牌和用户数据存储在 localStorage 中,以便当用户使用您的应用程序时,它可以跨不同页面持久存在。 为此,请将 frontend/src/components/SignUp.vue 文件的 loginregister 方法更新为如下所示:

前端/src/components/SignUp.vue

    [...]
    export default {
      name: "SignUp",
      [...]
      methods:{
        register(){
          const formData = new FormData();
          let valid = this.validate();
          if(valid){
            // prepare formData
            [...]
            // Post to server
            axios.post("http://localhost:3128/register", formData)
            .then(res => {
              // Post a status message
              this.loading = "";
              if (res.data.status == true) {
                // store the user token and user data in localStorage
                localStorage.setItem('token', res.data.token);
                localStorage.setItem('user', JSON.stringify(res.data.user));
                // now send the user to the next route
                this.$router.push({
                  name: "Dashboard",
                });
              } else {
                this.status = res.data.message;
              }
            });
          }
          else{
            alert("Passwords do not match");
          }
        }
        [...]

让我们也更新登录方法:

前端/src/components/SignUp.vue

        login() {
          const formData = new FormData();
          formData.append("email", this.model.email);
          formData.append("password", this.model.password);
          this.loading = "Signing in";
          // Post to server
          axios.post("http://localhost:3128/login", formData).then(res => {
            // Post a status message
            console.log(res);
            this.loading = "";
            if (res.data.status == true) {
              // store the data in localStorage
              localStorage.setItem("token", res.data.token);
              localStorage.setItem("user", JSON.stringify(res.data.user));
              // now send the user to the next route
              this.$router.push({
                name: "Dashboard"
              });
            } else {
              this.status = res.data.message;
            }
          });

以前,用户数据是使用路由参数传递的,但现在应用程序从本地存储中获取数据。 让我们看看这如何改变我们的组件。

Dashboard 组件以前看起来像这样:

前端/src/components/Dashboard.vue

    
    <script>
    import Header from "./Header";
    import CreateInvoice from "./CreateInvoice";
    import ViewInvoices from "./ViewInvoices";
    export default {
      name: "Dashboard",
      components: {
        Header,
        CreateInvoice,
        ViewInvoices,
      },
      data() {
        return {
          isactive: 'create',
          title: "Invoicing App",
          user : (this.$route.params.user) ? this.$route.params.user : null
        };
      }
    };
    </script>

这意味着当用户登录或注册时,他们会被重定向到 Dashboard 页面,然后 Dashboard 组件的 user 属性会相应更新。 如果用户决定刷新页面,将无法识别用户,因为 this.$route.params.user 不再存在。

编辑您的 Dashboard 组件以现在使用浏览器的 localStorage

前端/src/components/Dashboard.vue

    
    import Header from "./Header";
    import CreateInvoice from "./CreateInvoice";
    import ViewInvoices from "./ViewInvoices";
    export default {
      name: "Dashboard",
      components: {
        Header,
        CreateInvoice,
        ViewInvoices,
      },
      data() {
        return {
          isactive: 'create',
          title: "Invoicing App",
          user : null,
        };
      },
      mounted(){
        this.user = JSON.parse(localStorage.getItem("user"));
      }
    };

现在用户数据将在刷新页面后保留。 发出请求时,您还必须将令牌添加到请求中。

看一下 ViewInvoices 组件。 组件的 JavaScript 如下所示:

前端/src/components/ViewInvoices.vue

    <script>
    import axios from "axios";
    export default {
      name: "ViewInvoices",
      components: {},
      data() {
        return {
          invoices: [],
\          user: '',
        };
      },
      mounted() {
        this.user = JSON.parse(localStorage.getItem('user'));
        axios
          .get(`http://localhost:3128/invoice/user/${this.user.id}`)
          .then(res => {
            if (res.data.status == true) {
              console.log(res.data.invoices);
              this.invoices = res.data.invoices;
            }
          });
      }
    };
    </script>

如果您当前尝试查看已登录用户的发票,则在检索发票时会因缺少令牌而出错。

这是因为应用程序的 invoice/user/:user_id 路由现在受到您之前设置的令牌中间件的保护。 将其添加到请求以修复此错误:

前端/src/components/ViewInvoices.vue

    <script>
    import axios from "axios";
    export default {
      name: "ViewInvoices",
      components: {},
      data() {
        return {
          invoices: [],
          user: '',
        };
      },
      mounted() {
        this.user = JSON.parse(localStorage.getItem('user'));
        axios
          .get(`http://localhost:3128/invoice/user/${this.user.id}`,
            {
              headers: {"x-access-token": localStorage.getItem("token")}
            }
          )
          .then(res => {
            if (res.data.status == true) {
              console.log(res.data.invoices);
              this.invoices = res.data.invoices;
            }
          });
      }
    };
    </script>

当您保存并返回浏览器时,您现在可以成功获取发票:

第 2 步 — 为发票创建单一视图

单击 TO INVOICE 按钮时,没有任何反应。 要解决此问题,请创建一个 SingleInvoice.vue 文件并进行如下编辑:

    <template>
      <div class="single-page">
        <Header v-bind:user="user"/>
        <!--  display invoice data -->
        <div class="invoice">
          <!-- display invoice name here -->
          <div class="container">
            <div class="row">
                <div class="col-md-12">
                  <h3>Invoice #{{ invoice.id }} by {{ user.company_name }}</h3>
                  <table class="table">
                    <thead>
                      <tr>
                        <th scope="col">#</th>
                        <th scope="col">Transaction Name</th>
                        <th scope="col">Price ($)</th>
                      </tr>
                    </thead>
                    <tbody>
                      <template v-for="txn in transactions">
                        <tr :key="txn.id">
                          <th>{{ txn.id }}</th>
                          <td>{{ txn.name }}</td>
                          <td>{{ txn.price }} </td>
                        </tr>
                      </template>
                    </tbody>
                    <tfoot>
                      <td></td>
                      <td style="text-align: right">Total :</td>
                      <td><strong>$ {{ total_price }}</strong></td>
                    </tfoot>
                  </table>
                </div>
              </div>
            </div>
          </div>
      </div>
    </template>

v-for 指令用于允许您循环遍历特定发票的所有提取交易。

组件结构如下图所示。 您首先导入必要的模块和组件。 当组件为 mounted 时,使用 axios后端 服务器发出 POST 请求以获取数据。 获得响应后,我们将它们分配给各自的组件属性。

    <script>
    import Header from "./Header";
    import axios from "axios";
    export default {
      name: "SingleInvoice",
      components: {
        Header
      },
      data() {
        return {
          invoice: {},
          transactions: [],
          user: "",
          total_price: 0
        };
      },
      methods: {
        send() {}
      },
      mounted() {
        // make request to fetch invoice data
        this.user = JSON.parse(localStorage.getItem("user"));
        let token = localStorage.getItem("token");
        let invoice_id = this.$route.params.invoice_id;
        axios
          .get(`http://localhost:3128/invoice/user/${this.user.id}/${invoice_id}`, {
            headers: {
              "x-access-token": token
            }
          })
          .then(res => {
            if (res.data.status == true) {
              this.transactions = res.data.transactions;
              this.invoice = res.data.invoice;
              let total = 0;
              this.transactions.forEach(element => {
                total += parseInt(element.price);
              });
              this.total_price = total;
            }
          });
      }
    };
    </script>

注意:有一个send()方法目前为空。 随着您继续阅读本文,您将更好地理解为什么以及如何添加必要的功能。


该组件具有以下作用域样式:

前端/src/components/SingleInvoice.vue

    <!-- Add "scoped" attribute to limit CSS to this component only -->
    <style scoped>
    h1,
    h2 {
      font-weight: normal;
    }
    ul {
      list-style-type: none;
      padding: 0;
    }
    li {
      display: inline-block;
      margin: 0 10px;
    }
    a {
      color: #426cb9;
    }
    .single-page {
      background-color: #ffffffe5;
    }
    .invoice{
      margin-top: 20px;
    }
    </style>

现在,如果您返回应用程序并单击 View Invoices 选项卡中的 TO INVOICE 按钮,您将看到单个发票视图。

第 3 步 — 通过电子邮件发送发票

这是发票应用程序的最后一步,是允许您的用户发送发票。 在此步骤中,您将使用 nodemailer 模块向后端服务器上的指定收件人发送电子邮件。 要开始,首先安装模块:

npm install nodemailer

现在模块已安装,更新 server.js 文件如下:

服务器.js

    // import node modules
    [...]
    let nodemailer = require('nodemailer')
    
    // create mail transporter
    let transporter = nodemailer.createTransport({
      service: 'gmail',
      auth: {
        user: 'COMPANYEMAIL@gmail.com',
        pass: 'userpass'
      }
    });
    
    // create express app
    [...]

此电子邮件将在后端服务器上设置,并将作为代表用户发送电子邮件的帐户。 此外,您需要在安全设置 中暂时允许您的 Gmail 帐户进行非安全登录以进行测试

服务器.js

    // configure app routes
    [...]
    app.post("/sendmail", multipartMiddleware, function(req, res) {
      // get name  and email of sender
      let sender = JSON.parse(req.body.user);
      let recipient = JSON.parse(req.body.recipient);
      let mailOptions = {
        from: "COMPANYEMAIL@gmail.com",
        to: recipient.email,
        subject: `Hi, ${recipient.name}. Here's an Invoice from ${
          sender.company_name
        }`,
        text: `You owe ${sender.company_name}`
      };
      transporter.sendMail(mailOptions, function(error, info) {
        if (error) {
          return res.json({
            status: 200,
            message: `Error sending main to ${recipient.name}`
          });
        } else {
          return res.json({
            status: 200,
            message: `Email sent to ${recipient.name}`
          });
        }
      });
    });

此时,您已将电子邮件配置为在向 /sendmail 路由发出 POST 请求时工作。 您还需要允许用户在前端执行此操作并给他们一个表单以输入收件人的电子邮件地址。 为此,请执行以下操作来更新 SingleInvoice 组件:

前端/src/components/SingleInvoice.vue

    
    <template>
     <Header v-bind:user="user"/>
        <!--  display invoice data -->
        <div class="invoice">
          <!-- display invoice name here -->
          <div class="container">
            <div class="row">
              <div class="col-md-12">
                // display invoice
              </div>
            </div>
            <div class="row">
              <form @submit.prevent="send" class="col-md-12">
                <h3>Enter Recipient's Name and Email to Send Invoice</h3>
                <div class="form-group">
                  <label for="">Recipient Name</label>
                  <input type="text" required class="form-control" placeholder="eg Chris" v-model="recipient.name">
                </div>
                <div class="form-group">
                  <label for="">Recipient Email</label>
                  <input type="email" required placeholder="eg chris@invoiceapp.com" class="form-control" v-model="recipient.email">
                </div>
                <div class="form-group">
                    <button class="btn btn-primary" >Send Invoice</button>
                    {{ loading }}
                    {{ status }}
                </div>
              </form>
            </div>
          </div>
        </div> 
    </template>

此外,组件属性更新如下:

前端/src/components/SingleInvoice.vue

    
    <script>
    import Header from "./Header";
    import axios from "axios";
    export default {
      name: "SingleInvoice",
      components: {
        Header
      },
      data() {
        return {
          invoice: {},
          transactions: [],
          user: '',
          total_price: 0,
          recipient : {
            name: '',
            email: ''
          },
          loading : '',
          status: '',
        };
      },
      methods: {
        send() {
          this.status = "";
          this.loading = "Sending Invoice, please wait....";
          const formData = new FormData();
          formData.append("user", JSON.stringify(this.user));
          formData.append("recipient", JSON.stringify(this.recipient));
          axios.post("http://localhost:3128/sendmail", formData, {
            headers: {"x-access-token": localStorage.getItem("token")}
          }).then(res => {
            this.loading = '';
            this.status = res.data.message
          }); 
        }
      },
      mounted() {
        // make request to fetch invoice data
      }
    };
    </script>

进行这些更改后,您的用户将能够输入收件人电子邮件并从应用程序接收“已发送发票”通知。

您可以通过查看 nodemailer 指南 来进一步编辑电子邮件。

结论

在本系列的这一部分中,我们研究了如何使用 JWTokens 和浏览器的本地存储来保持用户登录。 我们还为单个发票创建了视图。