写这篇文章的初衷来源于最近心血来潮想独立开发一款自己的产品,由于本人一直从事Android开发,但产品又需要数据的支持,迫于无奈又捡起了我那半桶水的服务端开发,在做架构设计的时候遇到了一些问题,下面想谈谈在Servier/APP通讯中对用户身份安全认证的一些思考,也希望可以抛砖引玉,和大家一起探讨出更好的解决方案。
本文章打算从下面几个点切入:
1、认识什么是Session和Token认证?
2、Session和Token认证分别会引发些什么问题?
3、如何解决这些问题,还有更深层次的思考?
Session认证
由于HTTP请求协议是无状态的,用户每次访问服务器,对服务器而言都是全新的访客,服务器没有办法识别是哪个用户,更不用说去记录用户上一次的活动状态了,所以我们需要某种机制来维持这个关系,就这样Session诞生了,每一个Session都对应着一个SessionId(唯一标识),在这个Session里可以保存用户的各种状态信息,那服务器要怎么识别Session具体属于哪个用户的呢,此时Cookie就派上用场了,它是存放在客户端的,里面记录着一个SessionId,只要用户在请求服务器的时候,带上这个Cookie,也就可以和服务器“相认”了。
Session认证会引发的一些问题
-
由于Session是存放在服务器的内存中的,随着用户量的增加,会导致服务器内存吃紧,压力和开销也明显增大。
-
当业务量足够大,单台服务器吃不消的时候,我们往往会做负载均衡来分担单台服务器的压力,此时就会面临到一些问题,比如用户在A服务器登录建立起Session会话,当再次访问服务器的时候,被负载均衡策略代理到了B服务器上,此时B服务器上是没有该用户的Session会话的,会导致用户需要重新登录等。
-
在移动端上,每次请求服务器都要带上Cookie,有点显得“过重”,并且也不是很好“维护”。
当然,以上提到的这些点,对于现在来说已经有很多成熟的解决方案了:
-
比如内存吃紧,单台服务器压力过大,自动扩容就可以解决这个问题。
-
在做服务器负载均衡的时候,可能会导致用户丢失Session会话,只要处理好Session的保持、复制或者共享即可,比如用Nginx 做负载均衡的Session保持,Tomcat来做Session的复制,通过KV数据库来做Session的共享,比如Redis等。
-
要在移动端上维护Cookie,GitHub上也有一些开源框架,可以很轻松的帮我们处理这个问题。
既然问题都可以得到解决,为什么我还想去谈Token认证,其实这只是“另外一种”解决问题的思路,能让问题更加友好,更加不费力的解决。
Token认证
Token认证也是无状态的,它的出现使得服务器不再需要为每个用户在内存中开辟一个Session会话,简单点说,就是在用户登录成功的时候,服务器会给用户颁发一个特殊的令牌(Token),这个令牌类似于身份证,每个用户都是不一样的,每次用户访问服务器的时候,只需要带上这个令牌,服务器便可知道具体是哪个用户,对于客户端而言,它只是一串字符串,维护起来很轻松,比如可以存放在内存,缓存文件,数据库,或者so文件中,对于服务端而言,很大程度上节约了内存的开销,只需要将用户id和token做KV的关联即可。
Token认证会引发的一些问题
1、如何传递Token才是比较安全的做法?
2、如何防范当传输信息被非法盗取后发起的恶意请求?
针对以上的问题,我们一步步的来探讨:
首先我们对Token认证做了简单的介绍,这时候我们应该去考虑,当用户去访问服务器的时候,如果只是简单的将Token拼接在URL之后,或者附带在HTTP请求头里,这样是很容易被窃取的,由于Token是客户端和服务器之间唯一识别的凭证,只要知道了这个Token和它所携带的方式,那么就可以模拟用户的一些操作了,比如修改用户信息,虚拟财产交易,更改订单状态等。
如何传递Token才是比较安全的做法?
1、这里引入一个比较主流的规范:JWT
JSON WEB Token(JWT),是一种基于JSON的、用于在网络上声明某种主张的令牌(token)。JWT通常由三部分组成:头信息(header),消息体(payload)和签名(signature)。
关于JWT一些更具体的信息,大家可以在https://jwt.io上了解,这里我就不做过多的介绍了,我直接切入主题。
JWT它是由三个部分组成的,分别是头部(Header),载体(Payload),签名(Signature),它们之间用字符串“.”进行连接,也就是:
头部(Header).载体(Payload).签名(Signature)
官方对每个部分应该包含什么信息也给出了一些建议(并不是强制要求),这里我们来简单看一下:
头部(Header):
由两部分组成:令牌类型,加密算法,比如:
{
"alg": "HS256",//加密算法为HS256
"typ": "JWT"//令牌类型为JWT
}
载体(Payload):
由一些有效信息组成(不应该包含敏感信息,比如用户的手机,密码等),比如:
{
"sub": "1234567890",//面向的用户编号为1234567890
"name": "John Doe",//面向的用户名为John Doe
"admin": true//是否是管理员
}
到这里,可能有朋友会问,这信息也太简单了吧,一个布尔值就可以确定用户是不是管理员,那么我手动修改下信息,那是不是也可以变成管理员了,别着急,我们继续往下看。
关键部分,签名(Signature),它是由头部和 载体两部分经过一些编码处理和加密组成的,官方给了一个示例:
HS256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
我们可以看出这个签名是是由:
经过Base64编码的头部 + 字符串“.” + 经过Base64编码的载体一起拼成后的字符串,再和秘钥secret进行加密算法(头部指定的HS256算法)组成。
JWT生成示例
如上图所示,可以我们就可以得到JWT了,我们再来梳理一下流程:
当用户登录服务器的时候,服务器会根据用户的信息生成JWT,并保存在KV数据库里,然后回传给用户生成的JWT,用户下一次再访问服务器的时候只需要带上这个JWT,即可完成身份的验证。
JWT交互时序图
为什么这样可以保证信息的安全,因为签名(Signature)是由服务器通过头部和载体和加密算法组成,虽然头部和载体是可以通过Base64解码成明文的,但是我们在生成签名的时候已经确定好的里面的内容,加入用户篡改头部或者载体的信息,比如把管理员权限字段设置为true,那么和我们服务器所存的签名肯定是对应不起来的,这时候服务器就会拒绝掉该用户的请求。
关于JWT的优点:
-
由JSON格式组成,所以具有跨平台性,可以支持各种编程语言。
-
减少了服务器的压力,相比在内存中开辟Session,存储字符串明显来的省力许多,但是这里需要注意用户的规模,虽然这里我们减少了I/O的开销,但是我们却多了加解密的过程,两者需要权衡。
-
解决了分布式共享Session的问题,不需要再去考虑文章开头所提到的负载均衡策略所带来的问题。
关于JWT的缺点:
没有什么东西是完美无瑕的,JWT虽然解决了一些问题,但是也同时带来了一些新的问题,我列举几个:
-
无法控制单点登录,假设用户在A设备登录了服务器,服务器正常返回JWT,用户又在B设备登陆了服务器,服务器又返回一个JWT,此时2个JWT都是可用的,服务器没办法踢出其中任意设备的用户。
-
当用户退出登录或者修改密码的时候,JWT没有办法回收,之前和朋友讨论过也看了一些开源项目,大多数的做法就是直接把JWT丢掉,类似删Cookie形式,但是这里还是存在安全隐患,如果被窃取,依旧会造成一些问题,因为这个JWT还是有效可用的。
可能有些朋友会说,在载体里面设置JWT的有效时间不就好了,但是这样又会导致新的问题,这个有效时间多久合适?太短,会造成用户频繁的过期登录,太长,当发生以上问题的时候,还是要忍耐有效期内的操作。 -
关于安全操作,回到文章开头的问题,当JWT被抓包窃取后,是不是就可以模拟用户(有效期内)操作了?比如修改用户的订单信息,频繁的调用验证码接口等,这里涉及到URL的重放等攻击,先不做深究,有时间再写一篇文章来详细说明。
如何防范当传输信息被非法盗取后发起的恶意请求?
2、另外一种加密Token的方式
整体执行流程还是不变的,只是在处理这个Token的时候,让它变得更安全(加密,时效,可控)
具体流程:
登录时序图
-
客户端在登录的时候除了账号密码一些信息外,还要额外的带上客户端的本地时间戳。
-
服务器验证账号密码,并求出当前服务器时间戳减去客户端时间戳的时间差,并生成一个Token,返回给客户端的信息为:用户编号(不一定是数据表的自增id,出自一些安全性的考虑,文章篇幅有限,这里就暂不拓展讲了),时间差,Token等信息,并在KV数据库(比如Redis)建立用户编号->Token关系。
-
客户端存储用户编号,Token和时间差,再下一次访问服务器的时候,利用访问的URL,用户编号,Token,访问时间(客户端本地时间戳 + 时间差)加密(可以是对称或者非对称加密)形成一个签名(signature),然后再重新拼接url+用户编号+访问时间+签名 去访问服务器。
-
服务器接收到客户端请求,首先判断访问时间是否超过当前时间30秒(自定义),如果有则拒绝请求,如果没有,根据用户编号到KV数据库获取Token,然后进行同样的算法得出签名与客户端传递的签名相比对,如果一样则方可通过,否则拒绝。
这种方案的好处:
-
让访问链接具备时效性,为什么要去判断这个访问时间?就是防止链接被重用(URL重放),避免接口被抓包获取,被重复使用,这样一来,就算完整的链接暴露了,这个链接的有效访问时间也只有30秒。
-
解决了用户单点登录,修改密码,登出的问题,因为Token是由服务器维护的,在用户信息以上操作的时候,我们对Token进行刷新即可,利用旧的Token再生成签名自然校验就不通过了。
关于客户端Token的保存
这里针对客户端而外再提一下关于Token的保存,毕竟客户端存在被反编译的风险的,因为本人对Android开发比较熟悉,这里就拿Android来举例:
-
做好代码的混淆和加固是最根本的。
-
不要把这些比较重要的信息写在配置文件,缓存文件,数据库里等,因为Android系统是可以通过Root拿到高级权限的。
-
我们可以把Token的生成策略存放so文件中,然后用JNI去调用,因为so文件是由C++编写的,反编译难度大大增大,可能有人会说那我拷贝so文件,不就依旧可以在其他地方使用了吗?所以我们也可以采取点其他方法,比如添加包名的验证等。
好了,文章到这里就收尾了,以上是本人对服务端身份校验的一些理解与思考,由于篇幅的限制,有些地方没有办法讲的太细,也肯定存在不足的地方(比如在登录,发送验证码,下订单等涉及到安全操作的时候使用HTTPS),希望有更好方案的朋友一起交流。
网友评论