Express

Express,高度包容、快速而极简的 Node.js Web 框架。通过本章节,你将了解到一下内容:

  • 使用 express-generator 快速搭建项目框架
  • 使用 nunjucks 作为视图模版
  • 使用 favicon.ico 设置图标
  • 使用 axios 请求第三方接口
  • 使用 cors 允许跨域请求
  • 使用 knex 增删改查数据库
  • 使用 Base 公共模型优化
  • 使用 session 完成 csrf 防御
  • 使用 cookie 完成登录权限控制

本章主要为使用 Express Web 来体验 Nodejs Web,更多依赖包及使用方法,数据库SQL的运用及后端安全知识,请自行查阅相关专业信息。

express-generator

express-generator 是 Express 应用程序生成器工具,我们可以使用它来快速创建应用程序框架。

  1. 全局安装 express-generator 项目构建工具,express-generator 会帮忙我们快速创建一个基于 express 的 web后端服务代码,已经完成了基础需要依赖及配置。
npm install -g express-generator
  1. 初始化项目,进入桌面使用 express appName,这时候桌面就创建 expressApp 文件夹,里面包含基本的项目文件
cd ~/Desktop && express expressApp && cd expressApp
  1. 下载相关依赖,我们打开 expressApp/package.json 会发现包含有以下依赖,可以点击查看依赖包的作用及使用方法:
npm install
  1. 启动项目
npm start

浏览器打开http://localhost:3000/,可以看到 Welcome to Express 啦!

  1. 目录结构分析

express-generator 帮助我们创建及配置好项目文件,主要有以下:

  • app.js 主文件
  • bin/www 启动入口文件
  • package.json 依赖包管理文件
  • public 静态资源目录
  • routes 路由目录
  • views 模版目录
.
├── app.js
├── bin
│   └── www
├── package.json
├── public
│   ├── images
│   ├── javascripts
│   └── stylesheets
│       └── style.css
├── routes
│   ├── index.js
│   └── users.js
└── views
    ├── error.pug
    ├── index.pug
    └── layout.pug

app.js

// 各个依赖包
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

// 路由文件引用
var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

// Express 引用实例化
var app = express();

// 视图模版设置
// 设置视图模版目录,设置视图模版后缀为 jade 文件
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// 使用 morgan 日志打印
app.use(logger('dev'));
// 使用对 Post 来的数据 json 格式化
app.use(express.json());
// 使用对 表单提交的数据 进行格式化
app.use(express.urlencoded({ extended: false }));
// 使用 cookie
app.use(cookieParser());
// 设置静态文件地址路径为 public
app.use(express.static(path.join(__dirname, 'public')));

// 使用配置好的路由
app.use('/', indexRouter);
app.use('/users', usersRouter);

// 捕捉404错误
app.use(function(req, res, next) {
  next(createError(404));
});

