Bootstrap

Spring Security——结合JWT实现令牌的验证与授权

目录

JWT(JSON Web Token)

项目总结

新建一个SpringBoot项目

pom.xml

PayloadDto

JwtUtil工具类

MyAuthenticationSuccessHandler(验证成功处理器)

JwtAuthenticationFilter(自定义token过滤器)

WebSecurityConfig配置类

WebSecurityController控制器

 index.html

项目测试


JWT(JSON Web Token)

  • 基于token的认证方式相较于Session认证方式的好处:
    • 可以节省服务器端的开销,因为每个认证用户占一个Session对象是需要消耗服务器内存资源的,而token是在每次请求时传递给服务器端的
    • 当服务器端做集群部署时,基于token的认证方式也更容易扩展
    • 无须考虑CSRF,由于不同依赖cookie,所以采用token认证方式不会发生CSRF
    • 更适合于移动端,当客户端是非浏览器平台时,cookie是不被支持的,此时采用token认证方式会更简单
  • JWT就是token的一种实现方式
  • JWT可以用于认证和信息交换,流程:
    • 在Web应用程序中,当用户成功登录后,服务器可以生成一个JWT并将其发送回客户端
    • 客户端存储该令牌,并在后续每次请求服务器接口时,都在请求报头中携带JWT
    • 服务器校验JWT签名,得到用户信息,如果验证通过,则根据授权规则返回前端请求的数据
  • JWT是一种用于在网络上安全传输信息的开放标准(RFC 7519),通过使用数字签名(或加密)来验证消息的发送者以及确保消息在传输过程中没有被篡改
  • JWT由三部分组成(用点号(.)分隔开)
    • Header(头部):是一个描述JWT元数据的JSON对象
      • 令牌的类型(JWT)
      • 正在使用的签名算法(如HMAC SHA256或者RSA)
    • Payload(载荷):是一个JSON对象,存放有效信息
      • 三种声明类型,关于用户和附加数据的陈述
      • 1、registered
        • iss(issuer):签发人
        • sub(subject):主题
        • aud(audience):受众
        • exp(expiration time):过期时间
        • nbf(not before):生效时间
        • iat(issued at):签发时间
        • jti(jwt id):JWT的唯一身份标识 
      • 2、public
      • 3、private
    • Signature(签名):对头部和载荷采用单向散列算法生成一个哈希码,以确保数据不会被篡改。签名用于验证消息在此过程中是否被更改,并且对于使用私钥签名的令牌还可以验证JWT的发送者是不是它所声称的
    • JWT=Header.Payload.Signature
  • 优点:
    • 简洁:由于其信息是以JSON对象的形式存储的,因此JWT是非常紧凑的
    • 自包含:JWT包含了所有必要的信息,因此不需要查询数据库来验证令牌
    • 可扩展性:由于其灵活的结构,JWT可以包含任意的额外信息
  • 缺点:
    • 信息是以Base64编码的形式存储的,所以不适合存储敏感信息,例如密码
    • 使用时要小心JWT被盗用或篡改
  • 应用场景:
    • 授权:一旦用户登录,每个后续请求就都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录(Single Sign On)是目前被广泛使用JWT的一个功能,因为它开销小,并且能够轻松地跨不同的域
    • 信息交换:可以在各方之间安全传输信息。因为可以对JWT进行签名(例如使用公钥或私钥对),所以可以确定发送者是特定的人。此外,由于签名是使用标头和有效载荷计算的,因此还可以验证内容是否被篡改

