由于blog各种垃圾评论太多,而且本人审核评论周期较长,所以懒得管理评论了,就把评论功能关闭,有问题可以直接qq骚扰我

JWT 的认证架构设计

JAVA 西门飞冰 56℃
[隐藏]

1.前言

作为后端应用,我们暴露的接口不是所有人都可以访问的,只有经过授权并拥有相应角色的时候才可以访问。那么针对于这种权限控制,该怎么设计呢?这个就是JWT的应用场景了

2.JWT介绍

2.1.JWT是什么

JWT(Json Web Token)是一个经过加密的包含用户信息的且具有时效性的固定格式字符串

2.2.JWT有什么用处

JWT最常见的场景就是授权认证,一旦用户登陆,后续每个请求都将包含JWT,系统在每次处理用户请求之前,都要先进行JWT安全校验,通过之后在进行处理。

2.3.JWT是什么样子

JWT是这个样子的:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9.NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU

JWT 由三部分组成,用 . 拼接

第一:标头Header,主要说明使用什么加密算法进行加密

标头Header
eyJhbGciOiJIUzI1NiJ9
-----------------------------------------
{
  "alg": "HS256",		// 使用的加密算法
  "typ": "JWT"			// 使用的token类型
}

第二:载荷Payload,我们在JWT中包含的自定义信息

载荷Payload
eyJzdWIiOiJ7XCJ1c.....
-----------------------------------------
{
  "sub": "1234567890",
  "name": "zhangsan",
  "admin": true
}

第三:签名Sign,结合前面的Header和Payload首先对其进行bash64的编码,在结合后端服务器持有的一个私钥,在进行相应的加密工作

签名Sign
NT8QBdoK4S-....
---------------------------------------------------------------------
HMACSHA256(base64UrlEncode(header) + "." +  base64UrlEncode(payload),  secret)

通过这样的一个公式,完成一个签名的工作,这个签名是用来进行数据校验的,只有我们传递的原始数据和附加的签名完全相同的情况下,我们才会认为这个JWT是一个合规的不是人为篡改的。

3.JWT 生成和解析

JWT是不需要自己造轮子的,在各个语言都有了良好的支持,Java提供了一个JJWT的组件来完成JWT的创建与校验工作,具体操作步骤如下:

(1)依赖导入:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

(2)JWT 生成代码:

public class JWTEncode {
    // 过期时间,1天
    private static long time = 1000*60*60*24;
    // 签名
    private static String signature = "wwwww";

    public static void main(String[] args) {
        JwtBuilder jwtBuilder = Jwts.builder();
        String jwtToken = jwtBuilder
                // 添加Header 信息
                .setHeaderParam("typ","JWT")
                .setHeaderParam("alg","HS256")
                // 添加payload信息,包含过期时间
                .claim("username","fblinux")
                .claim("role","leader")
                .setExpiration(new Date(System.currentTimeMillis()+time))
                .setId(UUID.randomUUID().toString())
                // 设置签名
                .signWith(SignatureAlgorithm.HS256,signature)
                .compact();
        System.out.println(jwtToken);
    }
}

生成内容

image-20221117201422089

(3)JWT 解析过程

public class JWTDecode {
    // 签名
    private static String signature = "wwwww";

    public static void main(String[] args) {
        // 要解密的对象
        String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImZibGludXgiLCJyb2xlIjoibGVhZGVyIiwiZXhwIjoxNjY4NzczNjQxLCJqdGkiOiI2OTQxMjczOS0yNmRlLTQxMGEtOThmMi1lOTg1OWNhZmRlZmUifQ.j2i5d6ZpxpusrhyssKzxU19WwWYZiGn8IUewX2qQryQ";
        // 通过签名对token进行解析,解析后会得到一个类似集合的数据结构
        JwtParser jwtParser = Jwts.parser();
        Jws<Claims> claimsJws = jwtParser.setSigningKey(signature).parseClaimsJws(token);
        Claims claims = claimsJws.getBody();
        // 获取JWT中的数据
        System.out.println(claims.get("username"));
        System.out.println(claims.get("role"));
        System.out.println(claims.getId());
        System.out.println(claims.getExpiration());
    }
}

解析结果

image-20221117201532313

3.1.特别说明

实际项目中,为了安全,在JWT加密中会引入客户端第一次请求的环境特征,比如IP地址,UA,MAC地址等。

这样做的目的主要是为了防止黑客获取到JWT数据后冒充请求,通过黑客客户端环境特征加密后,只要其中一项有变化,就会导致JWT校验不通过。这样就可以大幅度的提高我们系统的安全性。

4.JWT的认证架构设计方案

4.1.方案一:网关统一校验

统一认证是一个大而全的方案。这是一个简要的网关统一认证流程图:

