Spring Security 基于 JWT Token 的接口安全控制

发布时间 2023-09-25 22:13:51作者: 乔京飞

现在的网站开发,基本上都是前后端分离,后端提供 api 接口并进行权限控制。使用 Spring Security 框架可以大大简化权限控制的代码实现。对于后端接口而言,为了能够实现多节点负载均衡部署,更好的方案是不再使用 Session 了,绝大多数情况下,通过提交 JWT Token 来进行身份认证。

本篇博客的 Demo 相比上一篇博客的 Demo,进行一些功能的简化和调整,核心在于简单快速的实现后端接口基于 Spring Security 和 JWT Token 的角色权限控制。大家可以在本篇博客 Demo 的基础上进行功能强化,如增加防止暴力破解密码的功能,密码输入次数达到上限后锁定账号等等。

Spring Security 的官网地址:https://docs.spring.io/spring-security/reference/index.html


一、搭建工程

新建一个 SpringBoot 工程,代码结构如下:

image

config 包下主要是配置类,自定义的 JWT Token 工具类、自定义的 Md5 密码生成和验证类

controller 包下 SecurityController 是编写了一些带有权限控制注解的接口,用于演示权限控制

fileter 包下主要是自定义编写的两个过滤器,都是用于身份认证,一个基于 json 格式,一个基于 JWT Token 字符串。

mapper、pojo、service 分别是数据访问、实体类、业务方法,其中数据访问采用的是 mybatis plus

先看一下项目工程的 pom 文件:

<?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
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.jobs</groupId>
    <artifactId>spring_security_token</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.10</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <scope>compile</scope>
        </dependency>
        <!--引入 spring security 依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--导入 mysql 连接依赖-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
            <scope>runtime</scope>
        </dependency>
        <!--导入连接池依赖,生产环境下,连接数据库必然使用连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.2.16</version>
        </dependency>
        <!--导入 mybatis plus 依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>
        <!--引入 redis 依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.8</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!--里面有很多非常实用的工具类-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.4.3</version>
        </dependency>
        <!--引入该依赖,是为了使用 jwt 的生成和解析方法-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
    </dependencies>

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

相比于上一篇博客的 Demo 代码,这里引入了 jjwt 的依赖包,主要用于创建和解析 JWT 字符串。

然后在看一下 application.yml 配置文件的内容:

server:
  port: 8888
spring:
  datasource:
    # 使用 druid 连接池
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.136.128:3306/security_demo?serverTimeZone=Asia/Shanghai
    username: root
    password: root
  # 配置 redis 连接信息
  # 使用 redis 目的是为了将 session 存储在 redis 中,使网站可以负载均衡
  redis:
    host: 192.168.136.128
    port: 6379
    password: root
  main:
    # 控制台日志中不打印 spring 的 logo
    banner-mode: off
mybatis-plus:
  configuration:
    # 开启 sql 打印日志,输出的控制台,方便开发过程中查看 sql 执行细节
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    # 日志中不打印 mybatis plus 的 logo 信息
    banner: false
    db-config:
      # 主键采用数据库的自增长 id 策略
      id-type: auto
      # 配置数据库表的前缀 tb_ 作为前缀,跟实体类上配置的表名进行组合,就是数据库中的表名
      table-prefix: tb_
# 自定义的配置项:jwt的密钥
jwt:
  secret: jobs666
  # 创建的 token 有效期(秒)
  expire-second: 7200

相比于上一篇博客的 Demo ,这里增加了自定义的 jwt 的 2 个配置:对称加密的密钥、token 的有效期。


二、核心代码细节

由于本篇博客的 Demo 是在上一篇博客的 Demo 基础上进行了修改,代码重复的地方很多,因此只列出修改过的核心代码,具体详细的细节可以在本篇博客的末尾下载源代码进行查看和运行效果验证。

首先看一下自定义编写 JwtTokenUtil 工具类,具体细节如下:

package com.jobs.config;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.Map;

@Slf4j
@Component
public class JwtTokenUtil {

    @Value("${jwt.secret}")
    private String jwtSecret;