项目总结

  • 工作流程:

  • 1、用户注册和登录:

    • 用户通过注册接口提交注册信息,系统保存用户信息到数据库。
    • 用户通过登录接口提交用户名和密码,系统验证用户信息,成功后生成并返回JWT令牌。
  • 2、请求保护资源:

    • 客户端在每次请求时将JWT令牌放在请求头中。
    • JwtAuthenticationFilter 拦截请求,从请求头中提取JWT令牌,并通过 getAuthentication(token) 方法验证和解析令牌。
    • 若验证成功,将 UsernamePasswordAuthenticationToken 设置到 SecurityContextHolder 中,使当前线程拥有安全上下文。
    • 后续处理链和控制器可以通过安全上下文获取用户信息进行权限验证。
  • 3、安全考量:

    • JWT加密:使用HMAC算法对JWT进行签名,确保令牌的完整性和真实性。
    • 过期时间:设置JWT的有效期,减小令牌被盗用的风险。
    • 角色和权限:通过角色和权限控制资源访问,确保不同用户只能访问其权限范围内的资源。

新建一个SpringBoot项目

 项目的结构

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.3.12.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.study</groupId>
	<artifactId>jwt</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>jwt</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>8</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>1.3.2</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
		</dependency>
		<!--hutool是一个Java工具类库-->
		<dependency>
			<groupId>cn.hutool</groupId>
			<artifactId>hutool-all</artifactId>
			<version>5.5.7</version>
		</dependency>
		<!--jwt相关依赖-->
		<dependency>
			<groupId>com.nimbusds</groupId>
			<artifactId>nimbus-jose-jwt</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

PayloadDto

package com.study.jwt.dto;

import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.List;

/**
 * 用于封装JWT中的有效载荷
 */
@Data
//这个注解单独应用时会生成 equals 和 hashCode 方法,callSuper = false 表示生成的方法不会调用父类的 equals 和 hashCode 方法
@EqualsAndHashCode(callSuper = false)
//用于生成 Builder 模式相关的代码。它会生成一个静态内部类 ExampleBuilder,使得创建对象更加简洁和灵活
@Builder
public class PayloadDto {
    private String sub;//主题
    private Long iat;//签发时间
    private Long exp;//过期时间
    private String jti;//JWT的ID
    private String username;//用户名称
    private List<String> authorities;//用户拥有的权限
}

JwtUtil工具类

package com.study.jwt.util;

import cn.hutool.json.JSONUtil;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.study.jwt.dto.PayloadDto;

import java.text.ParseException;
import java.util.Date;

/**
 * 工具类,用于提供生成和验证JWT的方法
 */
public class JwtUtil {
    //默认密钥
    public static final String DEFAULT_SECRET="mySecret";

    /**
     * 使用HMAC SHA-256签名算法
     * @param payloadStr 有效载荷
     * @param secret 密钥
     * @return JWS串
     */
    public static String generateTokenByHMAC(String payloadStr,String secret) throws JOSEException {
        //创建JWS头,设置签名算法和类型
        JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build();
        //将载荷信息封装到Payload中
        Payload payload = new Payload(payloadStr);
        //创建JWS对象
        JWSObject jwsObject = new JWSObject(jwsHeader, payload);
        //创建HMAC签名器
        JWSSigner jwsSigner = new MACSigner(secret);
        //签名
        jwsObject.sign(jwsSigner);
        return jwsObject.serialize();
    }

    /**
     * 验证签名,提取有效载荷,以PayloadDto对象形式返回
     * @param token JWS串
     * @param secret 密钥
     * @return PayloadDto对象
     */
    public static PayloadDto verifyTokenByHMAC(String token,String secret) throws ParseException, JOSEException {
        //从token中解析JWS对象
        JWSObject jwsObject = JWSObject.parse(token);
        //创建HMAC验证器
        JWSVerifier jwsVerifier = new MACVerifier(secret);
        if (!jwsObject.verify(jwsVerifier)) {
            throw new JOSEException("token签名不合法!");
        }
        String payload = jwsObject.getPayload().toString();
        PayloadDto payloadDto = JSONUtil.toBean(payload, PayloadDto.class);
        if (payloadDto.getExp() < new Date().getTime()){
            throw new JOSEException("token已过期!");
        }
        return payloadDto;
    }
}