image-20221114215326634

然后我们再来看统一认证的具体流程和各个组件的工作内容:

(1)首先作为客户端,无论是浏览器还是APP的应用,如果要访问后台的某一个私密接口的话,那么第一步就必须进行登陆的操作,作为登陆我们需要单独的构建出来一个认证中心的应用。

(2)作为认证中心,他的主要职责就是来接收客户端提交的用户名和密码的信息,然后首先在用户表里进行查询校验是不是有这个用户,密码是不是正确,校验通过以后,就根据当前用户的信息,在配合服务器本地持有的JWT密钥去生成JWT字符串,这时JWT字符串就是上面三个部分构成的长字符串。

(3)然后再把JWT和服务器返回的其他内容一起拼接成一个JSON返回给客户端。

登陆成功后,向用户返回的数据,可以参考为这个样子,其中包括服务器返回的状态码和消息

image-20221114215830843

这个完整的Json会被客户端的浏览器或者APP保存到本地,浏览器一般放到Cookie或者localstore中,APP的话一般放到安卓或者IOS的存储空间中。

(4)登陆成功以后,作为浏览器或APP再向后端某个具体的接口请求的时候,会把刚才保存的JWT附加在请求头或者Cookie中,向后台的应用网关进行发送。

(5)这里后台的应用网关就起到了一个拦截器的作用,作为统一认证模式下,应用网关收到了来自客户端发来的token以后,他会立即向认证中心发送一个验证签名的操作。在验证签名的过程中由认证中心来判断用户中心提交的JWT是否有效,有没有过期。

(6)验证签名完毕以后,认证中心会把JWT中的荷载信息提取出来进行返回,这个时候应用网关就获取到了当前登陆用户的信息,以及他对应的权限数据。

(7)然后应用网关再把数据转发给具体的业务模块。每一个业务模块在收到原始请求的参数之外,还可以接收到用户数据和权限数据,然后业务模块根据权限数据来判断当前这个接口是否允许被执行。

如上就是统一认证的过程了。

4.2.方案二:应用认证方案

应用认证方法和统一认证方案最大的区别就是浏览器和APP在获取到了JWT以后,随着请求头发送给网关,网关是不做任何额外处理的。

网关会把JWT原封不动的发送给后端业务模块。由后端业务模块完成JWT的签名验证和校验工作。

签名验证的工作还是由认证中心来完成的。

如下是应用认证的简要流程图:

image-20221114221411497

这样就衍生出一个新问题,作为我们业务模块可能有上百个方法,其中百分之七十都是需要验证签名的。对于这百分之七十的方法我们如何完成这个验签的过程呢?

解决方案:可以通过Spring AOP+自定义注解来完成,验签成功,返回方法里面的代码,验签失败则返回异常信息

@GetMapping("/xxx")
//自定义注解,利用AOP做验签
@CheckJwt 
public void xxx(){
    //Controller代码
}

4.3.两者区别

方案一:JWT校验无感知,验签过程无侵入,执行效率低,几乎每一个发往后台的请求,都需要进行签名验证,而且所有验签的工作都压到了网关上,网关就成了整个架构中的瓶颈所在,设计不好就有可能把整个业务拖垮,因此网关校验的这种方法适用于低并发企业级应用

方案二:控制更加灵活,有一定代码侵入,代码可以灵活控制,适用于追求性能互联网应用

5.JWT的续签问题怎么解决

5.1.为什么JWT要续签

续签就是延长JWT的有效时间,要是用户在操作过程中使用了过期的Token,会被后端服务拒绝访问并自动踢出到登陆页,续签就是用来防止用户在操作过程中被踢出到登陆页。

image-20221114224450971

5.2.JWT 为什么需要过期时间

1、JWT 要是不设置过期时间,会留下”太空垃圾”(长期存在并且有时候还可用的垃圾数据),后患无穷。

2、设想一下,作为当前的JWT要是没有设置过期时间,就意味着它永久有效放在客户端中。要是有人对客户端的网络请求进行抓包或者埋下了木马,拿到了JWT的字符串以后,就可以伪造客户身份来发起请求。

因此设置JWT有两个建议:

1、JWT 需要设置有效期,且不建议设置长时有效期。

2、续签JWT必须有退出机制,目的是不允许无限续签,要是JWT一直可以续签,就相当于没有设置过期时间。

下面就来看一下JWT如何续签,和认证一样也是两种方案:

5.3.不改变JWT实现续签

方案核心思想,将过期时间交给redis,让redis来控制JWT的有效时间。

这种情况的续签实现方案:引入了Redis,使用Redis的过期时间来控制JWT的有效性,实现简易流程图如下:

image-20221114225935351

然后我们再来详细看一下这种方案续签的具体流程和各个组件的工作内容:

