前段时间处理一个抽奖H5,测试过程中想到如果有用户抓到抽奖接口,比如
https:xxx/lottery/userinfo复制代码
如果直接访问抽奖接口,可以直接进行抽奖动作。这里就涉及到处理验证用户身份的问题
之后的解决方式是 判断接口的cookie中是否包含 userInfo 等参数信息
不过还可以通过另外一种方式来处理-- JWT
什么是JWT (JSON WEB TOKEN)
JWT是通信双方之间以 JSON对象的形式安全传递信息的方法。
其实可以理解为使用非对称算法来进行前后端校验。
JWT 由三部分组成
头部
-
typ 声明类型
-
alg 声明加密的算法
然后按照此规则将头部信息进行base64编码,构成JWT第一部分
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9复制代码
payload
payload 就是存放有效信息的地方
payload 中有一些参数字段是建议使用的 (仅列出几个)
参数 | 含义 |
---|---|
iat | jwt的签发时间 |
exp | jwt的过期时间,这个过期时间必须要大于签发时间 |
nbf | 定义在什么时间之前,该jwt都是不可用的 |
比如来定义一个payload
{ "exp": Math.floor(Date.now() / 1000) + (60 * 60), "name": "John Doe"}复制代码
payload 会进行base64编码,构成JWT第二部分
签证
可以看到,签证部分是由三个部分组成的
参数 | 含义 |
---|---|
base64UrlEncode | base64加密后的Header |
base64UrlEncode | base64加密后的payload |
your-256-bit-secret | 自定义的加密secret |
secret 相当于私钥,不可泄漏,如果客户端可以拿到secret,就可以自我签发JWT了
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload)var signature = HMACSHA256(encodedString, 'secret')复制代码
signature 是JWT的第三部分
将以上三部分拼接起来,就是最后的JWT
第三方库
jsonwebtoken
如果自己在生成jwt,有点复杂。目前已经有很多开发的第三方库来支持JWT。比如 jsonwebtoken
-
sign 用于生成 token
-
verify 用于检验token
koa-jwt
koa-jwt 用于验证接口中是否包含token信息
搭建了一个简易的server 来看下效果
app.use( jwtKoa({secret: SECRET}) .unless({ path: [/\/login/] // 不需要通过jwt验证的请求路径 }))router.get('/login', async (ctx) => { let token = jwt.sign({ name: 'dva' }, SECRET) console.log(token, 'token') ctx.body = { token }})router.get('/try', async (ctx) => { let token = ctx.header.authorization let result = jwt.verify(token, SECRET) ctx.body = { result }})复制代码
- /login 拿到token
// { "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiZHZhIiwiaWF0IjoxNTMxMjgwMDg2fQ.Rh_vAKeytjAL2TbOk-MmXQWFesszjRU3Bzldrx5x17s"}%复制代码
- 如果不添加token 会被koa-jwt 拦截
- 添加 token
工作机制
图片来自于文章
-
登录拿到JWT
-
前端发起请求,Header中挂载JWT
项目实战
由于我搭建的这个项目中有这种需要鉴权的接口比较少,所以并没有使用koa-jwt来处理。只是用了 jsonwebtoken
server端
构建两个接口 login , lottery
- login 用来生成JWT 返回给前端
token = jwt.sign({ name: 'who', exp: Math.floor(Date.now() / 1000) + (60 * 60), // 设置 token 过期时间 }, SECRET)复制代码
- lottery 用来验证JWT,验证通过则进行抽奖动作
let token = this.headers.authorization // 解码 let decoded = jwt.verify(token, SECRET) // console.log(decoded, 'decoded') let {name} = decoded if (name != 'who') { code = 403 return }复制代码
前端
- 首页点击登录后 经返回的token信息存储起来
我这里拿到token之后将其写入了localStorage
window.localStorage.setItem('token', token)复制代码
- 进入抽奖页面进行抽奖,每次请求的时候挂载Authorization
axios.get(`/${APP_NAME}/win`, { headers: { Authorization: token } })复制代码
如果传递错误的token 在server端JWT验证的时候就会报错
总而言之,如果你的接口需要考虑鉴权问题,可以参考下JWT来处理。
Other
问题回复
这篇文章发布之后,很多同学提出了一些问题,这里一一回复。感谢各位的评论。
问题1: 关于base64处理 Header 和 payload
其实JWT在处理Header 和 payload 的时候,只是很简单的进行了Base64编码。 如果拿到某一个token的话,是很容易就将其解码出来的。
const base64url = require('base64url')let header = { 'typ': 'JWT', 'alg': 'HS256'}let resultH = base64url(JSON.stringify(header))console.log(resultH, 'resultH')let payload = { name: 'dva', exp: 1531410000}let result = base64url(JSON.stringify(payload))console.log(result, 'result')// 解码payloadlet isP = 'eyJuYW1lIjoiZHZhIiwiZXhwIjoxNTMxNDEwMDAwfQ'let getP = base64url.decode(isP)console.log(getP, 'getP')复制代码
所以不建议在payload中存放敏感信息,比如用户手机号,地址信息等
问题2 JWT怎么做续签更新
查阅资料后总结,jwt的续签更新目前有以下处理方式,基本的原理就是在某一个时间点,server端发放新的token
- 每次客户端发起新的请求过来,server自动更新token,返回最新的token信息给客户端,客户端拿到token后需要再更新token
比如 在用户点击抽奖,发起请求的时候,server端每次更新一个token(我这里只更新jwt的有效期)
拿到新的token之后将其返回。下次发起抽奖动作的时候,挂载这个最新的token
但是这种处理方案有缺陷,如果用户两次请求的间隔时间超过了过期时间(比如20分钟),则接口过来的时候 首先会被判断为过期状态,请求终止(之后的代码不被执行,不会被下发新的token了)。用户会被强制退出到登录界面。
- 每次请求过来的时候,不去判断有效期 (当然此请求本身携带的token必须在有效期内,我的意思是不像第一种,判断距离过期还有多久) 直接下发新的token
问题3 处理注销
可以说token比较重要的问题就是注销token。
比如我上面第二个jwt的项目,当用户点击退出登录的时候,仅仅在客户端做了token的删除。
但是实际上这个token还是处于有效期内的。如果用户保存了token值,在点击了退出登录之后,实际还可以使用此token值的。可以理解为伪注销。
传统的方式怎么处理用户的注销行为呢?-- 删除数据库记录。当用户注销登录信息的时候,server会变更数据库信息
但是jwt是没有介入服务器来存储用户状态的。这就比较难处理了。我们希望token能够在用户注销后不可以被继续使用了
-
设置比较短的token 有效期,每次请求过来的时候,重新下发,不断更新token.
-
使用服务器存储token状态。当用户点击注销,将token置空。
问题4 JWT单点登录(强制退出用户登录,比如修改密码后,希望能让其他客户端登陆的地方全部强制登出)
可以理解为如何让一个token立即失效(有点像上面的问题3)
- jwt + 数据库(比如 redis)+ 白名单 (这种思路是公司同事提出来的,特别感谢~)
每一位用户在设备A登录的时候,将UID和token对应关系存放起来
myRedis.set(`${uid}:token`, ${tokenA})复制代码
用户换设备B登录的时候,将redis中的的token进行更新
myRedis.set(`${uid}:token`, ${tokenB})复制代码
每次请求发起的时候,server端去验证该UID对应的token信息是否是最新的token, 这样 如果携带的不是redis中的token的话,拒绝请求。前端强制退出登录。
我将这个单点登录的逻辑加入到了项目中。
你可以在两台设备使用同一个用户名进行登录,尝试是不是可以将第一台设备的状态登出。
新增逻辑部分:将用户{name, token} 对应关系存放在文件中,每次发送抽奖请求的时候,判断文件最新的token与接口携带的token是否一致。不一致则反馈前端需要退出登录
缺点:需要保留每一位用户的 {user, token} 对应关系
- jwt + 数据库(比如 redis)+ 黑名单
当用户点击退出登录,此token则被放入黑名单(比如存放在redis)。如果有请求此时携带了黑名单中的token,则不予处理
缺点:长此以往黑名单数据量增长
问题5 如何防范Replay Attacks (重放攻击)
重放攻击就是攻击者发送一个目的主机已接收过的包,来达到欺骗系统的目的,主要用于身份认证过程
比如用户的token被获取,那么即使用户登出了系统,其他人还可以利用Token模拟正常请求,而服务器端则无法判断这种情况。
还是黑名单思路,每次token更新之后,或者用户登出之后,旧的token被放入黑名单。携带此token的请求一律不予处理
问题6 使用‘每一次发送请求就去更新token的方式’ 如果客户端有并发的请求,如何处理
em,这个和上面的问题5有点矛盾,如果使用变化token的情况处理,那么肯定会有当请求并发状态下,第一个请求在处理完毕拿到新的token,后面的请求携带的token就变成了旧的token,请求会失败
查阅资料后发现,有些人在将token存到黑名单的时候,会同时添加一个“宽限时间” 。当请求中携带了一个黑名单中的过期token,则去判断去“宽限时间”,如果在期宽限之间之内,则予以通过。
不过我个人没想明白,这种处理方式是不是有问题,既然已经被放入黑名单了,那为什么又来一个“宽限时间”。为什么不直接设置一个长一点的有效时间。
以上是对各位的一些回答。欢迎留言讨论。