MyAuthenticationSuccessHandler(验证成功处理器)

package com.study.jwt.config.handler;

import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.json.JSONUtil;
import com.nimbusds.jose.JOSEException;
import com.study.jwt.dto.PayloadDto;
import com.study.jwt.util.JwtUtil;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;

/**
 * 修改验证成功处理器,用户成功登录后,在响应报头中发送token
 */
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 获取认证主体信息
        Object principal = authentication.getPrincipal();

        // 用户登录成功后,在响应头中发送 token
        if(principal instanceof UserDetails){
            // 如果主体信息是用户详情实例
            UserDetails user=(UserDetails)principal;

            // 获取用户的权限信息
            Collection<? extends GrantedAuthority> authorities=authentication.getAuthorities();
            List<String> authoritiesList = new ArrayList<>(authorities.size());

            // 将用户权限列表转换为字符串列表
            authorities.forEach(authority -> {
                authoritiesList.add(authority.getAuthority());
            });

            // 定义 token 的创建时间和过期时间
            Date now = new Date();
            DateTime exp = DateUtil.offsetSecond(now, 60 * 60); // 1小时后过期

            // 创建 payload 数据传输对象
            PayloadDto payloadDto=PayloadDto.builder()
                    .sub(user.getUsername())
                    .iat(now.getTime())
                    .exp(exp.getTime())
                    .jti(UUID.randomUUID().toString())
                    .username(user.getUsername())
                    .authorities(authoritiesList)
                    .build();
            String token=null;
            try {
                // 生成 JWT token
                token= JwtUtil.generateTokenByHMAC(
                        //nimbus-jose-jwt所使用的HMAC SHA256算法所需密钥长度为256位(32字节),因此先用md5加密
                        JSONUtil.toJsonStr(payloadDto),
                        SecureUtil.md5(JwtUtil.DEFAULT_SECRET)
                );

                // 设置响应头和响应内容类型
                response.setHeader("Authorization",token);
                response.setContentType("application/json;charset=UTF-8");
                PrintWriter out = response.getWriter();
                out.write("登录成功!");
                out.close();
            } catch (JOSEException e) {
                e.printStackTrace();
            }
        }
    }
}

JwtAuthenticationFilter(自定义token过滤器)

package com.study.jwt.filter;