// 监听异常如果有,立刻返回异常
app.use(function(err, req, res, next) {
  // 设置错误信息
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // 渲染到模版
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

nunjucks

Nunjucks是Mozilla开发的一个纯JavaScript编写的模板引擎,既可以用在Node环境下,又可以运行在浏览器端。但是,主要还是运行在Node环境下,因为浏览器端有更好的模板解决方案,例如MVVM框架。

如果你使用过Python的模板引擎jinja2,那么使用Nunjucks就非常简单,两者的语法几乎是一模一样的,因为Nunjucks就是用JavaScript重新实现了jinjia2。

为什么要使用 nunjucks ?

在 express-generator 默认为我们配置了 jade 模版(现在改名字为 pug ),jade 模版语法如下:

extends layout

block content
  h1= title
  p Welcome to #{title}

语法简洁,但是不利于对完整的 HTML 代码进行复制,有时候我们很多代码是从别的地方拷贝服用过来,统一的模版结构可以减少我们转换的工作,因此我们切换为 nunjucks 模版,nunjucks 模版和 swig 模版都可以,但是他们之间还是有语法的不一致,建议专注使用一个 。nunjucks 的语法如下,可以在 nunjucks 中直接书写 HTML 结构:

{% block header %}
This is the default content
{% endblock %}

<section class="left">
  {% block left %}{% endblock %}
</section>

<section class="right">
  {% block right %}
  This is more content
  {% endblock %}
</section>

接下来我们使用 nunjucks 并修改模版:

  1. 安装 nunjucks 依赖
npm install --save nunjucks
  1. 参照使用文档修改配置信息

app.js

// 引入 nunjucks
var nunjucks = require('nunjucks');

...

// 视图模版设置
// 1. 设置视图模版后缀修改为 tpl 文件
// 2. 添加 nunjucks 在 express 中的自动配置
// 3. 注释 设置 views 代码,在 nunjucks 自动配置中有设置
// app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'tpl');
nunjucks.configure('views', {
  autoescape: true,
  express: app,
  watch: true
});
  1. 在 views 目录中添加 layout.tpl、index.tpl、error.tpl

layout.tpl

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>{{title}}</title>
    {% block css %}
    {% endblock %}
</head>
<body>
    {% block content %}
    {% endblock %}

    {% block js %}
    {% endblock %}
</body>
</html>

index.tpl

{% extends './layout.tpl' %}

{% block css %}
<link rel="stylesheet" href="/stylesheets/style.css">
{% endblock %}

{% block content %}
<h1>{{title}}</h1>
<p>Welcome to {{title}} with nunjucks!</p>
{% endblock %}

error.tpl

{% extends './layout.tpl' %}

{% block css %}
<link rel="stylesheet" href="/stylesheets/style.css">
{% endblock %}

{% block content %}
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
{% endblock %}
  1. 跑起来
npm start

favicon.ico

配置 favicon.ico 图标

  1. 下载serve-favicon 依赖
npm install -save serve-favicon
  1. favicon.ico文件放置到 public 目录下

  2. 配置app.js

// 引入 serve-favicon 依赖包
var favicon = require('serve-favicon');

...

// 设置 favicon.ico 地址
app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
  1. 可能缓存问题,关闭浏览器,重启服务多刷新几次就可以看到浏览器左上角的 favicon.ico 了

axios

Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。

本教材中因为使用 axios 作为网络请求,因为前端开发中逐渐的走向数据驱动视图化,慢慢的移除 jQuery框架,这时候 Ajax 之外的选择,axiso应运而生,不仅可以作为客户端网络请求,还可以作为服务端网络请求。

Node.js 中优秀的网络请求插件还有 request ,如果在某些条件下 axios 不能满足需求,我们可以考虑使用 request 。

  • axios
  • request(https://www.npmjs.com/package/request)

以下我们使用 axios 来实现一个 isbn 编码的信息查询接口,同时完善 MVC 的开发结构。

  1. 下载 axios
npm install -save axios
  1. 修改路由文件,把 routes/user.js 重命名为 routers/api.js

  2. 修改路由配置,因为我们是个接口地址,所以前缀应该为 /api ,而不是 /users/

app.js

// var usersRouter = require('./routes/users'); 修改为以下
var apiRouter = require('./routes/api');

// app.use('/users', usersRouter); 修改为以下
app.use('/api', apiRouter);
  1. 创建 models/douban.js ,豆瓣的数据管理Model

models/douban.js

// 引用 axios
const axios = require('axios');
// 定义豆瓣接口API
const ISBNAPI = 'https://api.douban.com/v2/book/isbn/';

const douban = {
  // 更具 isbn 查询内容的方法
  isbn:function(isbn){
    // 返回一个 Promise
    return new Promise((resolve,reject) => {
      // 发起请求
      axios.get(ISBNAPI + isbn + '?apikey=0df993c66c0c636e29ecbb5344252a4a')
        .then( res => {
          resolve(res.data)
        }).catch( err => {
          reject(err.response.data)
        })
    })
  }
}

module.exports = douban;
  1. 创建 controllers/book.js ,书的控制器

controllers/book.js

// 引用豆瓣Model
const doubanModel = require('./../models/douban');

const book = {
  // 书详情方法 async 方法
  info:async function(req,res,next){
    // 从 Get 请求中获取 isbn,如果为空,立即返回提示。
    const ISBN = req.query.isbn;
    if(!ISBN){
      res.json({ code: 0,msg: 'isbn empty!'})
      return
    }

    // 使用 try catch 捕获 reject 异常
    try{
      // 从豆瓣 Model 中获取数据并返回
      const data = await doubanModel.isbn(ISBN);
      res.json({ code: 200, data: data })
    }catch(err) {
      // 如果异常,返回错误信息
      res.json({ code: 0, data: err })
    }
  }
}

module.exports = book;
  1. 修改 routes/api.js 创建路由入口

routes/api.js

// 引用路由
var express = require('express');
var router = express.Router();
// 引用书控制器
var bookController = require('./../controllers/book');
// 设置路由地址
router.get('/isbn', bookController.info);

module.exports = router;
  1. 测试请求http://localhost:3000/api/isbn?isbn=9787121317989,查看返回数据。

CORS

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

上述 ISBN 编码查询的接口,如果我们再非同源的地址,例如在本地桌面新建一个文件然后使用 fetch 请求,会被浏览器禁止数据的返回。例如:

<!DOCTYPE html>
<html>
<head>
  <title>TEST</title>
</head>
<body>
  <script type="text/javascript">
    fetch('http://localhost:3000/api/isbn?isbn=9787121317989').then((response)=>{
      return response.json();
    }).then(res => {
      console.log(res)
    }).catch( err => {
      console.log(err)
    })
  </script>
</body>
</html>

会报以下错误:

Failed to load http://localhost:3000/api/isbn?isbn=9787121317989: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'null' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
  1. 新建 cors 中间件

middlewares/cors.js

const cors = {
  allowAll:function(req,res,next){
    res.header("Access-Control-Allow-Headers", "*");
    res.header("Access-Control-Allow-Origin", req.headers.origin);
    res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
    res.header('Access-Control-Allow-Credentials', true);
    next();
  }
}

module.exports = cors;
  1. 在ISBN路由中引用
var express = require('express');
var router = express.Router();
var cors = require('./../middlewares/cors.js');
var bookController = require('./../controllers/book');

router.get('/isbn', cors.allowAll, bookController.info);

module.exports = router;
  1. 再次使用本地文件发起 fetch 请求测试,这时候就可以正常 console 到数据了。

knex

使用 knex 增删改查数据库

Knex.js是为Postgres,MSSQL,MySQL,MariaDB,SQLite3,Oracle和Amazon Redshift设计的“包含电池”SQL查询构建器,其设计灵活,便于携带并且使用起来非常有趣。它具有传统的节点样式回调以及用于清洁异步流控制的承诺接口,流接口,全功能查询和模式构建器,事务支持(带保存点),连接池 以及不同查询客户和方言之间的标准化响应。Knex的主要目标环境是Node.js,您需要安装该knex库,然后安装适当的数据库库:pg适用于PostgreSQL和Amazon Redshift,mysql适用于MySQL或MariaDB,sqlite3适用于SQLite3或mysql适用于MSSQL。

以下我们使用 knex 使用 mysql 链接操作数据库,使用前请确保安装以下工具:

  • XAMPP 集成环境,用于启动数据库
  • navicat - 数据库管理工具 - windows 用户安装
  • Sequel Pro - 数据库管理工具 - macOS 用户安装

XMAPP 默认用户名为:root , 密码为空 , host 为 127.0.0.1 。请确保 XAMPP 启动,然后使用数据库管理工具连接成功,接着进行以下步骤:

注意:在 Mac 的新版本XAMPP中,host 未必为 127.0.0.1,可能是 192.168.64.2 ,需要按提示修改文件获取权限,同时设置新的用户和密码。

  1. 使用数据库管理工具新建数据库 database 名称为: expressapp 。

  2. 进入 expressApp 库,新建用户信息表为 users ,默认有 id 字段,需要再添加以下必要字段:

  • Field:id
  • Field:name Type:VARCHAR LENGTH:255 Comment:姓名
  • Field:email Type:VARCHAR LENGTH:255 Comment:邮箱
  • Field:password Type:VARCHAR LENGTH:255 Comment:密码
  1. 手动设置几个默认值,例如:
  • name:Jay email:jay@qq.com password:123456
  • name:Jeo email:jay@qq.com password:123456
  • name:Jax email:jay@qq.com password:123456
  1. 下载项目相关依赖
npm install -save knex mysql
  1. 在项目根目录下,新建配置信息 config.js,之后的配置信息涉及到数据库和密码,不上传到 Github 等托管平台,所以需要单独设置,之后使用 .gitignore 避免上传。之后敏感的配置信息,都将在此配置。host 地址为数据库的服务地址,本地为 127.0.0.1 ,也可能在虚拟机上 192.168.64.2 ,以 XAMPP 开放的地址为准。

config.js

const configs = {
  mysql: {
    host: '127.0.0.1',
    port: '3306',
    user: 'root',
    password: '',
    database: 'expressapp'
  }
}

module.exports = configs
  1. 在项目根目录下,新建 .gitignore 避免上传 config.jsnode_modules 等不需要被上传到 Github 的文件。

gitignore

.DS_Store
.idea
npm-debug.log
yarn-error.log
node_modules
config.js
  1. 新建 models/knex.js 数据库配置,初始化配置 knex
// 引用配置文件
const configs = require('../config');
// 把配置文件中的信息,设置在初始化配置中
module.exports = require('knex')({
  client: 'mysql',
  connection: {
    host: configs.mysql.host,
    port: configs.mysql.port,
    user: configs.mysql.user,
    password: configs.mysql.password,
    database: configs.mysql.database
  }
})
  1. 新建 models/user.js 用户模型,并设置获取所有用户的方法
// 引用 knex
const knex = require('./../models/knex');
// 定义数据库表信息
const TABLE = 'users';

const User = {
  // 获取所有用户的方法
  all: function(){
    // 返回 Promise
    return knex.select().table(TABLE)
  }
}

module.exports = User
  1. 新建视图文件 views/user/show.tpl ,用于显示用户控制的页面,视图中循环语法,请参考 nunjucs 文档。

views/user/show.tpl

{% extends './../layout.tpl' %}

{% block css %}
<link rel="stylesheet" href="/stylesheets/style.css">
{% endblock %}

{% block content %}

<div class="page">
  <h1>用户管理</h1>
  <div class="new-user">
    <h2>新建用户</h2>
    <input type="text" name="name" id="new-name" placeholder="请输入用户名">
    <input type="email" name="email" id="new-email" placeholder="请输入邮箱账号">
    <input type="password" name="password" id="new-password" placeholder="请输入密码">
    <button id="new-submit">新建用户</button>
  </div>
  <div class="user-list">
    <h2>用户列表</h2>
    <ul>
      {% for val in users  %}
      <li>
        <span>id: {{val.id}}</span>
        <span>email: {{val.email}}</span>
        <input class="user-name" type="text" name="name" placeholder="姓名" value="{{val.name}}">
        <button class="update-user" data-id="{{val.id}}">修改姓名</button>
        <button class="delete-user" data-id="{{val.id}}">删除用户</button>
      </li>
      {% endfor %}
    </ul>
  </div>
</div>
{% endblock %}
  1. 新建 controllers/user.js 用户控制器,并设置 show 方法
// 引用用户模版数据
const User = require('./../models/user.js');

const userController = {
  // show 获取用户数据并返回到页面
  show: async function(req,res,next){
    try{
      // 从模型中获取所有用户数据
      const users = await User.all();
      // 把用户数据设置到 res.locals 中
      res.locals.users = users;
      // 渲染到 views 视图目录的 user/show.tpl 路径中。
      res.render('user/show.tpl',res.locals)
    }catch(e){
      res.locals.error = e;
      res.render('error',res.locals)
    }
  },
}

module.exports = userController;
  1. 新建路由,修改 routes/index.js
var express = require('express');
var router = express.Router();
var userController = require('./../controllers/user.js');


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

router.get('/user', userController.show);

module.exports = router;
  1. 重启服务,打开http://localhost:3000/user 你将看到所有用户的信息。

knex 增删改

  1. 修改用户模型 Models/user.js , 添加操作数据库的方法,添加增、删、改方法。
const knex = require('./../models/knex');
const TABLE = 'users';

const User = {
  all: function(){
    return knex(TABLE).select()
  },
  // 添加用户
  insert: function(params){
    return knex(TABLE).insert(params)
  },
  // 修改用户
  update: function(id, params ){
    return knex(TABLE).where('id', '=', id).update( params )
  },
  // 删除用户
  delete: function(id){
    return knex(TABLE).where('id', '=', id).del()
  }
}

module.exports = User
  1. 修改用户控制器 controllers/user.js,添加增删改查接受参数及输出的判断逻辑

controllers/user.js

const User = require('./../models/user.js');

const userController = {
  show: async function(req,res,next){
    try{
      const users = await User.all();
      res.locals.users = users;
      res.render('user/show.tpl',res.locals)
    }catch(e){
      res.locals.error = e;
      res.render('error',res.locals);
    }
  },
  // 新增用户
  insert: async function(req,res,next){

    let name = req.body.name;
    let email = req.body.email;
    let password = req.body.password;
    console.log(name,email,password);

    if(!name || !email || !password){
      res.json({ code: 0, data: 'params empty!' });
      return
    }

    try{
      const users = await User.insert({name,email,password});
      res.json({ code: 200, data: users})
    }catch(e){
      res.json({ code: 0, data: e })
    }
  },
  update: async function(req,res,next){
    let id = req.body.id;
    let name = req.body.name;
    console.log(id,name);

    if(!name || !id){
      res.json({ code: 0, data: 'params empty!' });
      return
    }

    try{
      const user = await User.update(id,{ name });
      res.json({ code: 200, data: user})
    }catch(e){
      res.json({ code: 0, data: e })
    }
  },
  delete: async function(req,res,next){
    let id = req.body.id;
    if(!id){
      res.json({ code: 0, data: 'params empty!' });
      return
    }

    try{
      const user = await User.delete(id);
      res.json({ code: 200, data: user})
    }catch(e){
      res.json({ code: 0, data: e })
    }
  }
}

module.exports = userController;
  1. 修改路由 routes/api.js,添加用户增删改的接口。

routes/api.js

var express = require('express');
var router = express.Router();
var cors = require('./../middlewares/cors.js');
var bookController = require('./../controllers/book');
var userController = require('./../controllers/user');

/* GET users listing. */
router.get('/isbn', cors.allowAll,bookController.info);

// 同一个接口地址,但是不同的请求方法
router.post('/user' , userController.insert);
router.put('/user' , userController.update);
router.delete('/user' , userController.delete);

module.exports = router;
  1. 在视图中添加脚本逻辑,views/user/show.tpl 在底部添加以下脚本代码。
  • 引用jQuery
  • 绑定新建用户方法
  • 绑定修改用户方法
  • 绑定删除用户方法

views/user/show.tpl

{% block js %}
<script src="https://lib.baomitu.com/jquery/3.3.1/jquery.min.js"></script>
<script type="text/javascript">
  const indexPage = {
    init:function(){
      this.bind();
    },
    bind:function(){
      $('#new-submit').on('click',this.newUser);
      $('.update-user').on('click',this.update);
      $('.delete-user').on('click',this.delete);
    },
    delete: function(){
      let id = $(this).data('id');
      
      $.ajax({
          url: '/api/user',
          data: { id },
          type: 'DELETE',
          success: function(data) {
            if(data.code === 200){
              alert('删除成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    },
    update:function(){
      let id = $(this).data('id');
      let name = $(this).parent().find('.user-name').val();

      if(!name || !id){
        alert('缺少参数')
        return
      }

      $.ajax({
          url: '/api/user',
          data: { name, id },
          type: 'PUT',
          success: function(data) {
            if(data.code === 200){
              alert('修改成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    },
    newUser:function(){
      let name = $('#new-name').val();
      let email = $('#new-email').val();
      let password = $('#new-password').val();

      if(!name || !email || !password){
        alert('缺少参数')
        return
      }

      $.ajax({
          url: '/api/user',
          data: { name, email, password },
          type: 'POST',
          success: function(data) {
            if(data.code === 200){
              alert('新增成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    }
  }
  $(function(){
    indexPage.init();
  })
</script>
{% endblock %}

BaseModel

在完成上一章节的增删改查中,我们会发现不同表中使用 knex 进行增删改查的方法都将差不多,不同的一点只是 table 不一样。目前只是用户表,如果出现其他新闻表、分类表等更多相关的表信息进行查询时,每次都新建一个相关的 model 再构建差不多的方法,这样代码会显得很冗余。

因此我们抽离出一层基础模型,基础模型拥有各个模型可以共有的简单方法,增删改查。只要相关的模型继承基础模型,即可使用其方法。如果相关方法需要使用除了公共的方法之外的方法,例如多个表联合查询,这个时候可以在相关的模版中引用 knex 再配置其特有的方法即可。

  1. 新建基础模型 models/base.js ,除了 all、insert、update、delete ,方法,我们再新增一个 select 查询方法便于之后查询使用。

models/base.js

const knex = require('./knex');

class Base {
  constructor(props) {
    this.table = props;
  }

  all(){
    return knex(this.table).select()
  }

  select(params) {
    return knex(this.table).select().where(params)
  }

  insert(params){
    return knex(this.table).insert( params )
  }

  update(id, params ){
    return knex(this.table).where('id', '=', id).update( params )
  }

  delete(id){
    return knex(this.table).where('id', '=', id).del()
  }
}

module.exports = Base;
  1. 修改用户模型 models/user.js ,使其继承基础模型,并设置其 table 表格名称。

models/user.js

// 引用基础模型
const Base = require('./base.js');

// 定义用户模型并基础基础模型
class User extends Base {
  // 定义参数默认值为 users 表
  constructor(props = 'users') {
    super(props);
  }
}

module.exports = User
  1. 修改用户控制器 controller/user.js,修改后的用户模型是一个 class 类而不是对象,因此需要配合 new 来使用。

controller/user.js

const UserModel = require('./../models/user.js');
const User = new UserModel();

CSRF

CSRF(Cross-site request forgery),中文名称:跨站请求伪造,也被称为:one click attack/session riding,缩写为:CSRF/XSRF,通过伪装来自受信任用户的请求来利用受信任的网站。

我们针对 CSRF 通过伪造 cookie 的方式,通过在 session 中设置token 对比来防御,主要有一下步骤:

  • 在有表单的页面中,生成一段随机的 token ,渲染到页面中,也存储在 session 中。
  • 当提交请求时候,除了对 cookie 进行校验以外,增加对 token 的校验。
  • 如果请求中带有 csrf 字段,且和 session 中的 token 值匹配,则鉴权成功。
  1. 下载 express-session
npm install express-session --save
  1. app.js 中使用
var session      = require('express-session');

···

// Use the session middleware
app.use(session({
  secret: 'JaxChu',
  resave: true,
  saveUninitialized: true,
}))
  1. 新建 CSRF 中间件 middlewares/csrf.js

middlewares/csrf.js

// 使用 Nodejs 中的加密模块
const crypto = require('crypto');

const CSRF = {
  // 生成随机数
  generateRandom:function(len){
    return crypto.randomBytes(Math.ceil(len*3/4)).toString('base64').slice(0,len);
  },
  // 设置token
  getToken:function(req,res,next){
    // 获取当前 session 的 csrf_token。
    var token = req.session._csrf;
    // 获取请求过来的 csrf 参数的 token
    var _csrf = req.query.csrf ? req.query.csrf : (req.query.csrf = req.body.csrf);
    // 将两个 token 进行比较
    // 如果不相等,鉴权失败,禁止下一步。
    // 相等就可以下一步走接下来的业务逻辑操作。
    if(_csrf !== token){
      res.writeHead(403);
      res.end("禁止访问");
    }else{
      next()
    }
  },
  setToken:function(req,res,next){
    // 获取当前 session 是否有 csrf_token
    // 如果是没有关闭浏览器多次刷新的话,就有,使用原来的
    // 如果是新打开浏览器第一次请求的页面,没有就新建一个
    var token = req.session._csrf || (req.session._csrf = CSRF.generateRandom(24));
    // 把token设置在本地数据中,页面获取本地数据渲染到页面
    res.locals.csrf = token;
    next();
  }
}

module.exports = CSRF;
  1. 修改路由

routes/index.js

var express = require('express');
var router = express.Router();
var userController = require('./../controllers/user.js');

// 引入 CSRF 中间件
var csrf = require('./../middlewares/csrf.js');

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

// 使用 CSRF 中间件,往页面设置 token
router.get('/user', csrf.setToken, userController.show);

module.exports = router;

routes/api.js

var express = require('express');
var router = express.Router();
var cors = require('./../middlewares/cors.js');
var csrf = require('./../middlewares/csrf.js');
var bookController = require('./../controllers/book');
var userController = require('./../controllers/user');

/* GET users listing. */
router.get('/isbn', cors.allowAll,bookController.info);

// 使用使用 CSRF 中间件,检查token
router.post('/user' ,csrf.getToken, userController.insert);
router.put('/user' ,csrf.getToken, userController.update);
router.delete('/user' ,csrf.getToken, userController.delete);

module.exports = router;
  1. 修改视图模版 views/user/show.tpl

在HTML中添加一个input的隐藏元素,其value为csrf的token,然后ajax提交之前获取token的值,在参数中一起提交

{% extends './../layout.tpl' %}

{% block css %}
<link rel="stylesheet" href="/stylesheets/style.css">
{% endblock %}

{% block content %}
<div class="page">
  <h1>用户管理</h1>
  <div class="new-user">
    <h2>新建用户</h2>
    <input type="text" name="name" id="new-name" placeholder="请输入用户名">
    <input type="email" name="email" id="new-email" placeholder="请输入邮箱账号">
    <input type="password" name="password" id="new-password" placeholder="请输入密码">
    <button id="new-submit">新建用户</button>
  </div>
  <div class="user-list">
    <h2>用户列表</h2>
    <ul>
      {% for val in users  %}
      <li>
        <span>id: {{val.id}}</span>
        <span>email: {{val.email}}</span>
        <input class="user-name" type="text" name="name" placeholder="姓名" value="{{val.name}}">
        <button class="update-user" data-id="{{val.id}}">修改姓名</button>
        <button class="delete-user" data-id="{{val.id}}">删除用户</button>
      </li>
      {% endfor %}
    </ul>
  </div>
  <!-- csrf token -->
  <div class="csrf-contianer">
    <input type="text" name="csrf" id="csrf" value="{{csrf}}" hidden>
  </div>
</div>
{% endblock %}

{% block js %}
<script src="https://lib.baomitu.com/jquery/3.3.1/jquery.min.js"></script>
<script type="text/javascript">
  const indexPage = {
    init:function(){
      this.bind();
    },
    bind:function(){
      $('#new-submit').on('click',this.newUser);
      $('.update-user').on('click',this.update);
      $('.delete-user').on('click',this.delete);
    },
    delete: function(){
      let id = $(this).data('id');
      let csrf = $('#csrf').val();
      $.ajax({
          url: '/api/user',
          data: { id,csrf },
          type: 'DELETE',
          success: function(data) {
            if(data.code === 200){
              alert('删除成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    },
    update:function(){
      let id = $(this).data('id');
      let name = $(this).parent().find('.user-name').val();
      let csrf = $('#csrf').val();

      if(!name || !id){
        alert('缺少参数')
        return
      }

      $.ajax({
          url: '/api/user',
          data: { name, id, csrf },
          type: 'PUT',
          success: function(data) {
            if(data.code === 200){
              alert('修改成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    },
    newUser:function(){
      let name = $('#new-name').val();
      let email = $('#new-email').val();
      let password = $('#new-password').val();
      let csrf = $('#csrf').val();

      if(!name || !email || !password){
        alert('缺少参数')
        return
      }

      $.ajax({
          url: '/api/user',
          data: { name, email, password, csrf },
          type: 'POST',
          success: function(data) {
            if(data.code === 200){
              alert('新增成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    }
  }
  $(function(){
    indexPage.init();
  })
</script>
{% endblock %}

使用 cookie 完成登录权限控制,在登录页面设置登录接口,如果登录成功,为页面设置加密的 cookie。设置页面过滤组件,当每次有请求过来都会走鉴定组件,鉴定组件根据 cookie 的值判断用户的登录状态及身份。

我们需要做以下几件事情:

  • cookie 的加密算法
  • 登录接口,登录成功设置 cookie
  • 用户信息过滤组件,每个用户进来更具 cookie 鉴定身份
  • 登录页面,如果登录的话重新定向到首页
  • 用户管理页面,如果没有登录的话重定向到登录页面

以下我们逐步完成上述需求:

  1. 新建算法文件 utils/authCode.js,用于 cookie 的加密和解密。注意:算法文件为敏感文件,请不要上传到托管服务器,需要**.gitignore**。不然被外部发现开源的核心算法代码就会破解用户的 cookie 获取帐号密码。

utils/authCode.js

/**
 * 加密解密
 */

const crypto = require('crypto');
const key = Buffer.from('aitschool!@#$123', 'utf8');
const iv = Buffer.from('FnJL7EDzjqWjcaY9', 'utf8');

const authcode = function (str, operation){
    operation ? operation : 'DECODE';
    if (operation == 'DECODE') {
        let src = '';
        let cipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
        src += cipher.update(str, 'hex', 'utf8');
        src += cipher.final('utf8');
        return src;
    }else {
        let sign = '';
        let cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
        sign += cipher.update(str, 'utf8', 'hex');
        sign += cipher.final('hex');
        return sign;
    }
}

module.exports = authcode;
  1. 定义登录接口路由 routes/api.js , 走一层 csrf ,再走权限控制器。定义登录页面路由 routes/index.js ,走一层设置 csrf ,在渲染视图模版。

routes/api.js

var authController = require('./../controllers/auth.js');

...

router.post('/login' ,csrf.getToken, authController.login);

routes/index.js

var authController = require('./../controllers/auth.js');

...

router.get('/login', csrf.setToken, authController.renderLogin);
  1. 新建并定义权限控制器,controllers/auth.js

controllers/auth.js

const UserModel = require('./../models/user.js');
const User = new UserModel();
const authCodeFunc = require('./../utils/authCode.js');

const authController = {
  login:async function(req,res,next){
    // 获取邮件密码参数
    let email = req.body.email;
    let password = req.body.password;
    // 参数判断
    if(!email || !password){
      res.json({ code: 0, data: 'params empty!' });
      return
    }

    try{
      // 通过用户模型搜索用户
      const users = await User.select({ email, password });
      // 看是否有用户存在
      const user = users[0];
      // 如果存在
      if(user){
        // 将其邮箱、密码、id 组合加密
        let auth_Code = email +'\t'+ password +'\t'+ user.id;
        auth_Code = authCodeFunc(auth_Code,'ENCODE');
        // 加密防止再 cookie 中,并不让浏览器修改
        res.cookie('ac', auth_Code, { maxAge: 24* 60 * 60 * 1000, httpOnly: true });
        // 返回登录的信息
        res.json({ code: 200, message: '登录成功!'})
      }else{
        res.json({ code: 0, data: { msg: '登录失败,没有此用户!'} })
      }
    }catch(e){
      res.json({ code: 0, data: e })
    }
  },
  // 渲染登录页面的模版
  renderLogin:async function(req,res,next){
    res.render('login',res.locals)
  }
}

module.exports = authController;
  1. 定义登录页面视图模版 views/login.tpl
{% extends './layout.tpl' %}

{% block css %}
    <link rel="stylesheet" type="text/css" href="/stylesheets/style.css">
{% endblock %}


{% block content %}
<div class="form-cells">
  <h1>用户登录</h1>
  <div class="form-cell">
    <input id="email" type="email" name="email" placeholder="邮箱">
  </div>
  <div class="form-cell">
    <input id="password" type="password" name="password" placeholder="密码">
  </div>
  <div class="form-cell">
    <button id="submit">登录</button>
    <input type="text" name="csrf" id="csrf" value="{{csrf}}" hidden>
  </div>
</div>
{% endblock %}

{% block js %}
<script src="https://lib.baomitu.com/jquery/3.3.1/jquery.min.js"></script>
<script type="text/javascript">
  const PAGE = {
    init:function(){
      this.bind();
    },
    bind:function(){
      $('#submit').on('click',this.handleSubmit);
    },
    handleSubmit:function(){
      let password = $('#password').val();
      let email = $('#email').val();
      let csrf = $('#csrf').val();

      if(!password || !email){
        alert('params empty!')
        return
      }
      console.log(email,password)
      $.ajax({
          url: '/api/login',
          data: { email, password, csrf },
          type: 'POST',
          success: function(data) {
            if(data.code === 200){
              alert('登录成功!')
              location.reload()
            }else{
              console.log(data)
            }
          },
          error: function(err) {
            console.log(err)
          }
      })
    }
  }

  PAGE.init();
</script>
{% endblock %}
  1. 启动服务,打开http://localhost:3000/login,就可以看到登录页面,我们输出正确的账号和密码,即可完成登录,之后可以打开控制面板 Application 的 Cookies 处看到我们种下的 Cookie。

  2. 新建过滤中间件,filters/index.jsfilters/loginFilter.jsfilters/initFilter.jsfilters/index.js 为主文件分别引用 filters/loginFilter.jsfilters/initFilter.js,分别用于设置用户信息和全局信息。

filters/index.js

// 注册app的过滤器
module.exports = function(app) {
  app.use(require('./loginFilter.js'));
  app.use(require('./initFilter.js'))
};

filters/loginFilter.js

// 引用加密解密模块
const authCodeFunc = require('./../utils/authCode.js');
module.exports = function (req, res, next) {

  res.locals.isLogin = false;
  res.locals.userInfo = {};
  
  // 判断是否存在ac cookie
  let auth_Code = req.cookies.ac;
  if(auth_Code){
    // 如果有,对其进行解密
    auth_Code = authCodeFunc(auth_Code,'DECODE');
    authArr = auth_Code.split("\t");
    let email = authArr[0];
    let password = authArr[1];
    let id = authArr[2];
    // 注意:为了防止删改
    // 这里其实应该再调用一次用户模型进行登录校验
    // 然后把状态保存在 session 中来联合判断。
    // 当前为了体验,所以直接把用户名和密码和id直接解密返回
    res.locals.isLogin = true;
    res.locals.userInfo = {
      email,password,id
    }
  }

  next();
}

filters/initFilter.js

module.exports = function (req, res, next) {
  res.locals.seo = {
    title: 'ExpressApp',
    keywords: 'Express、Nodejs',
    description: 'ExpressApp to study Nodejs on Web'
  }

  next();
}
  1. 引用文件中引用 filters ,app.js,要在定义路由之前定义。

app.js

var filters = require('./filters/index')

...

filters(app);
app.use('/', indexRouter);
app.use('/api', apiRouter);
  1. 登录页面,如果登录的话重新定向到首页

controller/auth.js

const authController = {
  ...
  renderLogin:async function(req,res,next){
    // 如果用户已经登录,重定向到用户管理页面
    if(res.locals.isLogin){
      res.redirect('/user')
      return
    }
    res.render('login',res.locals)
  }
}
  1. 用户管理页面,如果用户没有登录,重定向到登录页面

controller/user.js

const userController = {
  show: async function(req,res,next){

    // 如果用户没有登录,重定向到登录页面
    if(!res.locals.isLogin){
      res.redirect('/login')
      return
    }

    try{
      const users = await User.all();
      res.locals.users = users;
      res.render('user/show.tpl',res.locals)
    }catch(e){
      res.locals.error = e;
      res.render('error',res.locals);
    }
  },
}
  1. 调试,打开控制面板清空 cookie ,访问http://localhost:3000/user , 然后就会自动跳转到登录页面。输入账号密码,刷新就会自动跳转到用户管理页面。