这次做这个东西可以是纯属无用的自嗨,甚至是什么的,希望大家嘴下留情,并且可以给我一些建议,因为对于token这一块实在并不是算很了解的,毕竟也是看别人博客慢慢学的,谁还不是一个学生呢(doge)
后端相关介绍交给【君语流年】同学:
自刷新Token——后端部分
新版
对于Web应用来说,保证用户使用安全性和便捷性是非常重要的。从历史角度来看,最早由网景公司提出使用Cookie。因为当时的网站都属于无状态应用,用户的每次访问、每次登录都是独立的,这对于一些交互式Web应用如购物网站、论坛网站等有极大的阻力,所以网景公司员工Lou Montulli(卢-蒙特利)便将Cookie的概念应用于网络通信,用来解决用户上网购物的购物车历史记录的问题。由当时最强大的网景浏览器提出的Cookie概念的推动下,其余浏览器也逐渐开始支持Cookie。至此,Cookie便走上了Web应用的舞台。
但随着技术的逐渐发展,Cookie所带来的问题也逐渐暴露。一些由Cookie承载的关键性信息,被有心之人通过一些巧妙的方法获取到,导致用户信息泄露。例如最经典的XSS攻击和CSRF攻击。所以开发者们在此基础上想到了使用Session和Token来作为用户状态管理和用户权限管理的“钥匙”。Session缺点是服务端需要存储信息,而Token的信息一般会通过加密后直接存储在Token中,服务端不需要耗费资源进行额外的存储。但是这样子的Token在重要的业务中反而时效性会越短,因为要保证安全性,所以开发者将Token分为Access Token和Refresh Token,Access Token用于鉴权,有效时间较短,Refresh Token有效时间较长,用于获取新的Access Token。可本质上获取到了Refresh Token后,在其有效期内等同于拥有了Access Token,从安全性上来说还是由一定缺陷的。所以在此问题上,我们需要让黑客不能直接简单地拿到Access Token,解决这个问题就需要具体分析整个Token流程:
(1) 用户使用账号密码登录。
(2) 服务端返回的响应头中包含Access Token、Refresh Token字段,客户端获取并将其储存,在以后每次对话将会附带上Access Token,用于鉴权(当然这个Access Token具有失效时间)。
(3) 客户端使用退出登录接口后,相应的Access Token、Refresh Token会失去权限。
(4) 为了适应多端登录,一个账号可以对应多个Token组。
(5) 当Access Token失效后,还可以使用Refresh Token请求新的Access Token,Refresh Token在每次登录时候获得,而且不参与本地储存。
从以上流程看,如果Access Token设置的过期时间太短,就需要通过Refresh Token比较频繁地重新获取新的Access Token,这样增加了Refresh Token被恶意获取的风险;同理,如果Access Token过期时间设置太长,也会有被恶意获取的风险。
所以,我们可以将Token“隐藏”起来,储存在内存变量中,利用JavaScript语言特性,通过闭包更新内存变量中的Token,致使每次Token使用后都将会失效,这样暴露在外部的始终是失效的Token,在每次请求时,再触发内存变量中Token的更新,使用新Token完成请求。客户端实现Token的更新后,服务端需要对新的Token进行解密认证,并且要对更新算法进行验证。验证过程即使用相同更新算法更新服务端旧的Token,比较更新后的Token与客户端所传Token是否相同,如果相同则认为请求是由客户端发出,而不是伪造请求。
但在客户端与服务端请求传输过程中,网络可能会出现不稳定的情况,这时候客户端会尝试多次请求,导致Token进行多次更新,服务端在网络波动后收到的Token将会与其本地存储的最新Token差异过大,即服务端更新Token后,与从客户端收到的Token不一致。造成服务端“误判”,拒绝客户端请求,导致用户体验感降低。
为了解决这个问题,可以采用一个Step标志位对齐客户端与服务端的更新频次(上述可更新的Token等同于下述的StepToken,而下述的Token为储存用户信息的真实Token)。客户端只需要每次更新Step并与Token结合生成StepToken,而服务端需要根据客户端反馈的Step同步更新StepToken(即服务端按照客户端的Step迭代自己储存的StepToken),再检查StepToken是否与客户端传送的StepToken一致。但同时有个新问题,服务端无法去相信一个Step差值大于 10 的StepToken(等同于客户端有9个请求在网络中丢失),因为这有可能是恶意请求或请求被恶意拦截。所以服务端可以规定Step差值大于10,则该StepToken无效。
解决了客户端与服务端同步StepToken的问题,还需要规定StepToken格式以及加解密方式。StepToken格式采用简单组合方法,将Token与Step拼接成未加密StepToken。加解密方式采用RSA[8-9]非对称加密算法,加密方式:StepToken = RSA(Token + Step)。非对称加密需要一组公钥和密钥,服务端以及客户端都需要进行加解密,所以需要两组公钥,客户端以及服务端分别保存一份公钥和一份密钥。客户端使用服务端公钥加密,服务端使用客户端公钥加密。
但在实际情况下,客户端网络请求较为频繁,可非对称加密耗时相对于网络请求耗时较大,而对称加密耗时较小。所以最终方案采用上述非对称加密算法进行一次随机码的交换,将客户端与服务端的随机码结合生成对称加密密钥,后续请求中Token采用AES[10]加密算法进行对称加密,加密方式:Token = AES(Token + Step)。具体实现是由客户端提供随机生成的Client-RandomCode与服务端提供随机生成的Server-RandomCode按位轮询穿插生成16位对称加密密钥。该方法实现所消耗的加密时间远小于非对称加密耗时。
服务端需要维持不同用户的Token状态,维护多个AES Key,所以需要使用一个Flag标志位记录缓存中Token与AES Key的依赖关系。
客户端整体流程如下:
(1) 用户登录,客户端生成Client – RandomCode,经由RSA加密发送至服务端。
(2) 服务端生成Server – RandomCode,与Client – RandomCode生成AES Key,用其加密初始化后的Token与Step,生成StepToken,并向客户端发送Server-RandomCode。
(3) 客户端结合服务端发送的Server-RandomCode生成AES Key,并解密StepToken,获取到初始Token与Step,存储在内存变量中。
(4) 用户后续请求将更新Step并生成新StepToken,并将标志位Flag与StepToken拼接,随请求头发送至服务端。
旧版
对于token,我相信很多人都很熟悉
在之前我和小伙伴使用了一套简单的token技术用于自己开发的网页应用
具体流程是:
- 用户使用账号密码登录
- 服务端返回的响应头中包含token字段,客户端获取并将其储存,在以后每次对话将会附带上这个token,用于鉴权和自动登录(当然这个token具有失效时间)
- 客户端使用logout接口后,相应的token会失去权限
- 为了适应多端登录,一个账号可以对应多个token
当然我了解到当access token失效后,还可以使用refresh token请求新的access token,refresh token在每次登录时候获得,而且不参与本地储存
但是这里就出现一个问题,如果我的access token设置的过期时间太短,我的refresh token就需要比较频繁的重新获取新的access token,这样增加了refresh token被恶意获取的风险;同理,如果我的access token过期时间设置太长,也会有被恶意获取的风险
当攻击者能够维持一个虚假的登录态或者是权限的时候,用户信息什么就像是对他敞开门户,他甚至可以做一些非法操作,这样对用户和开发者来说会很难受
所以,在某个普普通通的一天,我和我的好友【君语流年】突然灵机一动,为什么不让展示在用户层面的token都是一个无效的token
怎么让传输的token无效呢,那就让每次token都会自动刷新就好了呀(ps:说的简单)!
还要让前后端同步更新,但是也要考虑网络状况问题,所以我们想到用一个step去对齐前后端的 “ 步数 ”(token刷新的次数),前端只需要每次更新token和step,而后端需要根据前端反馈的step去同步刷新token(通俗点说就是按照前端的步数去迭代自己存的token),然后看看这个token是不是能对的上。这里有个新问题,我可不能去相信一个步数差在 1000000000000000000…+ 的token吧,所以我们
规定步数差大于10就认为该token失效
好了,步数问题确定了,那我们token格式是什么呢,起初我们想到直接在headers里加step和token字段,但是这样很容易让人看出端倪,然后我们又想到使用token+step的方式拼接成一个新的token,但是这样其实和前者没两样
而且这里也有一个新的问题,我的token怎么去生成一个新的token呢。DING!灵光一现,我可以使用
MD5(token+step)
的方式生成一个
sToken(stepToken,划重点)
,当然MD5已经并不是很安全(不了解的同学可以了解一下:https://cloud.tencent.com/developer/article/1805350),但是他快呀!
这里有个伏笔,为什么我可以用MD5
这样生成一个sToken后我怎么知道步数呢,oh no,我还是得加上一个步数,所以现在变成sToken+step,感觉没做什么有用的工作,但还好已经把更新token的问题解决了,接下来怎么去让step不是那么“显眼”,同时这里有有个关键性问题:我的token都没了,我服务端怎么去提取用户信息(因为原本token中存了用户的账号、密码、权限,当然这个token是加密后的,【君语流年】又不傻)
这时候直接开始讨论(此处省略一万字讨论内容),加上那段时间一直在面试,刚学完计网,我谋生一计,我们可以使用非对称加密呀,wow,就像TLS那样,用一个RSA将我的token加密起来,这样谁都看不懂了,连我自己都看不懂,说干就干
token = RSA(tToken+sToken+step)
(tToken,trueToken,真正的带着信息的可用的token)
这里又有个问题,我的公钥可以加密,但是私钥并不能加密,只能签名(这里作者没有,甚至于一点也不了解加密相关内容,从前面MD5也可以看出来,不过因为MD5加密后的内容并不直接暴露,所以暂且先采用MD5,同时也解释上文伏笔),所以我使用了两对密钥,密钥对称保留,前端拥有一个私钥、一个公钥,后端拥有相应的一个公钥、一个私钥。前端传给后端信息时用后端的公钥加密,后端传给前端信息时用前端的公钥加密,
当然我们得保证后端拥有的两个密钥相对安全
。
当用户登陆时,会拿到一个通过公钥加密的token,这时候我们使用对应私钥解密,将解密后的token拆分出来(tToken、sToken、step)并将其储存在内存中,切记不能存储在任何用户能够直观看到的地方,因为这里有几个关键点:
1、内存相对来说不容易查阅
2、一般前端代码都会进行minify、uglify,这样增加定位难度
3、储存在localStorage、sessionStorage等地方就没意义了,控制台可以直接看呀
然后每次请求使用更新函数(这里是挂载在axios的拦截器里),创建好新的token后放在headers里传给后台,这时候这个token相当于被消耗了,使用者看到的这个token就失效了,下一次再请求时根据内存中的三个元素创建新的token,当然后台和前端的逻辑是一样的,但是后台每次要去看前端step,并且根据前端的step去做相同步数的更新,前端整体流程如下:
① 用户登录 –> token(RSA)
② token(RSA) – RSA key -> tToken、sToken、step(存在内存中)
用户请求:
③ sToken = MD5(sToken+step)
④ step++(当然不是认知上的加一,可以是自己规定的一个跨步大小或某种规律)
⑤ token(RSA) = RSA(tToken+sToken+step)
然后后台拿到后根据自己存储的sToken和step对照前端的sToken和step来确定前端的这个sToken是否有效,有效的话就可以使用这次的tToken来进行用户检查和鉴权
本来以为解决了,但是后来发现TLS并不是每次都用RSA去对话,而是RSA+对称加密。后来了解到RSA很费时,对于请求来说,我们这样弄会花费很多时间,肯定会消耗不必要的资源甚至会给用户带来不好的体验
所以效仿我们的TLS,只对第一次获取token的时候使用RSA,正常对话使用对称加密(我们这里采用AES-CBC),对于CBC来说需要一个iv,我们可以使用后端规定的flag作为iv(通过headers传递,在用户登录时传输,前端储存在内存中,
这个flag在后面也有用处
)。由前端登录请求时产生一个随机数random-client,在headers里传给后台RSA(random-client),后台解密后也产生一个random-server,将random-client和random-server采用某种特定的方式组合起来,形成一个AES密钥,用这个密钥对生成的token进行加密,传给前端,并且将RSA(random-server)通过headers传给前端,前端解密后用同样的方式组合出这个AES密钥,对token进行解密,优化后前端的相关步骤如下:
用户登录:
① RSA(random-client) –> token(AES) + RSA(random-server)
② random-server + random-client –> AES key
③ token(AES) – AES key -> tToken、sToken、step(存在内存中)
其它步骤不变
我本以为这样就完了,结果【君语流年】又提出了新的问题,说他需要一个flag来记录对应的token(AES)和对应的AES key,一番讨论后我们决定将flag(自定义一个可以辨别的值)直接明文拼接在token(AES)前作为标志位,当然采用特定的拼接手法
所以token = flag + token(AES)
这就是最终的token的实现了,当然可能其中有许多bug以及不足,不过我个人对于这个过程还是很满意的,基本上是我和【君语流年】的互相碰撞思考了
BUG
这里我要主动披露一些bug,这也是我在完成中和完成后思考所得:
1、如果攻击者主动降低网络速率甚至中断网络,这个时候它可以从请求报文中获取token,这时候的token并没有被消费,可以被消费一次。这样攻击者循环往复等同于拥有了访问权限。但是这个访问权限只限于当前用户,这也是为什么用户要保护好自己的电脑不被奇奇怪怪的插件入侵
2、因为前端需要做登录态,所以攻击者可以在localStorage中获取token,当然也要在token未被消费前,也就是页面自动登录请求没有被后端处理前,但这种攻击只能获取一些无关数据,因为我们规定这个请求不会返回用户关键数据,但是可以拿到uid。这类攻击只拥有一次权限,因为攻击者不知道random组合方法以及step更新规则
这两者对于当前来说确实是比较容易实现的攻击方式,但是都有受限
当然所有的探讨都是针对以下基础:
1、Google Chrome不容易被其他软件攻击,其自身防御相对安全
2、攻击者相对于比较不易于查看前端混淆后的代码,以及分析代码逻辑,查看内存占用
3、开发者不喜欢“裸奔”