【记录 撸一个博客系统】 10.登陆注册-看看laravel自带的鉴权 聊聊JWT

JWT官网https://jwt.io/introduction/

参考
https://www.jianshu.com/p/a2efb2c8dcde
https://segmentfault.com/a/1190000009981879
https://www.jianshu.com/p/606cc5f0b936
https://www.jianshu.com/p/d1644e281250
https://learnku.com/articles/17883

什么是JWT

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。

jwt的特点

简洁(Compact): 可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快。
自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库或缓存。

jwt消息结构

jwt有3个组成部分,分别是

头部(header)

{"alg":"HS256","typ":"JWT"}

  • 声明类型,这里是jwt
  • 声明加密的算法,通常直接使用HMACSHA256,就是HS256了

其他算法
【记录 撸一个博客系统】 10.登陆注册-看看laravel自带的鉴权 聊聊JWT_第1张图片

载荷(payload)

承载消息具体内容的地方 {"sub":"1234567890","name":"John Doe","iat":1516239022}

  • 标准中注册的声明
    - iss: jwt签发者
    - sub: jwt所面向的用户
    - aud: 接收jwt的一方
    - exp: jwt的过期时间,这个过期时间必须要大于签发时间
    - nbf: 定义在什么时间之前,该jwt都是不可用的.
    - iat: jwt的签发时间
    - jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
  • 公共的声明 可以放用户的基本信息(非敏感信息)
  • 私有的声明 私有声明是提供者和消费者所共同定义的声明 (不要放敏感信息)

签证(signature)

base64UrlEncode(header) + "." +
base64UrlEncode(payload),
(我的密钥)
)
  • 头部-header (base64后的)
  • 载荷-payload (base64后的)
  • 密钥-secret
    然后HMACSHA256只有两个参数,
  • base64后的头部 + “.” + base64后的载荷
  • 密钥-secret

流程

  • 初次登录:用户初次登录,输入用户名密码
  • 密码验证:服务器从数据库取出用户名和密码进行验证
  • 生成JWT:服务器端验证通过,根据从数据库返回的信息,以及预设规则,生成JWT
  • 返还JWT:服务器的HTTP RESPONSE中将JWT返还
  • 带JWT的请求:以后客户端发起请求,HTTP REQUEST
  • HEADER中的Authorizatio字段都要有值,为JWT
  • 服务器验证JWT
    【记录 撸一个博客系统】 10.登陆注册-看看laravel自带的鉴权 聊聊JWT_第2张图片

token 的三个时间

一个 token 一般来说有三个时间属性,其配置都在 config/jwt.php 内。

有效时间

有效时间指的的是你获得 token 后,在多少时间内可以凭这个 token 去获取内容,逾时无效。

// 单位:分钟
'ttl' => env('JWT_TTL', 60)

刷新时间

刷新时间指的是在这个时间内可以凭旧 token 换取一个新 token 。例如 token 有效时间为 60 分钟,刷新时间为 20160 分钟,在 60 分钟内可以通过这个 token 获取新 token ,但是超过 60 分钟是不可以的,然后你可以一直循环获取,直到总时间超过 20160 分钟,不能再获取。

'refresh_ttl' => env('JWT_REFRESH_TTL', 20160)

宽限时间

宽限时间是为了解决并发请求的问题,假如宽限时间为 0s ,那么在新旧 token 交接的时候,并发请求就会出错,所以需要设定一个宽限时间,在宽限时间内,旧 token 仍然能够正常使用。
// 宽限时间需要开启黑名单(默认是开启的),黑名单保证过期token不可再用,最好打开

'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true)
// 设定宽限时间,单位:秒
'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 60)

