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

news2024/9/30 12:24:22

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

一 无状态登录

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;
    }
    @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();
        }

    }
}

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

  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.coloradmin.cn/o/2179603.html

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

相关文章

什么牌子的护眼台灯质量好?五款护眼台灯性价比高又好用

如今&#xff0c;市场上的护眼台灯琳琅满目&#xff0c;种类繁多。家长们为了孩子们的视力健康&#xff0c;都会精心挑选合适的台灯。什么牌子的护眼台灯质量好&#xff1f;然而&#xff0c;市面上仍存在一些质量不过关的产品&#xff0c;可能会出现频闪、线路短路、底盘过热、…

生产环境升级mysql流程及配置主从服务

之前写到过mysql升级8.4的文章, 因此不再介绍mysql的安装过程 避免服务器安装多个mysql引起冲突的安装方法_安装两个mysql会冲突吗-CSDN博客 生产环境升级mysql8.4.x流程 安装mysql 参考之前文章: 避免服务器安装多个mysql引起冲突的安装方法_安装两个mysql会冲突吗-CSDN博客…

无界购物:线上商城开启便利与智能新潮流-亿发

随着科技的不断进步&#xff0c;线上购物已经成为现代消费者日常生活的重要组成部分。无论是在繁忙的工作间隙&#xff0c;还是在闲暇的周末时光&#xff0c;消费者都能通过线上商城轻松实现购物的愿望。而这一切的背后&#xff0c;是线上商城打破了地理界限&#xff0c;开启了…

【SpringBoot详细教程】-06-Restful风格【持续更新】

点个关注&#xff0c;不迷路 ⾸先&#xff0c;回顾并详细说明⼀下在SSM中使⽤的 Controller 、 RestController 、 RequestMapping 注解。如果您对SpringMVC不熟悉并且还没有尝试过快速⼊⻔案例&#xff0c;建议先看⼀下SSM与Restful相关的内容。 Controller &#xff1a;修饰…

Zynq7000系列中的Quad-SPI(四线制串行外设接口)Flash控制器编程指南

Zynq7000系列中的Quad-SPI&#xff08;四线制串行外设接口&#xff09;Flash控制器是一个功能强大的组件&#xff0c;它支持高速数据传输和多种配置模式。以下是基于Zynq7000系列的Quad-SPI Flash控制器编程指南&#xff0c;旨在帮助开发者了解如何配置和使用该控制器。 编程指…

医院配电系统谐波的分析及治理

七次谐波&#xff0c;流入电网。 4.计算机及UPS 目前大部分医院都是通过计算机实现运营和管理工作&#xff0c;计算机数量众多&#xff0c;计算机服务器配有UPS等备用电源&#xff0c;加之个人电脑的开关电源&#xff0c;都是产生谐波的电源。 3医院配电系统谐波设备的治理方…

【Golang】Go语言中时间time相关处理方法

✨✨ 欢迎大家来到景天科技苑✨✨ &#x1f388;&#x1f388; 养成好习惯&#xff0c;先赞后看哦~&#x1f388;&#x1f388; &#x1f3c6; 作者简介&#xff1a;景天科技苑 &#x1f3c6;《头衔》&#xff1a;大厂架构师&#xff0c;华为云开发者社区专家博主&#xff0c;…

微信广告任务平台 ajax_upload 任意文件上传漏洞

0x01 漏洞描述&#xff1a; 微信广告任务平台ajax_upload接口处存在任意文件上传漏洞&#xff0c;攻击者可利用该漏洞将恶意文件上传至服务器&#xff0c;进而可能实现远程代码执行、篡改网站内容或发动其他形式的攻击&#xff0c;严重危及系统与数据安全。 位于控制器中IndexC…

x-cmd pkg | tokei - 代码统计利器,助你快速了解项目进度

目录 简介首次用户技术特点竞品和相关项目进一步阅读 简介 tokei 是一个使用 Rust 编写的显示有关代码统计信息的命令行工具&#xff0c;可以分门别类的统计目录内的代码行数。 tokei 具有良好的跨平台性&#xff0c;可以在 Linux、macOS、Windows 等多种平台上安装运行。 首…

SU03T(语音识别播报模块)

