JWT实现单点登录
单点登录:单点登录的英文名叫做:Single Sign On(简称SSO),指在同一帐号平台下的多个应用系统中,用户只需登录一次,即可访问所有相互信任的系统。简而言之,多个系统,统一登陆。
常见的实现单点登录的方式
-
传统
session
认证 -
token+redis
-
token
传统Session认证
我们知道HTTP
本身是一种无状态的协议,这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,认证通过后HTTP
协议不会记录下认证后的状态,那么下一次请求时,用户还要再一次进行认证,因为根据HTTP
协议,我们并不知道是哪个用户发出的请求。
所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在用户首次登录成功后,在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这是传统的基于session
认证的过程。
缺点
-
服务器开销增大:因为每一个访问请求都会创建一个
Session
对象,如果当用户数量过多的时候服务器就会放过多的Session
,导致服务器开销增大 -
在分布式中不可用:当多台服务器做负载均衡的时候,第一次登录在服务器1中存下对应用户登录信息,下一次登录被分配到服务器2中还需要重新登陆。
当然俄油方案解决这个问题:
-
session
复制 -
将
session
统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件
-
-
会有跨域的问题
-
会有CSRF(Cross Site Request Forgery:跨站域请求伪造攻击):因为
Sessionid
是放在Cookie
中的,如果Cookie
被截获,用户就很容易遭到CSRF
攻击
token+redis
客户端登录,输入用户名和密码,后台进行验证,如果验证失败则返回登录失败的提示。如果验证成功,则服务器端生成 token 然后将 username
和 token
双向绑定 (可以根据 username
取出 token 也可以根据 token
取出username
)存入redis
,同时使用 token+userId
作为key把当前时间戳也存入redis
。并且给它们都设置过期时间。
当用户登录成功后,服务器端会回传一个Token给前端,当用户想通过前端访问比如订单服务或支付服务的时候。
前端会带着这个Token
到服务器中,服务器拿到Token
后先去Redis
中查询出Token
对应的username
。检查Token
是否过期。查询出来的username
就可以用来作为查询条件去数据库中查询到我们想要的信息了。前端拿到回传的token
后就可以直接去服务器中查找对应的数据可。
优点
其核心优点实服务端可以主动让token失效,并且解决了Cookie+Session
暴露的一些问题
缺点
-
Token+Redis
是中心化的,要能识别token
必须能访问该Redis
,要求每次token
都实时检测; -
占用
redis
存储空间 -
每次都要查询完
Redis
返回的username
后,还要去数据库中查询想要的信息,增大了服务器的压力
JWT(Json Web Token)
结构
-
Header
头部信息,主要声明了JWT
的签名算法等信息 -
Payload
载荷信息,主要承载了各种声明并传递明文数据 -
Signature
签名,拥有该部分的JWT
被称为JWS
,也就是签了名的JWS
,用于校验数据
在传输的时候,会将JWT
的3部分分别进行Base64
编码后用.
进行连接形成最终传输的字符串
整体结构是:header.payload.signature
例如:用默认的HS265(HmacSHA256)
签名算法
// 密钥 byte[] key = "1234567890".getBytes(); String token = JWT.create() .setPayload("sub", "1234567890") .setPayload("name", "looly") .setPayload("admin", true) .setKey(key) .sign();
生成的内容为:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWUsIm5hbWUiOiJsb29seSJ9. 536690902d931d857d2f47d337ec81048ee09a8e71866bcc8404edbbcbf4cc40
我们先来看下token验证的流程:
token验证的流程
-
客户端使用用户名和密码请求登录
-
服务端收到请求,验证用户名和密码
-
验证成功后,服务端会签发一个
token
,再把这个token
返回给客户端 -
客户端收到
token
后可以把它存储起来,比如放到cookie
中 -
客户端每次向服务端请求资源时需要携带服务端签发的
token
,可以在cookie
或者header
中携带 -
服务端收到请求,然后去验证客户端请求里面带着的
token
,如果验证成功,就向客户端返回请求数据
这种基于token
的认证方式相比传统的session
认证方式更节约服务器资源,并且对移动端和分布式更加友好。
token验证的优点
-
支持跨域访问:
cookie
是无法跨域的,而token
由于没有用到cookie
(前提是将token
放到请求头中),所以跨域后不会存在信息丢失问题 -
无状态:
token
机制在服务端不需要存储session
信息,因为token
自身包含了所有登录用户的信息,所以可以减轻服务端压力 -
更适用
CDN
:可以通过内容分发网络请求服务端的所有资料 -
更适用于移动端:当客户端是非浏览器平台时,
cookie
是不被支持的,此时采用token
认证方式会简单很多 -
无需考虑
CSRF
:由于不再依赖cookie
,所以采用token
认证方式不会发生CSRF
,所以也就无需考虑CSRF
的防御
通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json
字符串中,然后进行编码后得到一个JWT token
,并且这个JWT token
带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json
对象传输。
JWT的认证流程
-
首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个
POST
请求。建议的方式是通过SSL
加密的传输(HTTPS
),从而避免敏感信息被嗅探 -
后端核对用户名和密码成功后,将包含用户信息的数据作为
JWT
的Payload
,将其与JWT Header
分别进行Base64
编码拼接后签名,形成一个JWT Token
,形成的JWT Token
就是一个如同lll.zzz.xxx
的字符串 -
后端将
JWT Token
字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token
即可 -
前端在每次请求时将
JWT Token
放入HTTP
请求头中的Authorization
属性中(解决XSS
和XSRF
问题) -
后端检查前端传过来的
JWT Token
,验证其有效性,比如检查签名是否正确、是否过期、token
的接收方是否是自己等等 -
验证通过后,后端解析出
JWT Token
中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果
传统session认证的问题
-
每个用户的登录信息都会保存到服务器的
session
中,随着用户的增多,服务器开销会明显增大 -
由于
session
是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将session
统一保存到Redis
中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件 -
对于非浏览器的客户端、手机移动端等不适用,因为
session
依赖于cookie
,而移动端经常没有cookie
-
因为
session
认证本质基于cookie
,所以如果cookie
被截获,用户很容易收到跨站请求伪造攻击。并且如果浏览器禁用了cookie
,这种方式也会失效 -
前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,
cookie
中关于session
的信息会转发多次 -
由于基于
Cookie
,而cookie
无法跨域,所以session
的认证也无法跨域,对单点登录不适用
JWT的优点
-
可扩展性好 应用程序分布式部署的情况下,
session
需要做多机数据共享,通常可以存在数据库或者redis
里面。而jwt
不需要。 -
无状态
jwt
不在服务端存储任何状态。RESTful API
的原则之一是无状态,发出请求时,总会返回带有参数的响应,不会产生附加影响。用户的认证状态引入这种附加影响,这破坏了这一原则。另外jwt
的载荷中可以存储一些常用信息,用于交换信息,有效地使用JWT
,可以降低服务器查询数据库的次数。 -
JWT Token
是以JSON
加密形式保存在客户端的,而不是服务器中,可以减轻服务器压力
JWT的缺点
-
安全性:我们用的是
hutool
工具包内的JWT
生成token
,如果被人知道这个信息的话,token
是可以被解密的,不是很安全,不能放敏感信息。解决:加盐值(密钥)。每个项目的盐值都不能一样。
-
token
被拿到第三方使用。解决:对同一用户的同时登录数量进行限流。
-
一次性:无状态是
jwt
的特点,但也导致了这个问题,jwt
是一次性的。想修改里面的内容,就必须签发一个新的jwt
。(1)无法废弃:通过上面jwt的验证机制可以看出来,一旦签发一个
jwt
,在到期之前就会始终有效,无法中途废弃。例如你在payload
中存储了一些信息,当信息需要更新时,则重新签发一个JWT
,但是由于旧的JWT
还没过期,拿着这个旧的JWT
依旧可以登录,那登录后服务端从JWT
中拿到的信息就是过时的。为了解决这个问题,我们就需要在服务端部署额外的逻辑,例如设置一个黑名单,一旦签发了新的jwt
,那么旧的就加入黑名单(比如存到redis
里面),避免被再次使用。(2)续签:如果你使用
jwt
做会话管理,传统的cookie
续签方案一般都是框架自带的,session
有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变jwt
的有效时间,就要签发新的jwt
。最简单的一种方式是每次请求刷新jwt
,即每个http
请求都返回一个新的jwt
。这个方法不仅暴力不优雅,而且每次请求都要做jwt
的加密解密,会带来性能问题。另一种方法是在redis中单独为每个jwt
设置过期时间,每次访问时刷新jwt
的过期时间。可以看出想要破解
jwt
一次性的特性,就需要在服务端存储jwt
的状态。但是引入redis
之后,就把无状态的jwt
硬生生变成了有状态了,违背了jwt
的初衷。而且这个方案和session
都差不多了。
封装hutool的JWT工具
public class JwtUtil { private static final Logger LOG = LoggerFactory.getLogger(JwtUtil.class); /** * 盐值很重要,不能泄漏,且每个项目都应该不一样,可以放到配置文件中 */ private static final String key = "12306"; public static String createToken(Long id, String mobile) { DateTime now = DateTime.now(); DateTime expTime = now.offsetNew(DateField.SECOND, 10); Map<String, Object> payload = new HashMap<>(); // 签发时间 payload.put(JWTPayload.ISSUED_AT, now); // 过期时间 payload.put(JWTPayload.EXPIRES_AT, expTime); // 生效时间 payload.put(JWTPayload.NOT_BEFORE, now); // 内容 payload.put("id", id); payload.put("mobile", mobile); String token = JWTUtil.createToken(payload, key.getBytes()); LOG.info("生成JWT token:{}", token); return token; } public static boolean validate(String token) { JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes()); // validate包含了verify boolean validate = jwt.validate(0); LOG.info("JWT token校验结果:{}", validate); return validate; } public static JSONObject getJSONObject(String token) { JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes()); JSONObject payloads = jwt.getPayloads(); //把之前加进去的三个日期移除掉(签发时间、过期时间、生效时间) payloads.remove(JWTPayload.ISSUED_AT); payloads.remove(JWTPayload.EXPIRES_AT); payloads.remove(JWTPayload.NOT_BEFORE); LOG.info("根据token获取原始内容:{}", payloads); return payloads; } //验证,也可以写到test中 public static void main(String[] args) { createToken(1L, "123"); String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE2NzY4OTk4MjcsIm1vYmlsZSI6IjEyMyIsImlkIjoxLCJleHAiOjE2NzY4OTk4MzcsImlhdCI6MTY3Njg5OTgyN30.JbFfdeNHhxKhAeag63kifw9pgYhnNXISJM5bL6hM8eU"; validate(token); getJSONObject(token); } }