总结

  1. 使用场景
  • 在Web应用中,别再把JWT当做session使用,绝大多数情况下,传统的cookie-session机制工作得更好
  • JWT适合一次性的命令认证,颁发一个有效期极短的JWT,即使暴露了危险也很小,由于每次操作都会生成新的JWT,因此也没必要保存JWT,真正实现无状态。
  1. 优点
  • 节省服务器的资源:因为服务端无需维护一个状态,因此能够节省服务端原先保存这些状态所花费的资源
  • 适合分布式:因为服务端无需维护状态,因此如果服务端是多台服务器组成的分布式集群,那么无需像『有状态』一样互相同步各自的状态。
  • 时间换空间:因为 token 的校验时通过签名校验来进行的,签名校验消耗的是 CPU 时间,而『有状态』是需要通过客户端提供的凭据对服务端现有的状态进行一次查询,消耗的是 I/O 和内存、磁盘空间。通常对于一个 Web 服务来说,其属于 I/O 密集型,因此通过时间换空间这一操作,可以提高整体的硬件使用率。
  1. 缺点
  • 安全性:由于jwt的payload是使用base64编码的,并没有加密,因此jwt中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全。
  • 性能:jwt太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致jwt非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以jwt一般放在local storage里面。并且用户在系统中的每一次http请求都会把jwt携带在Header里面,http请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用jwt的http请求比使用session的开销大得多。
  • 一次性
    无状态是jwt的特点,但也导致了这个问题,jwt是一次性的。想修改里面的内容,就必须签发一个新的jwt。

JWT 的安全问题

既然主要使用场景是鉴权,那么安全问题就是不得不考虑的问题了。下面对 JWT 可能需要的安全问题都进行一次深入的探讨并寻求最佳的解决方案。

  1. 重放攻击
    重放攻击是通过把原先的包进行一次重放来进行攻击的手段。需要先明确是的 cookie + session 也是存在重放攻击的问题的。
    常用的防范重放攻击的措施主要有以下几种:
  • timestamp
    在请求中夹带一个时间戳,设置较短的有效期,如果一个新来的请求的请求时间超过了请求中的有效期,则认为无效。但是这种策略也存在问题,即如果一个黑客『眼疾手快』在有效期以内将你的包进行了重放, 那就来攻击成功。
    这种策略对应到 JWT 中就是给 token 设置一个较短的有效期。
  • nonce
    在请求中夹带一个随机字符串,这个字符串传送到客户端后即存入客户端的黑名单中,如果一个新来的请求其中存在的随机字符串已经在黑名单中则认为无效。但是显然,这个策略存在巨大的问题:服务端需要维护一个黑名单库,这个库的大小会随着业务运行的时间而变得无比巨大,从而严重影响效率。
    这种策略对应到 JWT 中就是给 token 设置一个黑名单,但是不设置有效期。
  • timestamp + nonce
    在请求中夹带一个随机字符串和一个时间戳,如果一个新来的请求,其随机字符串已经在黑名单中则认为无效,或者一个请求的的请求时间超过了其有效期,则也认为其无效。这样黑名单的范围只需设置为时间戳策略的有效期范围即可。
    这种策略对应到 JWT 中就是给 token 既设置一个黑名单,又设置一个有效期。
  • 挑战 - 应答
    这个其实和 timestamp + nonce 策略一样,只是随机字符串是有服务端生成给客户端的,客户端携带服务端所给的随机串来请求。这样有什么好处呢?服务端可以通过一个加密算法来生成这个串,使其和时间戳相关,同时客户端又无法伪造。这样就不需要维护黑名单了。同样也是时间换空间的策略。但是显然每次或几次请求就要进行一次与预请求以得到随机串,并不是特别方便,造成的额外消耗也有待考量。
  • 序列号
    通过在请求中嵌入一个序列号,每次请求依次加一,如果一个请求的序列号早已用过,则认为无效。但是这个要用逻辑额外一个全局序列号,并不是特别方便。
  • HTTPS
    终极解决方案了,HTTPS 在握手过程中会自动维护一个隐式序列号,解决了上面要自己维护序列号的问题。
    注意:以上均没有讨论客户端主动重放的问题,有兴趣的同学可以自己研究一下。
  1. token 被盗
    因为 token 中包含了登陆状态,因此一旦 token 被盗,那么就会被人盗用身份。那么 token 针对被盗的防范措施整理如下:
  • 使用 HTTPS 传输:从传输层的角度解决问题
  • HTTPOnly:从存储层的角度解决问题,防止 XSS 攻击窃取 cookie,但是这种方案其实存在问题,因为这样 js 就无法读取 token 并把它加到 header 头中了。所以不开启 HTTPOnly 的话必须要额外注意防范 XSS 攻击。
  • 在 token 中嵌入客户端指纹:通过客户端指纹,即使黑客盗取了你的 cookie,他也无法用你的 cookie 进行请求。
  • 设置较短的 token 有效期:这样如果 token 被盗,只要超过一定时限就无法使用。