    //新创建 jwt token 字符串
    //map 表示存储到 jwt 中的自定义数据
    //second 表示 jwt token 多长时间过期
    public String createToken(Map data, Integer second) {
        long currentTime = System.currentTimeMillis();
        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .setExpiration(new Date(currentTime + second * 1000))
                .addClaims(data)
                .compact();
    }

    //验证 jwt token 字符串是否有效
    public Boolean verifyToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (Exception ex) {
            //无效的 jwt token,要么已经过期,要么被篡改了不合法
            log.error("解析验证 token 失败:" + ex.getMessage());
            return false;
        }
    }

    //获取 jwt token 中存储的自定义数据
    public Map getClaims(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(jwtSecret)
                    .parseClaimsJws(token).getBody();
        } catch (Exception ex) {
            //无效的 jwt token,要么已经过期,要么被篡改了不合法
            log.error("获取 token 自定义数据失败:" + ex.getMessage());
            return null;
        }
    }
}

主要的方法就是生成 JWT Token 字符串,以及设置 token 的有效期。token 里面可以自定义存放一些内容,比如用户名、权限列表等等。另外就是解析 JWT Token 字符串,获取里面存放的内容。有关 Jwt 字符串的详细内容,这里不做介绍。

然后看一下 JsonLoginFilter 过滤器的代码,由于提交给登录接口的数据,都是 json 字符串,因此我们需要从 json 字符串中获取提交过来的用户名和密码,然后进行封装传递给底层进行判断用户名和密码的正确性。

package com.jobs.filter;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

public class JsonLoginFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {

        //提供登录接口,提交过来的是 json 数据,因此需要从 json 中解析出用户名和密码
        if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
            try {
                Map<String, String> map = new ObjectMapper().readValue(request.getInputStream(),
                        new TypeReference<Map<String, String>>() {
                        });
                String userName = map.get(getUsernameParameter());
                String passwd = map.get(getPasswordParameter());
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(userName, passwd);
                return this.getAuthenticationManager().authenticate(authenticationToken);
            } catch (Exception ex) {
                throw new RuntimeException(ex);
            }
        }

        return super.attemptAuthentication(request, response);
    }
}

再来看一下 TokenLoginFilter 的代码,前面使用用户名和密码验证成功后,就会创建 JWT Token 字符串返回给前端,后续所有请求都是需要在 Header 中携带该 JWT Token 字符串,然后在 TokenLoginFilter 中进行验证,该过滤器需要拦截所有请求,并放在所有过滤器的最前面。

package com.jobs.filter;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.jobs.config.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
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.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
@WebFilter(filterName = "TokenLoginFilter", urlPatterns = "/*")
public class TokenLoginFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException, IOException {

