14

1. 开场白

用户系统是许多网站的基础。这篇文章主要就是讲解如何写一个基于Node的单页应用的用户系统,这个用户系统的功能包括:注册,登录,自动登录,忘记密码,修改密码,邮件激活。
如果使用在后端使用模板引擎,而不是用前后端分离的方案,用户系统貌似没有那么复杂。在这个Nodejs教程里面已经介绍得很详细了(这是个不错的Nodejs教程)。但是如果选择前后端分离的方案,比如像接下来要介绍的SPA,那用户系统又该怎么处理呢?模板引擎的方案里面,事实上session/cookie上都做了封装,所以操作起来相对简单。但后者则不一样,它需要我们对于HTTP相关的概念有更加清晰的认识。要求会更加细致。

2. 基础知识

下面先介绍一下一些基础的知识。说得不会很多,但是对于彻底理解CookieSession整个Authentication的机制非常重要。

2.1 HTTP

2.1.1 Cookie & Session

众所周知,HTTP是无状态的协议。这个的意思就是说,如果发送两个完全一样的请求,那么收到的响应也会完全相同。然而在实际生活中,这明显不符合许多场景。因为每个人虽然都点击了按钮,但我是Harry,她是Clara,我们应该收到不同的内容。服务器需要对我们做出区分,这时候cookie就登场了。我发出请求,服务器在响应里面加一个Set-Cookie,到我们浏览器里设了一个cookie(点开devtool->Application->Cookies查看),下一次发送请求的时候,我的header里面就带有cookie了,服务器看到cookie,就知道我是Harry了。这样就完成了一次认证。
但是接下来还有一个问题:服务器资源极其宝贵,如果每次都认证会造成资源浪费。加之,如果我希望能够暂时性地在当前会话存储一些信息,存储在cookie会显得非常浪费。因此session就来了。
session就是当前用户的回话信息。它需要用到cookie,但不需要把所有信息都放在cookie里面,它需要的只是一个标示。
session的信息是存储在服务器上的,可以存在缓存里,数据库里或者类似Redis之类的东西里(没用过..)。举个例子,Express-session里面的session的标示是一个名字为connect.sidcookie。这个cookie是随机生成的独一无二的序列码,每次用户发起请求的时候,cookie跟着到了服务器上去。服务器检查一下用户的connect.sid,然后从内存,缓存,数据库或者Redis里面找到相应的信息,然后通过中间件进一步加到请求里面。这样服务器就可以使用专属于这个用户的信息而不再需要多次验证了。
因此cookie是整个用户机制的核心,下面简单介绍一下相关的header

2.1.2 Set-Cookie

Set-Cookierequestheaderheader的格式是NAME=VALUE然后用分号‘;’分隔开来。
其中有几个设置比较常用:

  • expires=Date (设置cookie的到期时间)

  • secure (仅仅只在https下使用)

  • HttpOnly (使得cookie不能被客户端JavaScript修改)

  • maxAgecookie的保持时间,以毫秒为单位)

2.2 Node.js

关于cookie

读取和设置cookieNodejs里面都很方便,在Express里面添加中间件cookie-parser,可以把cookie对象直接赋给req。在路由回调函数里面操作的时候,直接用req.cookie就可以获取到客户端的cookie值。
而设置客户端的cookie则需要用res.cookie函数来设置:

// 把cookie里面的name值设为name
res.cookie('name', name, {
  maxAge: 1000 * 60 * 60 * 24 * 30,
  path:'/',
  httpOnly: false
})

session机制

Expresssession实现需要一个中间件:

var session = require('express-session')
app.use(session({
    secret: settings.cookieSecret, // 设置密码“种子”
    store: new MongoStore({
      url: 'mongodb://localhost/color' // 这里用了数据库存储session,如果不设置就会用内存
    }),
    resave: true,
    saveUninitialized: true
}))

有关session的使用Nodejs教程里面有介绍,具体来说,比如用户登录之后,可以设置 req.session.user = "harry", 然后之后的所有需要用到用户登录的场景都可以先判断一下req.session里面有没有user这一项。这样就完成了一次区分,而不需要再次验证。

2.3 前端

在这里的预设是要做一个单页应用。如果使用模板引擎,使用render很容易就可以完成登录等等的功能,但如果要写一个前后端分离的应用,比如一个SPA,那就不得不使用AJAX来收发用户信息。
不管使用什么库来收发AJAX,有一点是需要注意的:那就是发送的AJAX请求要包含credentials: 'include' 以保证cookie能够被携带发送到后端,否则后端的req.cookie不会收到。

3. 实例讲解

3.1 确认

对于需要确认用户已经登录了才能够使用的路由,需要加一个中间件。这个中间件的作用是检查req.session.user是不是已经定义了。一般来说,在用户登录之后都需要设置一下req.session.user,以表示处于登录的状态。

function authorize(req, res, next) {
  if(req.session.user) {
    next()
  } else {
    res.status(401).send({errorMsg: "Unauthorize"})
  }
}

3.2 注册