jwt的注销问题

  1. 客户端主动注销
    客户端直接删掉这个token,再申请一个。然后就有了个可以用但是没人用的token。
    进化:加一个注销,获取的同时注销上一个。注销的就加入黑名单,不让使用。(不会过大,过期了就可以移除黑名单了)

  2. 服务端主动注销 \ 用户修改密码
    给每个用户分配一个secret,也就是一个用户只有一个token可以用。但是这就失去了意义,还不如维护session。

  • 预黑名单

把要注销的用户的 uuid 和当前时间(TIME) 组成 key-value 对加入预黑名单,下次请求来时,若其 uuid 和黑名单中的对应,并且签发时间在 TIME 之前,则将其注销。这样查找范围就是未过期但又要注销的用户。并且在实现逻辑上这个预黑名单可以和签名的黑名单做到一起。

  1. 关于黑名单策略的补充:

有人可能会觉得黑名单也是一种状态,用这种策略实现的 JWT 并不能算纯正的无状态。这种说法没错,但是考虑每次要检索的数据范围可以得到下面一个关系:

未过期但要提前注销的用户或 token 数 < 所有已登录用户数 < 所有用户数

此处的『 < 』基本可以看成『远远小于』,所以黑名单策略虽然也算有状态,但是其维护的状态数也是特别小的。

可见 『黑名单』策略能够有效解决 JWT 的注销问题。

JWT的续签问题

session 可以自动续签,那 token 如何实现自动续签呢?我们先仔细分析一下在 web 和 app 环境中,token 分别如何续签。先具体分析 web 续签和 app 续签分别是什么样的具体需求。

  • web
    超过一段时间没有请求,需要重新登录,这个时间一般设置为 1-2 小时
  • app
    超过一段较长的时间没有请求,需要重新登录,这个时间一般为 15-30 天

那这个需求可以如何实现呢?

方式一
  • 服务端接管刷新
  • token 设置一个『过期时间』
  • token 过期后但是仍在『刷新时间』内时仍然可刷新
  • token 过期后超过『刷新时间』就不能再刷新,需重新登录

web

假设一个 token 的签发时间为 12:00,需求为 2h 未进行请求就要重新登录。则过期时间为 1h,刷新时间为 3h。

那么在 12:00 - 13:00 其都是可以正常使用的,如果在 13:00 - 15:00 进行请求,服务端自动换一个新 token 给客户端,达成续签。

如果 13:00 -15:00 之间没有进行请求,而是在 15:00 之后进行的请求,那么判断过期,需重新登录。

这样的话,最终的实现效果是:token 过期 2h 后需要重新登录 ,而不是 token 2h 未使用需要重新登录,导致的结果是,用户是 2 - 3h 未进行请求,需要重新登录。比设定的需求要多一个小时的不确定时间,但这也是没办法的办法了,至于会不会对业务造成影响,看具体需求吧,大多数的情况还是不会的。

app

和 web 端类似,设置成更长的时间周期即可。

对使用 Laravel 开发并使用 tymon/jwt-auth 这个插件的开发者,有个必须要注意的地方。

此处进行 token 的刷新并不是通过 refresh 这个操作获得新 token,因为这样 token 在不断的刷新过程中会达到一个刷新时间的上限。而上面的逻辑是每次都新签发一个 token,只要不断签就能够一直使用下去。 然后这里的旧 token 放入黑名单,黑名单有效期设置为『刷新时间』—— 3h。

当然如果开发者觉得这样不断签就能够一直使用不太好,那就可以设置更长的刷新时间,用 refresh 操作来获取新 token,刷新时间保证每次登陆得到 token 后,即使每次及时续签,最终也不会超过刷新时间。

然后这里又会出现一个新坑:

如果刷新时间设置为 14 天,过期时间设置为 2h。