        String token = request.getHeader("Authorization");
        if (StringUtils.isBlank(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        //验证 jwt token 是否有效
        Map claims = jwtTokenUtil.getClaims(token);
        if (claims != null) {
            //已登录,获取用户信息,进行授权
            List<SimpleGrantedAuthority> authorities = ((List<String>) claims.get("powerlist")).stream()
                    .map(power -> new SimpleGrantedAuthority(power)).collect(Collectors.toList());
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(claims.get("username"), null, authorities);
            //这里只要给 Authentication 设置值后,后面有关验证登录的过滤器,就自动通过了
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            filterChain.doFilter(request, response);
        } else {
            filterChain.doFilter(request, response);
            return;
        }

    }
}

最后就是有关 Spring Security 的过滤器链配置内容:

package com.jobs.config;

import com.alibaba.fastjson.JSON;
import com.jobs.filter.JsonLoginFilter;
import com.jobs.filter.TokenLoginFilter;
import com.jobs.service.MyUserDetailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.annotation.PostConstruct;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Configuration
//对于该注解,prePostEnabled = true 是默认值,所以可以省略
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig {

    @Autowired
    private TokenLoginFilter tokenLoginFilter;

    //配置 spring security 的过滤器执行链信息
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                //允许匿名访问的地址
                .antMatchers(getAnonymousUrl()).permitAll()
                .anyRequest().authenticated()
                //采用 form 认证方式
                .and().formLogin().permitAll()
                //由于请求接口使用 token 进行验证,所以不再使用 Session
                .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().exceptionHandling()
                //没有权限访问时,进入该方法
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    Map<String, Object> data = new HashMap<>();
                    data.put("code", -2);
                    data.put("msg", "访问失败,无权限访问");
                    data.put("data", accessDeniedException.getMessage());
                    response.setContentType("application/json;charset=utf-8");
                    response.getWriter().println(JSON.toJSONString(data));
                })
                //没有登录时,进入该方法
                .authenticationEntryPoint((request, response, authException) -> {
                    Map<String, Object> data = new HashMap<>();
                    data.put("code", -1);
                    data.put("msg", "访问失败,请登录后再访问");
                    data.put("data", authException.getMessage());
                    response.setContentType("application/json;charset=utf-8");
                    response.getWriter().println(JSON.toJSONString(data));
                })
                .and()
                .addFilterAt(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterAt(tokenLoginFilter, JsonLoginFilter.class)
                //禁用 csrf 防护
                .csrf().disable();
        return http.build();
    }

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Value("${jwt.expire-second}")
    private Integer expireSecond;

    public JsonLoginFilter jsonLoginFilter() {
        JsonLoginFilter jsonLoginFilter = new JsonLoginFilter();
        jsonLoginFilter.setAuthenticationManager(authenticationManager());
        //登录失败
        jsonLoginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
            Map<String, Object> data = new HashMap<>();
            data.put("code", -1);
            data.put("msg", "登录失败");
            data.put("data", exception.getMessage());
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().println(JSON.toJSONString(data));
        });
        //登录成功,将 token 存储到 redis 中,然后返回 jwt token
        jsonLoginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            String token;
            //当前登录的用户名
            String username = authentication.getName();
            Boolean flag = redisTemplate.hasKey(username);
            if (flag) {
                //如果 redis 存在该登录用户的 token ,则直接返回之前创建的 token
                token = (String) redisTemplate.opsForValue()
                        .get("jwt_" + username);
            } else {
                Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
                List<String> powerlist = new ArrayList<>();
                if (authorities != null && authorities.size() > 0) {
                    powerlist = authorities.stream()
                            .map(GrantedAuthority::getAuthority).collect(Collectors.toList());
                }

                Map<String, Object> claims = new HashMap<>();
                claims.put("username", username);
                claims.put("powerlist", powerlist);
                //创建 jwt token 并设置过期时间
                token = jwtTokenUtil.createToken(claims, expireSecond);
                //如果 redis 中不存在该 key 时,则添加并设置过期时间
                redisTemplate.opsForValue()
                        .setIfAbsent("jwt_" + username, token, expireSecond, TimeUnit.SECONDS);
            }

            Map<String, Object> data = new HashMap<>();
            data.put("code", 0);
            data.put("msg", "登录成功");
            data.put("data", token);
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().println(JSON.toJSONString(data));
        });
        return jsonLoginFilter;
    }

    //在这里配置允许匿名访问的地址
    public String[] getAnonymousUrl() {
        return new String[]{
                "/powertest/all"  //测试匿名访问权限的地址
        };
    }

    //下面是配置【验证用户登录的数据来源】和【使用的密码加密方式】---------------------

    @Autowired
    private MyUserDetailService userDetailsService;

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        //采用 md5 密码加密方式
        daoAuthenticationProvider.setPasswordEncoder(new MyMd5PasswordEncoder());
        return new ProviderManager(daoAuthenticationProvider);
    }

    //要想在异步线程中获取当前登录的用户信息,必须将线程策略设置为 inheritable threadlocal
    //inheritable threadlocal 模式下,会复制父线程中存放的用户信息
    @PostConstruct
    public void setStrategyName() {
        SecurityContextHolder.setStrategyName(
                SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    }
}

由于后端提供的是接口,为了能够进行多节点负载均衡部署,因此不需要使用 Session,配置如下:

sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

