什么是 JWT?它是如何工作的?

松哥最近辅导了几个小伙伴秋招,有小伙伴在面小红书时遇到这个问题,这个问题想回答全面还是有些挑战,松哥结合之前的一篇旧文和大伙一起来聊聊。

一 无状态登录

1.1 什么是有状态

有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如 Tomcat 中的 Session。例如登录:用户登录后,我们把用户的信息保存在服务端 session 中,并且给用户一个 cookie 值,记录对应的 session,然后下次请求,用户携带 cookie 值来(这一步有浏览器自动完成),我们就能识别到对应 session,从而找到用户的信息。这种方式目前来看最方便,但是也有一些缺陷,如下:

  • 服务端保存大量数据,增加服务端压力
  • 服务端保存用户状态,不支持集群化部署

1.2 什么是无状态

微服务集群中的每个服务,对外提供的都使用 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性,即:

  • 服务端不保存任何客户端请求者信息
  • 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

那么这种无状态性有哪些好处呢?

  • 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意的迁移和伸缩(可以方便的进行集群化部署)
  • 减小服务端存储压力

1.3 如何实现无状态

无状态登录的流程:

  • 首先客户端发送账户名/密码到服务端进行认证
  • 认证通过后,服务端将用户信息加密并且编码成一个 token(一般是 JWT),返回给客户端
  • 以后客户端每次发送请求,都需要携带认证的 token
  • 服务端对客户端发送来的 token 进行解密,判断是否有效,并且获取用户登录信息

二 JWT

2.1 JWT 简介

JWT,全称是 Json Web Token, 是一种 JSON 风格的轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权:

JWT 作为一种规范,并没有和某一种语言绑定在一起,常用的 Java 实现是 GitHub 上的开源项目 jjwt,地址如下:https://github.com/jwtk/jjwt

2.2 JWT 数据格式

JWT 包含三部分数据:

  • Header:头部,通常头部有两部分信息:

    • 声明类型,这里是JWT
    • 加密算法,自定义

我们会对头部进行 Base64Url 编码(可解码),得到第一部分数据。

  • Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:

    • iss (issuer):表示签发人
    • exp (expiration time):表示token过期时间
    • sub (subject):主题
    • aud (audience):受众
    • nbf (Not Before):生效时间
    • iat (Issued At):签发时间
    • jti (JWT ID):编号

这部分也会采用 Base64Url 编码,得到第二部分数据。

  • Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露给客户端),通过 Header 中配置的加密算法生成。用于验证整个数据完整和可靠性。

生成的数据格式如下图:

注意,这里的数据通过 . 隔开成了三部分,分别对应前面提到的三部分,另外,这里数据是不换行的,图片换行只是为了展示方便而已。

2.3 JWT 交互流程

流程图:

步骤翻译:

  1. 应用程序或客户端向授权服务器请求授权
  2. 获取到授权后,授权服务器会向应用程序返回访问令牌
  3. 应用程序使用访问令牌来访问受保护资源(如 API)

因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,这样就完全符合了 RESTful 的无状态规范。

2.4 JWT 存在的问题

说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:

  1. 续签问题,这是被很多人诟病的问题之一,传统的 cookie+session 的方案天然的支持续签,但是 jwt 由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入 redis,虽然可以解决问题,但是 jwt 也变得不伦不类了。
  2. 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 secret 来实现注销,服务端 secret 修改后,已经颁发的未过期的 token 就会认证失败,进而实现注销,不过毕竟没有传统的注销方便。
  3. 密码重置,密码重置后,原本的 token 依然可以访问系统,这时候也需要强制修改 secret。
  4. 基于第 2 点和第 3 点,一般建议不同用户取不同 secret。

对于上面提到的这些问题,在目前具体的项目实践中,更多的是通过引入 Redis 来解决这些问题。

三 实战

说了这么久,接下来我们就来看看这个东西到底要怎么用?

3.1 环境搭建

首先我们来创建一个 Spring Boot 项目,创建时需要添加 Spring Security 依赖,创建完成后,添加 jjwt 依赖,完整的 pom.xml 文件如下:

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId>
</dependency>

然后在项目中创建一个简单的 User 对象实现 UserDetails 接口,如下:

public class User implements UserDetails {private String username;private String password;private List<GrantedAuthority> authorities;public String getUsername() {return username;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}//省略getter/setter
}

这个就是我们的用户对象,先放着备用,再创建一个 HelloController,内容如下:

@RestController
public class HelloController {@GetMapping("/hello")public String hello() {return "hello jwt !";}@GetMapping("/admin")public String admin() {return "hello admin !";}
}

HelloController 很简单,这里有两个接口,设计是 /hello 接口可以被具有 user 角色的用户访问,而 /admin 接口则可以被具有 admin 角色的用户访问。

3.2 JWT 配置

先准备一个 JWT 操作的工具类:

public class JwtUtils {public static final Integer EXPIRE_TIME = 7 * 24 * 60 * 60;public static final Integer REDIS_TOKEN_EXPIRE_TIME = 30 * 60;public static final String TOKEN_ISSUER = "javaboy";/*** 生成 JWT 字符串** @return*/public static String createJWT() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {Authentication authentication = SecurityContextHolder.getContext().getAuthentication();String jws = Jwts.builder()//设置头部信息(相当于 JWT 字符串第一部分的内容).header().add("type", "JWT").and()//相当于 JWT 字符串第二部分的内容//签名密钥.signWith(getPrivateKey())//设置过期时间.expiration(java.util.Date.from(java.time.Instant.now().plusSeconds(EXPIRE_TIME)))//设置用户名.subject(authentication.getName())//设置用户权限,相当于把用户权限转为字符串 admin,user,xxx.claim("authorities", authentication.getAuthorities().stream().map(a -> a.getAuthority()).collect(Collectors.joining(",")))//令牌签发时间.issuedAt(new Date()).issuer(TOKEN_ISSUER).compact();return jws;}public static PrivateKey getPrivateKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {ClassPathResource resource = new ClassPathResource("private_key.pem");byte[] privateKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream());String key = new String(privateKeyBytes, StandardCharsets.UTF_8);String privateStr = key.replace("-----BEGIN PRIVATE KEY-----", "").replaceAll(System.lineSeparator(), "").replace("-----END PRIVATE KEY-----", "");byte[] privateKeyDecodedBytes = Base64.getDecoder().decode(privateStr);PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyDecodedBytes);KeyFactory keyFactory = KeyFactory.getInstance("RSA");PrivateKey privateKey = keyFactory.generatePrivate(keySpec);return privateKey;}public static RSAPublicKey getPublicKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {ClassPathResource resource = new ClassPathResource("public_key.pem");byte[] publicKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream());String key = new String(publicKeyBytes, StandardCharsets.UTF_8);String publicStr = key.replace("-----BEGIN PUBLIC KEY-----", "").replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", "");KeyFactory keyFactory = KeyFactory.getInstance("RSA");byte[] publicKeyBase64Bytes = Base64.getDecoder().decode(publicStr);X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBase64Bytes);PublicKey rsaPublicKey = keyFactory.generatePublic(publicKeySpec);return (RSAPublicKey) rsaPublicKey;}public static Authentication verifyToken(String token) {try {Claims claims = Jwts.parser().verifyWith(getPublicKey()).build().parseSignedClaims(token).getPayload();String username = claims.getSubject();// user,admin,xxxString authorities = claims.get("authorities", String.class);// 解析用户权限List<SimpleGrantedAuthority> list = Arrays.stream(authorities.split(",")).map(s -> new SimpleGrantedAuthority(s)).collect(Collectors.toList());return UsernamePasswordAuthenticationToken.authenticated(username, null, list);} catch (Exception e) {//令牌校验失败return null;}}
}

接下来,在用户登录成功的时候,生成 JWT 字符串,并存入到 Redis 中,如下:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).csrf(c -> c.disable()).sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).formLogin(f -> f.successHandler((req, resp, auth) -> {//登录成功,生成 JWT 字符串try {String token = JwtUtils.createJWT();//将 JWT 字符串保存到 redis 中redisTemplate.opsForValue().set(auth.getName() + ":" + token, auth.getName());// 设置过期时间redisTemplate.expire(auth.getName() + ":" + token, JwtUtils.REDIS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);resp.setContentType("application/json;charset=utf-8");RespBean respBean = new RespBean(200, token, null);resp.getWriter().write(new ObjectMapper().writeValueAsString(respBean));} catch (NoSuchAlgorithmException e) {throw new RuntimeException(e);} catch (InvalidKeySpecException e) {throw new RuntimeException(e);}}).permitAll()).logout(l -> l.logoutSuccessHandler((req, resp, auth) -> {// 删除 redis 中的 tokenString key = auth.getName() + ":" + req.getHeader("Authorization").replace("Bearer ", "");System.out.println("key = " + key);redisTemplate.delete(key);resp.getWriter().write("logout success");}).logoutUrl("/logout").permitAll());http.addFilterAfter(new JwtFilter(redisTemplate), SecurityContextHolderFilter.class);return http.build();
}

最后再来一个校验的过滤器,在用法发送请求的时候,校验 JWT 字符串,如下:

public class JwtFilter extends GenericFilterBean {StringRedisTemplate redisTemplate;public JwtFilter(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {RespBean result = null;HttpServletRequest req = (HttpServletRequest) servletRequest;HttpServletResponse resp = (HttpServletResponse) servletResponse;resp.setContentType("application/json;charset=utf-8");try {//如果是登录请求,则直接放行if (req.getRequestURI().equals("/login")) {filterChain.doFilter(servletRequest, servletResponse);return;}//获取请求头中的tokenString token = req.getHeader("Authorization");if (token == null) {result = new RespBean(500, "令牌为空,请求失败", null);PrintWriter out = resp.getWriter();out.write(new ObjectMapper().writeValueAsString(result));} else {//验证tokentoken = token.replace("Bearer ", "");//验证token是否有效//校验通过,则返回认证后的 Auhtentication 对象,否则返回 nullAuthentication authentication = JwtUtils.verifyToken(token);String s = redisTemplate.opsForValue().get(authentication.getName() + ":" + token);if (s == null) {// 令牌失效result = new RespBean(501, "令牌失效,请重新登录", null);resp.getWriter().write(new ObjectMapper().writeValueAsString(result));} else {//令牌有效,检查令牌过期时间,如果临近过期,则为令牌续期Long expire = redisTemplate.getExpire(authentication.getName() + ":" + token);if (expire != null && expire < 10 * 60) {//说明令牌还有 10 分钟过期,则重新设置新的过期时间redisTemplate.expire(authentication.getName() + ":" + token, Duration.ofSeconds(JwtUtils.REDIS_TOKEN_EXPIRE_TIME));}SecurityContextHolder.getContext().setAuthentication(authentication);filterChain.doFilter(servletRequest, servletResponse);}}} catch (Exception e) {} finally {// 清除上下文SecurityContextHolder.clearContext();}}
}

关于这个过滤器,我说如下几点:

  1. 首先从请求头中提取出 Authorization 字段,这个字段对应的 value 就是用户的 token。
  2. 将提取出来的 token 字符串调用 JwtUtils.verifyToken 方法进行验证,将验证结果放到当前的 SecurityContext 中,然后执行过滤链使请求继续执行下去。

3.3 Spring Security 配置

最后,在 Spring Security 中配置这个过滤器。

http.addFilterAfter(new JwtFilter(redisTemplate), SecurityContextHolderFilter.class);

搞定!

3.4 测试

做完这些之后,我们的环境就算完全搭建起来了,接下来启动项目然后在 POSTMAN 中进行测试,如下:

登录成功后返回的字符串就是经过 base64url 转码的 token,一共有三部分,通过一个 . 隔开,我们可以对第一个 . 之前的字符串进行解码,即 Header,如下:

再对两个 . 之间的字符解码,即 payload:

可以看到,我们设置信息,由于 base64 并不是加密方案,只是一种编码方案,因此,不建议将敏感的用户信息放到 token 中。

接下来再去访问 /hello 接口,注意认证方式选择 Bearer Token,Token 值为刚刚获取到的值,如下:

可以看到,访问成功。

四 总结

这就是 JWT 结合 Spring Security 的一个简单用法。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.xdnf.cn/news/1550132.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章

排序(交换排序:快排)

快速排序&#xff1a; 写快排的注意事项 1.单趟排序hoare 2.不写优化只说优化就行 理想的情况下&#xff1a;每次排序都是二分&#xff0c;直到二分到最后&#xff0c;那就相当于递归高度次(logN)&#xff0c;每一层单趟排都是O(N)&#xff0c;时间复杂度O(NlogN) 空间复杂度就…

Wed前端--HTML基础

目录 一、开发工具 二、HTML文档结构 2.1头部head 2.1.1title标记 2.1.2元信息meta标记 具体实例 ​编辑 一、开发工具 最基础的开发工具是&#xff1a;HBuilder 二、HTML文档结构 HTML文档由头部head和主体body组成 头部head标记中可以定义标题样式&#xff0c;头部信…

【流计算】流计算概论

前言 作者在之前写过一个大数据的专栏&#xff0c;包含GFS、BigTable、MapReduce、HDFS、Hadoop、LSM树、HBase、Spark&#xff0c;专栏地址&#xff1a; https://blog.csdn.net/joker_zjn/category_12631789.html?fromshareblogcolumn&sharetypeblogcolumn&sharerI…

常用激活函数总结

文章目录 什么是激活函数激活函数的作用常用激活函数1.Sigmoid函数2.Softmax函数3.Tanh函数4.Relu函数5.LeakyRelu函数6.PRelu函数7.ELU函数8.SELU函数 什么是激活函数 激活函数&#xff0c;通俗讲&#xff0c;就是一个函数&#xff0c;针对某个神经元&#xff0c;就是将输入经…

智慧园区与智能城市融合发展的关键举措

随着科技的快速发展和城市化进程的加速&#xff0c;智慧园区和智能城市作为未来城市发展的重要方向备受关注。智慧园区注重企业创新与产业升级&#xff0c;而智能城市着眼于城市管理与居民生活的便利。它们之间的融合将促进城市发展的智能化、可持续化和协同化。以下是实现智慧…

【web开发网页制作】Html+Css郑州大学保卫处学校网页制作(5页面附源码)

HTMLCSS学校主题网页目录 &#x1f354;涉及知识&#x1f964;写在前面&#x1f367;一、网页主题&#x1f333;二、网页效果Page1、首页Page2、通知公告Page3、公告详情页Page4、新闻动态页Page5、服务专区 &#x1f40b;三、网页架构与技术3.1 脑海构思3.2 整体布局3.3 技术说…

华为OD机试 - 匿名信(Python/JS/C/C++ 2024 E卷 100分)

华为OD机试 2024E卷题库疯狂收录中&#xff0c;刷题点这里 专栏导读 本专栏收录于《华为OD机试真题&#xff08;Python/JS/C/C&#xff09;》。 刷的越多&#xff0c;抽中的概率越大&#xff0c;私信哪吒&#xff0c;备注华为OD&#xff0c;加入华为OD刷题交流群&#xff0c;…

【网络安全 白帽子】用技术合法挖漏洞,一个月能拿多少钱

现在很多白帽子&#xff08;网安工程师/渗透测试工程师&#xff09;都会在下班之后利用业余时间去补天之类的漏洞赏金平台挖漏洞&#xff0c;用技术合法给企业找出威胁存在&#xff0c;拿到漏洞赏金。 那么现在&#xff0c;一般人用网安技术挖漏洞一个月能拿多少钱&#xff1f…

C++进阶:AVL树保持平衡的秘诀

✨✨所属专栏&#xff1a;C✨✨ ✨✨作者主页&#xff1a;嶔某✨✨ AVL树的底层实现代码&#xff1a;BSTree.h 钦某/Clearning 什么是AVL树&#xff1f; • AVL树是最先发明的⾃平衡⼆叉查找树&#xff0c;AVL是⼀颗空树&#xff0c;或者具备下列性质的⼆叉搜索树&#xff1…

【Linux基础IO】Linux IO编程入门:揭秘动态库与静态库的秘密

&#x1f4dd;个人主页&#x1f339;&#xff1a;Eternity._ ⏩收录专栏⏪&#xff1a;Linux “ 登神长阶 ” &#x1f921;往期回顾&#x1f921;&#xff1a;Linux Shell &#x1f339;&#x1f339;期待您的关注 &#x1f339;&#x1f339; ❀Linux基础IO &#x1f4d2;1. …

在编写vue3项目过程中,不希望ts校验怎么设置

问&#xff1a; 现在我查询了很多设置 可以看到&#xff0c;设置了很多关闭校验&#xff0c;但是仍然有报错提示&#xff0c;提示当前类型不正确&#xff0c;应该怎么办&#xff1f; 回答&#xff1a; 从其他人的博客中获取灵感&#xff0c;因为vscode的自己的设置了类型检查…

DL_语义分割(学习笔记)

文章目录 图像分割1 常见分类1.1 语义分割1.2 实例分割1.3 全景分割 2 语义分割2.1 模型评价指标2.2 常用数据集2.3 转置卷积2.4 膨胀卷积2.5 感受野2.6 双线性插值2.7 FCN 图像分割 1 常见分类 1.1 语义分割 定义&#xff1a;【只判断类别&#xff0c;无法区分个体】 语义分…

Tableau 瀑布图应用示例

通过探索 10 个示例&#xff0c;将瀑布图的应用拓展到更深层次的业务分析&#xff01; 作为一种直观展示数据变化的图表&#xff0c;瀑布图被广泛应用在业务分析中。同时&#xff0c;借助 Tableau 2024.2 中的 Viz Extensions&#xff0c;如今我们可以快速在 Tableau 中实现瀑布…

【YashanDB知识库】崖山数据库Outline功能验证

本文内容来自YashanDB官网&#xff0c;具体内容请见(https://www.yashandb.com/newsinfo/7488286.html?templateId1718516) 测试验证环境说明 测试用例说明 1、相同版本下&#xff0c;新增表数据量&#xff0c;使统计信息失效。优化器优先使用outline的计划。 2、相同版本下…

CHI trans简介--Retry

总目录&#xff1a; CHI协议简读汇总-CSDN博客 Retry trans flow requester发送一个请求&#xff0c;该请求没有protocol credit(P-Credit); 也就是说&#xff0c;该请求不保证completer一定能够接收该trans;completer返回一个retry resp, RetryAck, 给到Requester;completer再…

逻辑回归(上):函数求导过程自推 LaTex 语法

背景 闲来无事翻了一下之前买的一个机器学习课程及之前记录的网络笔记&#xff0c;发现遇到公式都是截图&#xff0c;甚至是在纸上用笔推导的。重新整理一遍之前逻辑回归函数的学习笔记&#xff0c;主要是为了玩一下 LaTex 语法&#xff0c;写公式挺有意思的。 整理之前三篇笔…

A股收复3000点!外资积极看涨,对冲基金净买入量创三年来新高

9月27日&#xff0c;A股开盘后跳空上涨&#xff0c;连续四天的大幅上涨&#xff0c;9月26日&#xff0c;上证指数收复3000点大关&#xff0c;让一些温州股民感到兴奋不已&#xff0c;一些炒股群里沸腾了。但对于行情走势&#xff0c;股民们产生了分歧&#xff0c;有人是逢高减仓…

SWAP、AquaCrop、FVCOM、Delft3D、SWAT、R+VIC、HSPF、HEC-HMS......

全流程SWAP农业模型数据制备、敏感性分析及气候变化影响实践技术应用 SWAP模型是由荷兰瓦赫宁根大学开发的先进农作物模型&#xff0c;它综合考虑了土壤-水分-大气以及植被间的相互作用&#xff1b;是一种描述作物生长过程的一种机理性作物生长模型。它不但运用Richard方程&…

源2.0全面适配百度PaddleNLP,大模型开发开箱即用

近日&#xff0c;源2.0开源大模型与百度PaddleNLP完成全面适配。用户通过PaddleNLP&#xff0c;可快速调用源2.0预训练大模型&#xff0c;使用源2.0在语义、数学、推理、代码、知识等方面的推理能力&#xff0c;也可以使用特定领域的数据集对源2.0 进行微调&#xff0c;训练出适…

爬虫及数据可视化——运用Hadoop和MongoDB数据进行分析

作品详情  运用Hadoop和MongoDB对得分能力数据进行分析&#xff1b;  运用python进行机器学习的模型调理&#xff0c;利用Pytorch框架对爬取的评论进行情感分析预测&#xff1b;  利用python和MySQL对网站的数据进行爬取、数据清洗及可视化。