token A 在 『 <= 14 天 』时刷新得到 token B,此时若再拿 token A 去请求刷新,肯定是不允许,否则 token 会出现『 1 变 N 』的问题,所以显然必须设置一个黑名单去放这些已过期但是又已经刷新过的 token。而这个黑名单的有效期范围应当为 token 的刷新期,即 14 天。然后你会发现对于每个用户每次登陆,需要维护的黑名单 token 数目最大可达 14 * 24 / 2 = 168 个,黑名单变得很大。

所以,如果要使用 refresh 操作,刷新时间务必是过期时间的尽量小的倍数。

方式二
  • 每次请求 token 都进行一次刷新
  • token 设置一个过期时间
  • token 过期后无法再刷新
  • token 没必要设置刷新时间了

web

假设一个 token 的签发时间为 12:00,需求为 2h 未进行请求即过期。则设置有效期 2h,不需要设置刷新期。那么每次请求都会把一个 token 换成一个新 token。如果 2h 没有进行请求,那么上一次请求的到的 token 就会过期,需要重新登录。同样是不断签就能一直使用下去。

如果想要和上面一样,不希望永久续签,则设置一个刷新时间即可。这个刷新时间不会导致进一步膨胀。

app

和 web 端类似,设置更长时间即可。

然后又到了问题时间:

每次都刷新 token,带来的性能影响如何?

以前每次请求,需要进行一次 token 签名校验,而现在是要签发一个新 token,进行的都是一次签名运算,那么运算量即从 n 变成 2n。

其次,每次刷新都要把旧 token 加入黑名单,会导致黑名单特别大,远远比方式一的设置刷新期大。

每次都刷新 token,并发请求时会不会因为 token 刷新而导致只有一个请求成功?

答案是确实会导致这个问题,怎么解决呢?设置一个宽限时间,每次 token 刷新后,原来逻辑应该是立刻不可用,现在设置一个宽限时间,让其在 n 秒之内仍然可用即可。

总之,这种策略会导致花费的 CPU 运算翻倍,并导致巨大的黑名单,然后必须设置一个宽限时间以解决并发请求问题,至于宽限时间会不会带来安全问题,微乎其微吧。

黑名单膨胀的解决方案

上面讲到,对于方式一【限定不能一直续签】,会导致巨大的黑名单,对于方式二,总会导致一个更加巨大的黑名单。那有没有解决方案呢?当然是有的。

我们可以这么想,既然一个 token 进行了刷新,那么签发时间在这次刷新之前的即可认为无效。于是,和上面的『预黑名单』策略类似,我刷新时不是把一个 token 加入黑名单,而是把 uuid-refresh_time 组成 key-vakue 对加入黑名单,这样针对每个用户的每次登陆,要存储到黑名单中的条目数就从 N 个变成了一个。

但是这样还要考虑一个问题:就是一个用户开两个浏览器,在不同的时刻在同一个系统都登陆了(假设业务允许),那么一个浏览器的 token 刷新就可能会导致另一个浏览器登陆失效。所以存储在黑名单中的 key-value 应该再加一个 key 以代表每次登陆,并且这个 key 要在 JWT 的载荷中随着刷新一直传承。

基于以上的优化,黑名单的大小变成了:每个用户同时登陆的系统个数之和,就变的和 cookie + session 一样了。

比如,A 系统(假设 2h 过期时间,14 天刷新时间),你用一个浏览器登陆了你的账号,我用 Chrome 浏览器登陆了我的账号,然后我又用 QQ 浏览器再登陆我的账号,那么黑名单的大小就为 : 1 + 2 = 3

而对于方式一【限定不能一直续签】,黑名单的大小(最大):168 + 168 * 2

而对于方式二,黑名单的大小为:你在 2h 内请求的次数 x ,我在 Chrome 浏览器请求的次数 y,我在 QQ 浏览器请求的次数 z 之和,即:x + y + z

总结

如果要解决续签问题,方式一【可以一直续签】是个比较好的解决方案,虽然会带来一点小问题,但是并不会有太大的影响。方式二【限定不能一直续签】和 每次刷新会让黑名单的维护量和有状态差不多,但是有更高的安全性。

推荐:
https://learnku.com/articles/10885/full-use-of-jwt
https://learnku.com/articles/10889/detailed-implementation-of-jwt-extensions

你可能感兴趣的:(PHP)