文章目录
本文主要介绍了JWT是什么,如何使用JWT以及实际开发中可能会遇到的有关JWT登录问题,比如token的续签、续期和登出问题等。
1 JWT概念
1.1 什么是JWT
Json Web Token
是通过数字签名的方式,以json为载体,在不同的服务之间安全的传输信息的技术。
1.2 JWT有什么用
一般使用在授权认证的过程中,一旦用户登录,后端返回一个token给前端,相当于后端给了前端返回了一个授权码,之后前端向后端发送的每一个请求都需要包含这个token,后端在执行方法前会校验这个token(安全校验),校验通过才执行具体的业务逻辑。
1.3 JWT的组成结构
JWT由Header
(头信息),PayLoad
(用户信息),Signature
(签名)三个部分组成
Header头信息主要声明加密算法:(具体算法对称不对称加密不作为研究内容)
通常直接使用 HMAC HS256这样的算法
{
"typ":"jwt"
"alg":"HS256" //加密算法
}
然后将头部进行base64加密
(该加密是可以对称解密的),构成了第一部分
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
PayLoad(载荷)
指定了七个默认字段供选择
iss:发行人
exp:到期时间
sub:主体
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
也可以自定义私有字段,但是不建议在此存放敏感信息,因为此部分可以解码还原出原始内容。虽然它可以解码,但是也不能修改这个内容。
{
"username":"zhangsan",
"name":"张三",
}
对其进行base64加密
,得到Jwt的第二部分
eyJ1c2VybmFtZSI6InpoYW5nc2FuIiwibmFtZSI6IuW8oOS4iSIsImFnZSI6MTgsInNleCI6IuWlsyIsImV4cCI6MTY0NzE0NTA1MSwianRpIjoiMTIxMjEyMTIxMiJ9
Signature 签证信息,此部分用于防止jwt内容被篡改。这个签证信息由三部分组成(由加密后的Header,加密后的PayLoad,加密后的签名三部分组成)
- header (base64后的)
- payload (base64后的)
- secret
base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐加密,然后就构成了jwt的第三部分,每个部分直接使用".
"来进行拼接
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
.
eyJ1c2VybmFtZSI6InpoYW5nc2FuIiwibmFtZSI6IuW8oOS4iSIsImFnZSI6MTgsInNleCI6IuWlsyIsImV4cCI6MTY0NzE0NTA1MSwianRpIjoiMTIxMjEyMTIxMiJ9.
5tmHCpcsS_VuZ2_z5Rydf2OpsviBGwB-fJE5aS7gKqE
1.4 JWT的优点和缺点
基于session和基于jwt的方式的主要区别就是用户的状态保存的位置,session是保存在服务端的,而jwt是保存在客户端的。
优点:
-
jwt基于json,非常方便解析。
-
可以在令牌中自定义丰富的内容,易扩展。
-
资源服务使用JWT可不依赖认证服务即可完成授权。
-
通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
缺点:
-
JWT 中的信息可以在客户端解码,因此敏感信息不应该存储在 JWT 中,尤其是不加密的情况下。
-
如果使用对称加密算法并且密钥被泄漏,攻击者可以使用该密钥签发有效的 JWT。为了防止这种情况发生,应该使用安全的加密算法,并妥善保管密钥。
-
JWT 不支持会话管理,也不能主动使令牌失效。因此,在某些情况下,需要实现其他机制来管理用户会话和授权状态。
-
JWT 一旦签发,就无法撤回或修改,除非到了过期时间。因此,如果令牌被盗用,攻击者可以使用它来获得未经授权的访问权限。
-
JWT 的长度相对较长,可能会影响网络传输性能。
2 使用JWT
2.1 引入jwt坐标
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2.2 生成token
//1.声明加密的算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//2.声明过期时间
long expMillis = System.currentTimeMillis() + 过期毫秒值;
Date exp = new Date(expMillis);
//3.配置jwt中要存入的信息
Map claims = new HashMap();
claims.put("id",1001);
claims.put("name","admin");
//4.创建密钥
String secretKey = "miyao"
//5.生成jwt
String token = Jwts.builder()
//配置jwt中需要保存的数据
.setClaims(claims)
//配置加密算法&加密的key
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
//配置过期时间
.setExpiration(exp)
//根据配置信息生成jwt
.compact();
2.3 解析token
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
int id = claims.get("id");
2.4 jwt工具类
/**
* jwt工具类
*/
public class JwtUtils {
/**
* 生成token加密令牌
* @param params => 存入的数据
* @param secretKey => 签名的密钥
* @param ttlMills => 过期时间(毫秒)
* @return 生成的jwt token
*/
public static String createToken(Map<String, Object> params, String secretKey, long ttlMills){
//1.声明加密的算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//2.声明过期时间
long expMillis = System.currentTimeMillis() + ttlMills;
Date exp = new Date(expMillis);
//3.生成jwt
String token = Jwts.builder()
//配置jwt中需要保存的数据
.setClaims(params)
//配置加密算法&加密的key
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
//配置过期时间
.setExpiration(exp)
//根据配置信息生成jwt
.compact();
return token;
}
/**
* 验证jwt, 解析token
* @param token => 加密令牌
* @param secretKey => 密钥
* @return 解析的结果集
*/
public static Claims parseToken(String token, String secretKey){
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
}
3 登录问题
3.1 有无状态登录
3.1.1 有状态登录
cookie+session
,所谓的状态就是在服务端存储session信息
。客户端访问服务端的时候,在cookie中携带sessionId,服务端根据sessionId就能才找到对应的session信息。
缺点:分布式支持不好,默认是内存存储,一般用的时候:把sessionId存到redis中
3.1.2 无状态的登录
服务端不存储session信息
,就需要token
机制,客户端访问服务端的时候,需要在header或者是cookie中传递token信息,并且token中是携带了用户信息的。一旦生成,不能修改。
优点:分布式支持好
缺点:实现比较麻烦,续约、登出
续约:每次访问重新生成一个新的token(不推荐);使用两个token,accesstoken refreshtoken(有效期长); 临近过期生成新的token
登出: 登出之后,把token的id存入redis的黑名单
3.2 单体项目登录问题
单体项目,可以使用session来维持会话,但是生产环境为了避免单点故障,一般都会部署多个节点,为了避免session不同步的问题,可以在nginx上开启ip_hash这种负载均衡算法,让来自同一个ip的客户端请求始终落到同一个tomcat上,就是所谓的session粘连
。
ip_hash
nginx提供ip_hash指令可实现session粘连需求,根据用户ip计算hash值,从而每次都分配到指定机器上,ip_hash支持配置weight权重。
但是ip_hash也有个不足,要求nginx必须是最前端的机器,即用户不经过其他服务中转,直接请求nginx,这样ip_hash才能生效。如果在nginx和用户中间有别的服务,比如SLB、LVS等负载集群,nginx的ip_hash指令可能会失效。
Nginx配置如下:
worker_processes 1;
events {
worker_connections 1024;
}
http {
upstream ipHashDemo{
ip_hash;
server 127.0.0.1:8081;
server 127.0.0.1:8082;
}
server{
listen 8888;
server_name 127.0.0.1;
location / {
proxy_pass http://ipHashDemo;
}
}
}
-
worker_processes 1; 表示 Nginx 使用单个 worker 进程。
-
events { worker_connections 1024; } 表示每个 worker 进程可以同时处理的最大连接数为 1024。
-
upstream ipHashDemo{…} 定义了一个名为 “ipHashDemo” 的负载均衡组,使用 IP 哈希算法,并将请求转发到两台后端服务器(分别监听 8081 和 8082 端口)。
-
server {…} 定义了一个虚拟主机,监听在本地 8888 端口,请求经过负载均衡组 “ipHashDemo” 处理并代理到后端服务器。
-
location / {…} 表示该虚拟主机对所有请求都进行代理。
根据上面的配置文件会启动一个 Nginx 实例,在监听 8888 端口时对所有请求进行负载均衡,ip_hash
在上面的配置文件中用于定义一个名为 “ipHashDemo” 的负载均衡组,并使用 IP 哈希算法对请求进行路由
。这样做的好处是在多个请求中,相同 IP 的请求总是会被路由到同一台后端服务器上,避免了一些请求需要重复地建立连接和发送状态信息的情况,提高了性能。
upstream hash
既然自带的ip_hash指令不好使,那么能不能自己指定一个字段来计算hash值,替代ip值,比如cookie或者request uri里面的一个能标识用户id的字段。
有个第三方的nignx模块nginx_upstream_hash-0.3.1可以实现该效果,该模块提供hash指令,可以指定一个变量用于hash,示例如下:
hash指令后面的变量可以设置为其他有效的nginx变量。
http {
upstream backend {
hash $arg_user_id consistent;
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}
server {
listen 80;
location / {
proxy_pass http://backend;
}
}
}
在这个配置中,我们定义了一个名为 “backend” 的负载均衡后端,指定了哈希指令 “hash $arg_user_id consistent”,即使用 GET 请求参数中的 “user_id” 变量作为哈希键,并且启用一致性哈希算法。然后我们将三个后端服务器添加到 upstream 中。
最后在 server 块中,我们将请求转发到 “backend” 负载均衡组。由于我们在 upstream 中启用了哈希算法并指定了要使用的变量,因此同一用户的请求会被分配到同一台后端服务器上。
3.3 分布式项目登录问题
redis存储session信息
用户登录成功以后,服务端生成一个uuid(token),同时把uuid作为key,用户信息作为value存储到redis中,然后把uuid返回给客户端。客户端在访问服务端接口的时候,可以在cookie或者header中传递这个uuid,服务端收到请求,首先读取这个uuid,然后根据uuid去redis中查找用户。这种方式也可以避免session的不同步问题,因为现在是存储到了多个节点都可以访问的redis中。
3.3.1 解析token并向下游的微服务传递
以admin为例,admin中所有的接口(除了登录),都需要登陆以后才能访问。admin服务接口中需要知道是哪个用户的请求。所以网关直接把解析出来的userId传递给下游的微服务即可。
如何传递?
// 把userId传递给下游的微服务
request.mutate().header("userId", "" + userId);
使用的是spring5提供webflux的api向request中追加请求头
。
3.3.2 完整的登录和鉴权过程
1)前端用户传递的用户名和密码,服务端收到请求,根据用户名到db查询记录,根据db中记录的password与前端传递的password做匹配,如果能匹配成功,则生成jwt token
返回给前端,token中携带了userId。
2)前端会把token保存到local storage
(本地存储器)中,在随后的访问中,在header中携带上这个token。
3)前端的请求首先是到网关,网关中有一个全局的过滤器
,会从请求中读取header中的token,解析,把解析出来的userId放入http的header中继续向下游的微服务传递,header的key=userId。
4)微服务中有一个拦截器
,在拦截器中拦截请求,从header中解析出userId,然后保存到ThreadLocal中。
5)在微服务的接口中,就可以使用ThreadLocal
来获取用户信息。
3.3.3 Token续签问题
单token方案
- 将 token 过期时间设置为15分钟;
- 前端发起请求,后端验证 token 是否过期;如果过期,前端发起刷新token请求,后端为前端返回一个新的token;
- 前端用新的token发起请求,请求成功;
- 如果要实现每隔72小时,必须重新登录,后端需要记录每次用户的登录时间;用户每次请求时,检查用户最后一次登录日期,如超过72小时,则拒绝刷新token的请求,请求失败,跳转到登录页面。
另外后端还可以记录刷新token的次数,比如最多刷新50次,如果达到50次,则不再允许刷新,需要用户重新授权。
双token方案
- 登录成功以后,后端返回
access_token
和refresh_token
,客户端缓存此两种token; - 使用
access_token
请求接口资源,成功则调用成功;如果token超时,客户端携带refresh_token
调用token刷新接口获取新的access_token
; - 后端接受刷新token的请求后,检查
refresh_token
是否过期。如果过期,拒绝刷新,客户端收到该状态后,跳转到登录页;如果未过期,生成新的access_token
返回给客户端。 - 客户端携带新的
access_token
重新调用上面的资源接口。 - 客户端退出登录或修改密码后,注销旧的token,使
access_token
和refresh_token
失效,同时清空客户端的access_token
和refresh_toke
。
1, 登录成功,生成两个token存到cookie或者local storage (一长一短)
2, 发起请求,网关检验token(短),验证通过则添加到请求头
并放行路由到微服务,不通过则说明过期或者被篡改, 封装响应码通知前端
3, 前端接收通过,将token(长)发送给后端请求刷新token的请求,后端检验token(长),验证通过则重新生成两个token返回给前端
,然后添加到请求头并放行路由到微服务,不通过则说明过期或者被篡改
3.3.4 Token续期处理
用户登录完成以后,服务端会生成两个token,一个是有效期比较短的accesstoken,比如2小时,还有一个是有效期长的refreshToken,比如30天,客户端会把这两个token都保存到客户端本地存储
客户端在随后访问服务端接口的时候,需要在header中携带accesstoken。请求首先是到网关,网关会做token过期时间的判断,如果token没过期则正常放行
到后端的微服务,如果token已经过期,网关会返回一个特殊的代表token过期的响应码4001
,客户端收到4001状态码的响应以后,不会向客户端提示失败,而目继续访问服务端提供的refresh token的请求
,这个请求会返回一个新的accesstoken和refreshtoken,客户端会继续使用这个新的accesstoken去访问服务端的接口
。
3.3.5 Token登出处理
生成token的时候,会给token设置一个token id
当用户退出的时候,把token id
存放到redis中,key: token id,value:userld
,有效期设置2小时
网关收到请求的时候,首先是根据token id
去redis
中查询,如果能查到值,说明token已经退出了,则返回token已经失效,如果查不到,则说明token是有效的,继续后续的业务逻辑外理。
3.4 如何获取客户端的各种信息
HttpServletRequest request = ServletActionContext.getRequest();
System.out.println("浏览器基本信息:"+request.getHeader("user-agent"));
System.out.println("客户端系统名称:"+System.getProperty("os.name"));
System.out.println("客户端系统版本:"+System.getProperty("os.version"));
System.out.println("客户端操作系统位数:"+System.getProperty("os.arch"));
System.out.println("HTTP协议版本:"+request.getProtocol());
System.out.println("请求编码格式:"+request.getCharacterEncoding());
System.out.println("Accept:"+request.getHeader("Accept"));
System.out.println("Accept-语言:"+request.getHeader("Accept-Language"));
System.out.println("Accept-编码:"+request.getHeader("Accept-Encoding"));
System.out.println("Connection:"+request.getHeader("Connection"));
System.out.println("Cookie:"+request.getHeader("Cookie"));
System.out.println("客户端发出请求时的完整URL"+request.getRequestURL());
System.out.println("请求行中的资源名部分"+request.getRequestURI());
System.out.println("请求行中的参数部分"+request.getRemoteAddr());
System.out.println("客户机所使用的网络端口号"+request.getRemotePort());
System.out.println("WEB服务器的IP地址"+request.getLocalAddr());
System.out.println("WEB服务器的主机名"+request.getLocalName());
System.out.println("客户机请求方式"+request.getMethod());
System.out.println("请求的文件的路径"+request.getServerName());
System.out.println("请求体的数据流"+request.getReader());
BufferedReader br=request.getReader();
String res = "";
while ((res = br.readLine()) != null) {
System.out.println("request body:" + res);
}
System.out.println("请求所使用的协议名称"+request.getProtocol());
System.out.println("请求中所有参数的名字"+request.getParameterNames());
Enumeration enumNames= request.getParameterNames();
while (enumNames.hasMoreElements()) {
String key = (String) enumNames.nextElement();
System.out.println("参数名称:"+key);
}