SU03T&#xff08;语音识别播报模块&#xff09; 注意&#xff1a;学习模块的方法是最重要的 目录 SU03T&#xff08;语音识别播报模块&#xff09; 查找资料 - SU03T 配置固件 1、进入智能公元&#xff0c;并注册登入 2、点击对应的模块&#xff0c;创建产品 3、随便选…

软件测试|数据库常见面试题

在软件测试数据库的面试中&#xff0c;面试官通常会考察应聘者对数据库的理解、SQL语言的应用、数据库性能优化、以及数据库相关的技术栈和工具等方面的知识。以下是一些可能的面试问题及建议的回答思路&#xff1a; 1、什么是关系型数据库&#xff0c;主键&#xff0c;外键&am…

揭秘网络钓鱼:如何识破并防范这场数字时代的诈骗游戏

网络钓鱼是一种网络攻击&#xff0c;它利用伪装的电子邮件欺骗收件人提供信息、下载恶意软件或采取其他期望的行动。 网络钓鱼是网络害虫&#xff0c;自20世纪90年代初从暗网出现以来&#xff0c;至今仍危害全球。根据SlashNext的报告&#xff0c;2023年平均每天有31,000次网络…

SpringCloudAlibaba的nacos启动注册实现

首先安装nacos&#xff0c;注意看清楚版本号码&#xff0c;后面设置pom依赖的时候有用。 安装地址&#xff1a;Nacos 快速开始 | Nacos 官网 选择这里&#xff1a; 然后在这个跳转的页面中&#xff0c;找到你需要的版本&#xff0c;比如选择2.0.4 进入之后&#xff0c;选择你…

软考题-数据库2-数据模型/ER图/关系模式规范化

一、概念数据模型-ER图 1.概念数据模型-ER图的构件-属性的分类 属性分多值属性和单值属性&#xff0c;题目已经给出联系方式的值有手机电话和办公室电话&#xff0c;所以是多值属性 派生属性是由其他属性而来&#xff0c;年龄来自于出生年月 部门号是EMP表的外键二、关系模式 …

vue-element 表格组合查询 - fc-table-search 组件封装

开发目的 解决搜索form参数读取&#xff0c;配合异步请求&#xff0c;更新渲染数据&#xff1b;支持自适应高度&#xff0c;分页查询&#xff0c;搜索查询/重置。 额外提供formater类型&#xff1a;标签定义&#xff0c;金额&#xff0c;时间格式化&#xff0c;跨页勾选&#x…

Minstrel自动生成结构化提示,让AI为AI写提示词的多代理提示生成框架

在人工智能快速发展的今天&#xff0c;如何有效利用大型语言模型&#xff08;LLMs&#xff09;成为了一个普遍关注的话题。这是9月份的一篇论文&#xff0c;提出了LangGPT结构化提示框架和Minstrel多代理提示生成系统&#xff0c;为非AI专家使用LLMs提供了强大支持。 对于非人…

PHP人才机遇桥梁招聘求职全能系统小程序源码

人才机遇桥梁 —— 招聘求职全能系统全解析 &#x1f4bc;&#x1f680; &#x1f309; 搭建人才与机遇的桥梁 在这个竞争激烈的职场环境中&#xff0c;找到一份心仪的工作或招募到合适的人才&#xff0c;往往不是一件容易的事。但幸运的是&#xff0c;我们有了“人才机遇桥梁…

Hello-Java-Sec靶场搭建

安装环境&#xff1a;宝塔Linux面板 1.将靶场克隆到本地 git clone https://github.com/j3ers3/Hello-Java-Sec 2.安装maven yum install maven &#xff08;kali&#xff1a;apt install maven&#xff09; 3.安装成功后 mvn -v验证⼀下 4. 配置数据库连接&#xff0c;数据…

SysML图例-悬架作动器(Suspension Aactuator)

DDD领域驱动设计批评文集>> 《软件方法》强化自测题集>> 《软件方法》各章合集>>

【Android】获取备案所需的公钥以及签名MD5值

目录 重要前提 获取签名MD5值 获取公钥 重要前提 生成jks文件以及gradle配置应用该文件。具体步骤请参考我这篇文章&#xff1a;【Android】配置Gradle打包apk的环境_generate signed bundle or apk-CSDN博客 你只需要从头看到该文章的配置build.gradle&#xff08;app&…