什么是 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 交互流程
流程图:
步骤翻译:
- 应用程序或客户端向授权服务器请求授权
- 获取到授权后,授权服务器会向应用程序返回访问令牌
- 应用程序使用访问令牌来访问受保护资源(如 API)
因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,这样就完全符合了 RESTful 的无状态规范。
2.4 JWT 存在的问题
说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:
- 续签问题,这是被很多人诟病的问题之一,传统的 cookie+session 的方案天然的支持续签,但是 jwt 由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入 redis,虽然可以解决问题,但是 jwt 也变得不伦不类了。
- 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 secret 来实现注销,服务端 secret 修改后,已经颁发的未过期的 token 就会认证失败,进而实现注销,不过毕竟没有传统的注销方便。
- 密码重置,密码重置后,原本的 token 依然可以访问系统,这时候也需要强制修改 secret。
- 基于第 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; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public 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,xxx String 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 中的 token String 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; } @Override public 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; } //获取请求头中的token String token = req.getHeader("Authorization"); if (token == null) { result = new RespBean(500, "令牌为空,请求失败", null); PrintWriter out = resp.getWriter(); out.write(new ObjectMapper().writeValueAsString(result)); } else { //验证token token = token.replace("Bearer ", ""); //验证token是否有效 //校验通过,则返回认证后的 Auhtentication 对象,否则返回 null Authentication 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(); } } }
关于这个过滤器,我说如下几点:
- 首先从请求头中提取出 Authorization 字段,这个字段对应的 value 就是用户的 token。
- 将提取出来的 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 的一个简单用法。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
蓝鲸观测平台:统一观测数据关联模型探索
前 言 本文为蓝鲸观测平台数据模块负责人 在 蓝鲸智云 和 DeepFlow 社区 合办的第六场 eBPF 零侵扰可观测性 Meetup 上的演讲,原来题为根因定位关键:统一观测数据关联模型探索 概 述 根因分析高度依赖可关联的观测数据。蓝鲸在构建了包含 traces、logs、metrics 等多种数据类型的复杂数据系统的基础上,探索通过整合 CMDB、K8s、eBPF 等数据源,利用实体关联, 网络关联,自定义关联等关联手段, 构建了统一的观测数据关联模型,为用户全景视图能力,大语言模型智能跟因分析能力等场景,构建了关键的数据基础,取得了初步的效果,以下是我们的分享,主要分为三部分: 从监控到观测转型的困境 观测数据关联模型探索 关联模型实践案例 本文提供了完整演讲稿(下面章节) 及 AI 总结(文章最后一个章节),并在文章最后附带上了演讲原视频,读者可以根据需求选择阅读。 演讲稿原稿 现在我分享的是我们最近关注的一个点,就是统一观测数据关联模型探索。大家一看这个话题可能会有点懵,接下来我就从以下几个方面来给大家阐述一下,为什么会存在这个关联模型? 第一部分是从监控到观测转型的困境...
- 下一篇
万字长文理解无界队列和有界队列和适用场景
>大家好,我是 V 哥,无界队列(Unbounded Queue)和有界队列(Bounded Queue)是两种常见的数据结构,用于存储和管理数据项。在计算机科学和并发编程中,它们有不同的特性和应用场景。下面详细解释这两者的概念、特点和适用场景。点赞收藏加关注,高效学习不迷路。 一、无界队列(Unbounded Queue) 1. 定义 无界队列是指在逻辑上没有限制队列中可以容纳的元素数量的队列。也就是说,无论向队列中添加多少元素,队列都能够处理,而不会因为超出某个限制而抛出异常或阻塞操作。 2. 特点 动态扩展:无界队列可以根据需要动态扩展内存,以容纳更多的元素。 适合高并发:在高并发的环境下,无界队列可以有效地处理大量的请求,而不会因为容量限制导致阻塞。 内存占用:由于没有容量限制,长期使用可能导致内存消耗过大,甚至引发内存溢出。 3. 适用场景 任务调度:在异步任务调度中,使用无界队列可以将任务放入队列中,消费者可以随时处理。 事件处理:在事件驱动的系统中,使用无界队列可以接收大量事件并异步处理。 4. 任务调度案例 下面是一个使用Java实现异步任务调度的示例,使用无界队...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- CentOS7,CentOS8安装Elasticsearch6.8.6
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- SpringBoot2整合Redis,开启缓存,提高访问速度
- CentOS7设置SWAP分区,小内存服务器的救世主
- CentOS7,8上快速安装Gitea,搭建Git服务器
- CentOS7安装Docker,走上虚拟化容器引擎之路
- CentOS关闭SELinux安全模块
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- Docker安装Oracle12C,快速搭建Oracle学习环境