1、客户端提醒用户登陆,用户登陆成功以后。一方面认证中心去颁发JWT,另一方面认证中心会向Redis写入一个键值对。

键值对中的键是通过一个MD5来生成的,其中的规则是用户的数据+客户端的环境特征,进行MD5加密。这么做主要是为了安全的目的。至于这里的值,就不重要了,后续处理用不到它。这里最重要的是EXP过期时间,这里定的是2小时,也就是这个JWT的最大存活时间。

2、客户端接收到JWT以后,向后端服务发起请求。后端服务接收到以后,首先要对JWT做有效性校验。具体做法是后端服务会从JWT中提取出用户特征在结合客户端当时发来的特征信息,按照同样的规则来生成MD5,然后去redis中进行查询判断是否存在。要是检查以后JWT不存在,就认为当前这个JWT一定是无效的,这个请求就会被立即拒绝。

当这个Key是有效的情况下,他会有一些分支条件,后端服务会去查询这个Key剩余的有效时间,要是剩余有效时间超过一个小时,作为后端服务不需要做任何额外的事情,正常处理用户请求。要是redis中有效时间少于一个小时的时候,后端除了正常的响应以为,还要执行redis的expire命令为现有的key增加一个小时过期时间,保证在后续的处理过程中,它还有足够的时间。

3、要是用户在JWT两个小时有效期内没有操作,redis中的key就会因为过期被自动删除。当用户在JWT两个小时有效期外再次执行操作的时候,因为Redis中Key不存在,后端服务会拒绝请求,并且让客户端重新登陆。

4、客户端要是想注销JWT,就直接向认证中心发送一个退出登陆请求,然后认证中心在Redis中删除对应的key即可。

5.4.改变JWT实现续签

这种方案一个核心的思路是:通过认证中心生成全新的token,再让客户端替代掉原有的token,来实现续期的功能,这种方案相比前面的redis来说是轻量级的,但是也意味着客户端持有的token是不断变化的

允许改变JWT的续签功能就简单多了

这里一个最核心的设计就是,客户端在发起登陆以后,认证中心返回的JWT不在是一个了,而是两个。一个是access_token 这个是我们日常向后端服务发起时主要校验的这个过期时间时30分钟,另一个是refresh_token ,它的作用是决定是否要续签,相当于一个刷新的标识,过期时间时一个小时。这两个token除了过期时间不一样之外,其他完全相同

下面是简易流程:

(1)客户端向后端服务发起请求时,会在请求头或者cookie中同时附带这两个token,然后向后端发起服务。要是在认证30分钟内发起请求的话,因为两个token都在有效期内,后端服务只需要校验access_token,然后提取其中数据进行后续处理即可。

image-20221115091953135

(2)客户度向后端服务发起请求的时间段是认证后的30~60分钟,access_token 已经过期,但是refresh_token 没有过期,这时后端服务逻辑就会发生变化,后端服务检查access_token过期以后,就会检查refresh_token ,要是refresh_token没有过期,后端服务就会把refresh_token 传入到认证中心的刷新接口中,然后认证中心重新生成两个token,分别是access_token 和 refresh_token,回传给后端服务,后端服务再把它放到响应头中,回传给客户端。

image-20221115091953135

(3)要是两个token都过期了,后端服务就会拒绝客户端的请求,客户端就会退回到登陆业务重新登陆,同时清除本地的token。

这种方案的问题:

问题一:客户端需要大量针对JWT续签做大量的改造工作,最明显的就是由原来的一个JWT变成两个JWT,同时返回的时候也要去监听响应的header信息进行相应的JWT替换。

问题二:过期临界时间 59分59秒,用户提交数据,到60分正好refresh_token过期,也会导致执行失败。如果对业务场景不是特别苛刻的话,这种情况可以不用考虑在内,概率实在太小了。

问题三:存在重复生成JWT的问题

5.5.续约时的重复生成JWT问题

有的客户端是多线程的,它向后端服务发起了两个请求,这两个请求发送的是同一个token,要是这两个请求的token都在超过30分钟,小于60分钟的范围,后端服务就会向认证中心发起续签的操作,因为是两次不同的请求,这次续签就会产生两组不同的JWT。比如请求2先结束执行,那么客户端就会把请求2新生成的JWT存入本地,紧接着请求1的JWT也生成了,也会完成JWT的写入操作,这时就会把请求2获取的JWT覆盖掉。

这种覆盖,按照当前的场景来看是没有太大的问题的,就算覆盖了JWT的有效期还是一个小时,只不过过期时间在秒这个级别上可以存在一丁点的区别,这种情况我们是可以接受的。

image-20221115094150011

转载请注明:西门飞冰的博客 » JWT 的认证架构设计

喜欢 (0)or分享 (0)