先进行 token 验证,如果没有 token 的话,再从 json 中尝试获取用户名和密码进行身份验证,配置过滤器顺序如下:

.addFilterAt(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAt(tokenLoginFilter, JsonLoginFilter.class)

用户第一次调用接口时,必须要登录,采用 JsonLoginFilter 过滤器从提交古来的 json 中获取用户名和密码验证身份,当验证成功时,也就是登录成功,需要创建 JWT Token 字符串,并存储到 redis 中,然后把 token 返回给调用者。之所以使用 redis 存储,主要是确保 token 没有过期前,让用户使用相同的 token,防止多次生成 token 字符串。

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private JwtTokenUtil jwtTokenUtil;

@Value("${jwt.expire-second}")
private Integer expireSecond;

public JsonLoginFilter jsonLoginFilter() {
    JsonLoginFilter jsonLoginFilter = new JsonLoginFilter();
    jsonLoginFilter.setAuthenticationManager(authenticationManager());
    //登录失败
    jsonLoginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
        Map<String, Object> data = new HashMap<>();
        data.put("code", -1);
        data.put("msg", "登录失败");
        data.put("data", exception.getMessage());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().println(JSON.toJSONString(data));
    });
    //登录成功,将 token 存储到 redis 中,然后返回 jwt token
    jsonLoginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
        String token;
        //当前登录的用户名
        String username = authentication.getName();
        Boolean flag = redisTemplate.hasKey(username);
        if (flag) {
            //如果 redis 存在该登录用户的 token ,则直接返回之前创建的 token
            token = (String) redisTemplate.opsForValue()
                .get("jwt_" + username);
        } else {
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            List<String> powerlist = new ArrayList<>();
            if (authorities != null && authorities.size() > 0) {
                powerlist = authorities.stream()
                    .map(GrantedAuthority::getAuthority).collect(Collectors.toList());
            }

            Map<String, Object> claims = new HashMap<>();
            claims.put("username", username);
            claims.put("powerlist", powerlist);
            //创建 jwt token 并设置过期时间
            token = jwtTokenUtil.createToken(claims, expireSecond);
            //如果 redis 中不存在该 key 时,则添加并设置过期时间
            redisTemplate.opsForValue()
                .setIfAbsent("jwt_" + username, token, expireSecond, TimeUnit.SECONDS);
        }

        Map<String, Object> data = new HashMap<>();
        data.put("code", 0);
        data.put("msg", "登录成功");
        data.put("data", token);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().println(JSON.toJSONString(data));
    });
    return jsonLoginFilter;
}

三、验证效果

剩下的代码,与上篇博客的 Demo 代码完全相同,这里就不再列出。另外数据库中所有用户的密码都是 123 采用 md5 加密后进行存储,本篇博客采用 PostMan 工具,使用 jobs 账号进行测试。

首先我们使用 PostMan 调用登录接口,传入账号密码的 json 字符串,获取 JWT Token 字符串:

image

通过网上的在线 JWT 字符串解析,可以看到里面存放的自定义内容:

image

可以发现,我们往 JWT 字符串中,存放是自定义内容是用户名和权限列表,还有 token 的过期时间。

获取到 token 之后,我们调用有权限访问的接口,首先需要在 header 中增加 Authorization 参数(该参数名称可以自定义,我们在 TokenLoginFilter 的代码中要求从 header 中接收 Authorization 参数),参数值就是刚才获取的 JWT Token 字符串。由于本篇博客开发的接口都不需要提供参数,因此 body 中可以不提供 json 字符串,你想编写一些 json 字符串的话,也没问题。

image

然后我们在调用一下不具备访问权限的接口,获取的结果如下图所示:

image

如果我们没有登录,或者 header 中不提供 Authorization 参数,或者 token 字符串过期后,此时我们请求具有权限控制的接口,返回的结果就会提示我们必须登录后才能调用接口,如下图所示:

image


OK,有关 Spring Security 基于 JWT Token 的安全控制就介绍完了,Demo 代码都详细测试无误。

本篇博客的 Demo 下载地址:https://files.cnblogs.com/files/blogs/699532/spring_security_token.zip