import cn.hutool.crypto.SecureUtil;
import com.nimbusds.jose.JOSEException;
import com.study.jwt.dto.PayloadDto;
import com.study.jwt.util.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * JwtAuthenticationFilter是一个过滤器,拦截用户请求,验证token
 * 继承OncePerRequestFilter,保证在任何servlet容器上每次请求调度都能执行一次。
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    /**
     * 过滤器逻辑,提取和验证JWT,如果有效则设置认证信息。
     *
     * @param request     HttpServletRequest对象
     * @param response    HttpServletResponse对象
     * @param filterChain FilterChain对象
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("Authorization"); // 从请求头中获取JWT
        if (token == null) {
            // 如果请求头中没有token,则直接放行
            filterChain.doFilter(request, response);
            return;
        }
        // 如果请求头中有token,则进行解析,并且设置认证信息
        try {
            // 验证并解析token,并将认证信息设置到SecurityContext中
            //它将经过认证的用户信息设置到当前的安全上下文中 (SecurityContext),从而使得后续的安全决策可以基于该用户的信息进行
            SecurityContextHolder.getContext().setAuthentication(getAuthentication(token));
            filterChain.doFilter(request, response); // 继续执行过滤链
        } catch (ParseException | JOSEException e) {
            e.printStackTrace(); // 打印异常信息
        }
    }

    /**
     * 验证token,并解析token,返回以用户名和密码所表示的经过身份验证的主体的令牌。
     *
     * @param token JWT字符串
     * @return UsernamePasswordAuthenticationToken对象
     * @throws ParseException JSON解析异常
     * @throws JOSEException  JWT处理异常
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String token) throws ParseException, JOSEException {
        // 使用HMAC算法验证并解析JWT,获取PayloadDto对象
        PayloadDto payloadDto = JwtUtil.verifyTokenByHMAC(token, SecureUtil.md5(JwtUtil.DEFAULT_SECRET));
        String username = payloadDto.getUsername(); // 从Payload中获取用户名
        List<String> roles = payloadDto.getAuthorities(); // 从Payload中获取角色列表
        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();

        // 将角色列表转换为Spring Security的SimpleGrantedAuthority集合
        roles.forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));

        if (username != null) {
            return new UsernamePasswordAuthenticationToken(username, null, authorities);
        }
        return null; // 如果用户名为空,返回null
    }
}

WebSecurityConfig配置类

package com.study.jwt.config;

import com.study.jwt.filter.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity // 启用Spring Security的Web安全支持
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler; // 成功处理器注入
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler; // 失败处理器注入
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    /**
     * 配置HTTP安全
     *
     * @param http HttpSecurity对象
     * @throws Exception 异常处理
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/", "/home", "/login").permitAll() // 允许所有人访问
                .antMatchers("/admin/**").hasRole("ADMIN") // 只有具有 "ADMIN" 角色的用户才能访问以 "/admin/" 开头的路径
                .anyRequest().hasRole("USER") // 其他请求必须有USER角色
                .and()
            .formLogin()
                .loginPage("/login") // 指定登录页面
                .successHandler(authenticationSuccessHandler) // 登录成功处理器
                .failureHandler(authenticationFailureHandler) // 登录失败处理器
                .and()
            .logout() // 启用注销功能
                .and()
            // 添加自定义的 JWT 过滤器
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        http.csrf().disable(); // 禁用CSRF保护
    }

    /**
     * 配置密码编码器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 使用BCrypt加密密码
    }
}

WebSecurityController控制器

package com.study.jwt.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * 1.将configure(HttpSecurity http)方法中设置的不同的URL映射到不同的页面
 * 2.方法返回的是视图名称,需要视图解析器将视图名称解析成实际的HTML文件
 * 然后访问url就可以跳转到HTML页面了,否则返回的只是一个字符串
 * 3.在application.properties配置文件中配置视图解析器,springboot已经默认配置好了,你不用写了
 */
@Controller
public class WebSecurityController {

    /**
     * 登录后跳转到home.html页面
     */
    @GetMapping("/home")
    public String home(){
        return "home";
    }

    /**
     * 登录页面
     */
    @GetMapping("/login")
    public String login(){
        return "login";//login.html
    }

    /**
     * 当访问/resource时,会重定向到/login,登录后才可以访问受保护的页面resource.html
     */
    @GetMapping("/resource")
    public String resource(){
        return "resource";//resource.html
    }

    @GetMapping("/admin/index")
    public String index(){
        return "index";//index.html
    }
}

 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>index</title>
</head>
<body>
这是网站的首页!
</body>
</html>

项目测试

1、访问网址:http://localhost:8080/login

下面的Headers里的Authorization里的Value即为token,它被点号(.)分成三个部分,第一部分为Header,第二部分为Payload,第三部分为Signature

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwiZXhwIjoxNzE4NDM4NDY1NDM3LCJpYXQiOjE3MTg0MzQ4NjU0MzcsImp0aSI6ImQyNDFmMDlkLTgwNzYtNDZhZC1hYTg0LTc3MjQxNTIyZjUyZSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.izNsanv4m-UJiIH23m8A2FvqWIvMfy0AEbleanxnMt8

2、访问网址:http://localhost:8080/resource

携带token进行后续访问:把登录时的token复制到Headers里的新增的Authorization里,访问受保护资源 

3、访问网址:http://localhost:8080/admin/index

该地址需要角色为“ADMIN”,所以需要使用第二个账号"admin"登录

如果不用特定账号登录,则返回403错误

;