完整源码在github,下载server和react部分
最近在做一个JSPatch后台管理系统。起初只是自己内部用,后来买了阿里云的免费服务器,用docker+jenkins完成了一个自动化部署,于是就想把这个小东西放到公网里。所以现在开始回来完善一下登录注册的功能,当然这个不是最终解决方案,只是帮助大家了解Json Web Token的基本流程和koa-jwt的基本用法。
一.Json Web Token介绍
由于http协议的无状态性,有些时候我们需要保存一些状态,比如用户的登录信息等。目前主要用到的一种方式是session + cookie。这种方式我之前也有过实现,但是只适用于浏览器端而不适用于原生应用。另外一种方式就是我们今天要讲到的Json Web Token。
1.组成
JWT主要由三部分组成
Header:base64编码的json object,包含token类型和使用的加密算法。一个Header行如下
{"alg": "HS256", "typ": "JWT"}
Payload:base64编码的json object,包含一些自定义信息(用户唯一标识),和一些jwt预留的标识。常用的有iss(签发者),exp(过期时间戳),sub(面向的用户),aud(接收方),iat(签发时间)等。jwt不会强制要求你使用预留标识,一个简单Payload行如下。
{"uid": 42245, "exp": Date.now()+10*60*1000}
Signature: 根据Header,PayloadA 和一个密钥(只有服务端知道),并利用Header中指定的加密算法,生成的一个签名串。
2.输出形式
JWT格式的输出是以.分隔的三段Base64编码,与SAML等基于XML的标准相比,JWT在HTTP和HTML环境中更容易传递。一个标准的JWT输出大概是这样子的
JWT输出示例
3.鉴权流程
这里以koa-jwt为例。服务端调用koa-jwt的sign方法生成token,通过api请求下发到客户端。客户端存入本地(h5就是localstorage,cookie,sessionstorage等,iOS的话就是NSUserDefault)。客户端在下一次请求的时候在header里面带上token,服务端koa-jwt检测到token,先进行校验,校验成功以后用decode方法解密,并直接赋予this.state.user。以下是一个流程图。
JWT流程
二.具体使用
看了上面的介绍,大家也许还一头雾水?没关系,下面我们在实战中具体来操作。先来看一下我们具体要实现的功能。首先我们需要有一个注册的页面
注册页
注册完成以后,我们需要验证用户的邮箱
验证邮箱页
验证完成以后,跳转到主页面
主页
最后我们还有一个登录页面,如果登录之后没验证邮箱的,也跳转到认证邮箱页面,否则跳转到主页面
登录
前端大概就是这么几个页面,很简单,大家可以用React把这几个页面画出来,然后用React Router完成相关路由设定。页面部分的代码我就不在贴出,待源码整理完成以后会提供大家参考,本文的重点是JWT。
下面看一下server部分的,除了之前讲解koa上传图片用到的基本库以外,本文还需要用到koa-jwt和nodemailer两个库,首先用npm install xxx --save安装jwt和nodemailer。
先来看一下我的一个整体的项目结构
工程目录
项目的路由由于引入koa-frouter,所以是根据文件路径来决定的。我设置的是routers下面的文件。model用于生成mongodb的Schema,lib下主要是提供一些mongo的增删改查函数。
然后,打开app.js
app.js
我们引入koa-jwt并且加入到中间件级联中。这里的secret就是我们之前所讲的JWT用来生成Signature的密钥。由于我们的登录,注册这几个路由不需要用到JWT验证,所以我们把相关的几个路由忽略掉。
首先我们先来写一下注册的接口,在routers文件夹下创建register.js,然后在model下创建User.js,我们先来生成一个UserSchma。
User.js
这里主要看activeKey和isActive。activeKey我这里是用邮箱的md5(也可以用别的随机字符串,保证唯一性),isActive是表明用户验证过没有。我这里认证邮箱的思路大概是这样:首先用户注册,注册成功后把用户邮箱md5以后存到activeKey里面(email是不能重复的),用koa-jwt生成token,然后用nodemailer往注册邮箱发一封邮件,邮件包含一个链接。链接打开后会访问验证邮箱页面,并且。把activeKey放到链接的查询参数里面(query),在认证邮箱页面请求服务端认证接口,如果参数key和数据库里面保存的key相同,就说明认证成功,把数据库里的isActive改为true。
在lib目录下,创建User.js
User.js
这里就简单封装了我们要用到的几个函数。然后在routers下创建register.js。
config.scheme.js
register.js
引入lib下的User.js,nodemailer和koa-jwt。在这里我用到了koa-scheme这个库,所以对post请求上来的body数据合法性检测就放到了config下的config.schema.js里面。主要是邮箱合法性,以及生成activeKey的一些操作。在register.js里面我们就无需再写这些繁琐的数据校验。在register的post函数中,我们首先检验邮箱是否已存在,若不存在,入库,然后生成token。这里我们用koa-jwt提供的sign。
在之前我们有讲过JWT包含三部分。Header一般情况我们并不需要指定。我们给koa-jwt的sign方法传递了三个参数:
第一个是Payload,也就是用户信息(要注意payload不要传整个文档,Payload需要的是唯一且不变的数据,否则当Payload改变的时候需要重新下发token)。这里我们用文档的id,目的是唯一标识用户,并且不可修改但是它默认是ObjectId类型的,我们使用toString方法让其转化为字符串。
第二个参数是密钥,也就是你生成Signature时所用到的加密密钥。要注意这里必须和创建jwt的时候传入的secret一致,因为服务端需要用创建时的secret来解密。
第三个参数则是设置一个token的过期时间,这里我们设置的是1天。
这样我们就生成了一个token,这个token我们下发到浏览器端,然后存到localstorage里面。
前端Register.js
然后我们使用nodemailer给指定邮箱发邮件。这里我们默认发送到我的一个网易邮箱。
register.js
注意mailOptions里面的html,这里的html就是在邮件内容里面显示出来的html。这里我们用一个a标签包一个超链接,链接到前端验证邮箱的页面,并带上查询参数verifyKey。收到邮件后,根据React Router点击会跳转到VerifyEmail组件
前端VerifyEmail.js
在组件挂载完毕之后,我们从loaction的query里取verifyKey参数,如果存在,证明是点击邮箱跳转过来的,这样我们就调用一下网络请求的函数访问服务端verifyemail接口。
前端Category.js
前端network.js
网络请求的函数我写进了Component的扩展里面,方便调用。Category里面,我们对10002和10001错误进行了统一处理。一个是说明用户未验证邮箱,一个则是说明用户未登录。
另外,在network里面我用了这种返回Promise的方法,也是避免回调函数嵌套。注意network里面,我们会从localstorage里面取一次token。如果token存在,证明服务端授权过。所以我们在请求的header里面增加Authorization字段,并且用Bearer + 空格 + token的形式把服务端生成并返回给我们的token传入。
当你的请求头里包含Authorization,并且koa-jwt没有unless的情况下,koa-jwt就会对这一次API访问进行鉴权。如果成功,会把token解密后的数据放到ctx.state.user中,在koa里我们的上下文就是this。当然这个user的名称是可以更换的,具体的请在github查看koa-jwt的usage。
现在由于我们是点击邮箱跳转过来的,所以verifyKey参数也是有的,再加上之前注册服务端返回并存到localstorage的token,所以我们在VerifyEmail里面直接请求verifyemail接口。在服务端routers文件夹下我们创建verifyemail.js
verifyemail.js
这个逻辑就很简单了,拿到前端传过来的verifyKey,再拿到jwt解密出来的文档id,然后查询出来id所对应的doc,判断两个key是否相等。相等的话就说明认证成功,然后更新文档的isActive字段,并返回成功给前端。前端判断如果code为0的话,就直接跳转到主页。
到这里我们已经完成了一个简短的邮箱验证,但是还有一些细节需要处理。
接下来我们看一下登录,在routers下创建login.js。
login.js
和注册的逻辑基本一样,只不过我们需要验证一下密码。在这里存在的一个逻辑就是,如果注册完没有立即去验证邮箱,也是能登录进来的。在这种情况下,我们需要在登录成功之后跳转到认证邮箱的页面。在这里我们实现的逻辑是登录成功后默认跳转到主页,主页会调用getapplist接口。然后我们对所有请求添加一个中间件进行处理。打开app.js。
app.js
添加如上代码,这里添加一个中间件。除login,register,verifyemail三个接口之外,所有接口都会验证User文档的isActive是否为true,若为false,则直接返回code==10002。若不为则交给下面的中间件继续处理。
前端的话,就要对所有网络请求进行统一的处理,这也就是我们为什么要封装网络请求的原因。(请看上文的Category.js)
所以当我们登录并未验证的用户,getapplist请求会被该中间件拦截并返回code==10002。前端对返回code==10002会跳转到认证邮箱页。同理未登录的用户会返回code==10001。
三.总结
关于JWT Token过期问题
1.如果在payload里面设置了exp属性,则你可以将你的token存到localstorage里面,当到达过期时间以后,你再用这个token去请求API,服务器会抛出Thrown error if the token is expired错误。
Thrown error if the token is expired.
Error object:
- name: 'TokenExpiredError'
- message: 'jwt expired'
- expiredAt: [ExpDate]
2.对于没有设置exp的童鞋,你可以存到cookie里面并设置过期时间。或者在localstorage里面再增加一条创建时间的字段。两种方式都可以看个人的喜好。
The End
至此我们已经用React+nodemailer+koa-jwt实现了一个简单的登录注册及邮箱验证功能。如果你正在为原声移动应用或者SPA编写API的话,JWT会是一个非常不错的选择。有一点需要记住的是: 在浏览器中使用JWT你需要将它存在LocalStorage或者SessionStorage中,但这可能会导致XSS攻击。关于XSS攻击的话,网上有详细的文章来说明,这里就不赘述。
源码待我整理完毕后会上传到github。
好了,打个小广告。小弟和几个朋友一起弄一个技术公众号,主要专注于React全栈相关技术。感兴趣的朋友可以关注,一起交流学习。公众号另外一位小编可是资深React和Node开发者哦!