对于一个注册的过程来说需要有如下的一些步骤。收到用户的用户名,邮箱之后,要在数据库里面找一下,如果找到了同名或者用邮箱的,就要告知用户,重名了。如果没有重名,就发送邮件到邮箱中进行验证,同时创建一个未激活的账户。
另一个要注意的点就是密码的存取最好不要直接存入,推荐是先加密。
这里涉及到了多重嵌套的异步,可以使用我之前写的这篇文章co,也可以用async/await。用回调函数来写后期看起来会很吃力...

function *registerGen(req, res, newUser) {
  try {
    // 看有没有重名的
    const userOfSameName = yield new Promise(function(resolve, reject) {
      User.get("NAME", req.body.name, function(err, user) {
        if(err) reject(err)
        resolve(user)
      })
    })
    // 看是不是同一邮箱又想重复注册
    const userOfSameEmail = yield new Promise(function(resolve, reject) {
      User.get("EMAIL", req.body.email, function(err, user) {
        if(err) reject(err)
        resolve(user)
      })
    })
    
    // 如果是以上两种情况,就发送错误信息。
    if(userOfSameName) {
      return res.status(200).send({ errorMsg: "此账户名已经被注册。"})
    } else if (userOfSameEmail) {
      return res.status(200).send({ errorMsg: "此邮箱已经被注册。"})
    }

    // 成功的话就新建一个未激活的账户
    yield new Promise(function(resolve, reject) {
      newUser.save(function(err, user) {
        if(err) {
          console.log("Register error:" ,err)
          reject(err)
        }
        resolve(user)
      })
    })

    // 发送激活邮件
    yield new Promise(function(resolve, reject) {
      
      const nameHash = crypto.createHmac('sha256', SECRET)
                         .update(req.body.name)
                         .digest('hex')

      const emailHash = crypto.createHmac('sha256', SECRET)
                          .update(req.body.email)
                          .digest('hex')
     
      const base = "http://colors.harryfyodor.tk/activate/"

      // 打开这一段链接之后会可以通过立即发起一个ajax来更新数据库,激活账户。
      const link = `${base}${req.body.name}/${nameHash}|${emailHash}`

      User.activate({
        subject: 'Colors 验证邮件',
        html: '如果您并没有注册Colors,请忽略此邮件。点击下面链接激活账户。<br>\
                <a href=' + link + ' target="_blank">激活链接</a>',
        to: req.body.email
      }, function(err) {
        if(err) reject(err)
        res.send({ ok: true })
        resolve()
      })
    })
    
  } catch(e) {
    // 如果有错误就在这里发起,方便debug
    return res.status(500).send({ msg: "ERROR"})
    console.log('Error ', e)
  }
}

function register(req, res) {
  
  // 密码需要先加密,不推荐明文存储。
  var md5 = crypto.createHash('md5'),
      password = md5.update(req.body.password).digest('hex');

  // 创建用户,这里的User是model(后端MVC的M)的一个构造函数。
  var newUser = new User({
    name: req.body.name,
    password: password,
    email: req.body.email
  })

  // 用co函数来实现同步写法写异步
  co(registerGen(req, res, newUser))
}

3.3 登陆

用户登录需要有以下的步骤,代码就不详细叙述了。这里面需要非常繁琐的判断语句,但是理解起来非常简单。
登录流程图

3.4 邮件通知

激活用户需要用到nodemailer这个库,非常方便,用起来也非常简单。可以上官网看。如果使用163邮箱作为发件的邮箱,有一点要格外注意,那就是密码处要是网易的授权密码。这一个需要在163邮箱里面自己设置,然后代码里就用那一个授权密码。这一点需要格外注意。

function sendEmail(detail, callback) {
  var config_email = {
    host: 'smtp.163.com',
    post: '25',
    auth: {
      user: 'example@163.com',
      pass: '**********' // 这个密码不是邮箱密码,请先到邮箱里面设置授权密码。
    }
  }

  var transporter = nodemailer.createTransport(config_email)
  var data = {
    from: config_email.auth.user,
    to: detail.to,
    subject: detail.subject,
    html: detail.html
  }

  // 异步发送邮件
  transporter.sendMail(data, function(err, info) {
    if(err) {
      console.log("SendEmail Error", err)
      callback(err)
    } else {
      console.log("Message sent:" + info.response)
      callback(null);
    }
  })
}

4.总结

当然,这一个用户登录系统仍然还有很多要改进的地方(比如安全问题等等)。除此之外,在功能上还有不少需要增加的。比如修改密码,比如更换密码等等,看了上面的内容,其实要完成这些功能也是非常简单的一件事了。
如果感兴趣的话可以看看我自己写的一个网站,Colors,这是一个基于ReactNodejs的网站,有完整的用户系统,如果没有什么头绪的话可以参考一下~

如果文章中有什么错误或者不妥的地方,欢迎指出,互相交流学习~感谢阅读~


harryfyodor
406 声望32 粉丝

关注前端,关注JS, React, Vue, Nodejs, Backbone.