简介

Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。

一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。

一般Web应用的需要进行认证授权

  • 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
  • 授权:经过认证后判断当前用户是否有权限进行某个操作

而认证和授权也是SpringSecurity作为安全框架的核心功能。

技术版本:

  • springboot3
  • mybatisplus3
  • redis6+
  • jwt
  • mysql8+
  • springsecurity 6.2+

我们先要搭建一个SpringBoot工程,创建工程 添加依赖

1.2 引入SpringSecurity

在SpringBoot项目中使用SpringSecurity我们只需要引入依赖即可实现入门案例。

引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。

必须登陆之后才能对接口进行访问。

退出:输入logout即可。

想要知道如何实现自己的登陆流程就必须要先知道入门案例中SpringSecurity的流程。

2.2.1 SpringSecurity完整流程

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。

图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。

ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。

FilterSecurityInterceptor:负责权限校验的过滤器。通俗一点就是授权由它负责。(鉴权)

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法

UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

  • ①自定义登录接口 调用ProviderManager的方法进行认证 如果认证通过生成jwt 把用户信息存入redis中
  • ②自定义UserDetailsService 在这个实现类中去查询数据库

校验

思考:从JWT认证过滤器中获取到userid后怎么获取到完整的用户信息?

结论:如果认证通过,使用用户id生成一个jwt,然后用userid作为key,用户信息作为value存入redis,用户下一次访问的时候,到达JWT认证过滤器后,再去redis中就可以取到对应的用户信息(缓解一部分数据库的压力)

两种方案:

  1. redis中存储jwt
  2. 不在redis中存储jwt,自解释

①定义Jwt认证过滤器 获取token 解析token获取其中的userid 从redis中获取用户信息(可选) 存入SecurityContextHolder

2.3.2 添加依赖及配置文件

<!--fastjson依赖-->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.83</version>
</dependency>
<!--jwt依赖-->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
<!--web依赖-->
<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>
</dependency>
<!--mybatisplus依赖-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.6</version>
</dependency>
<!--mysql驱动依赖-->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.20</version>
</dependency>
<!--lombok依赖-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<!--validation依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--redis坐标-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--springdoc-openapi-->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.1.0</version>
</dependency>
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
    <version>2.1.0</version>
</dependency>
<!--JAXB(JWT依赖,适配SpringBoot3)-->
<dependency>
    <groupId>jakarta.xml.bind</groupId>
    <artifactId>jakarta.xml.bind-api</artifactId>
    <version>4.0.1</version>
</dependency>
<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>4.0.1</version>
</dependency>
<!--SpringSecurity依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

yml配置

server:
  port: 8001
  #address: 127.0.0.1
#spring数据源配置
spring:
  application:
    name: token #项目名
  # 数据源
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security_manager?serverTimezone=GMT%2B8&useUnicode=true&useSSL=false&characterEncoding=utf8
    username: root
    password: root
    druid:
      max-active: 20
      max-wait: 5000
      initial-size: 1
      filters: stat,wall
      validationQuery: SELECT 'x'   #验证连接 SQL心跳包
      test-on-borrow: true
  # redis 配置
  redis:
    database: 0
    host: 127.0.0.1
    port: 6379
    #password: 123456
    lettuce:
      pool:
        #最大连接数
        max-active: 8
        #最大阻塞等待时间(负数表示没限制)
        max-wait: -1
        #最大空闲
        max-idle: 8
        #最小空闲
        min-idle: 0
    #连接超时时间
    timeout: 10000
  # jackson 配置
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8
# mybatis-plus配置
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
  configuration:
    map-underscore-to-camel-case: true # 数据库下划线自动转驼峰标示关闭
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #日志配置
  mapper-locations: classpath*:/mapper/**/*.xml

2.3.3 添加响应类

@Data
public class R {
    private Boolean success;  //返回的成功或者失败的标识符
    private Integer code;  //返回的状态码
    private String message; //提示信息
    private Map<String, Object> data = new HashMap<String, Object>();  //数据
    //把构造方法私有
    private R() {}
    //成功的静态方法
    public static R ok(){
        R r=new R();
        r.setSuccess(true);
        r.setCode(ResultCode.SUCCESS);
        r.setMessage("成功");
        return r;
    }
    //失败的静态方法
    public static R error(){
        R r=new R();
        r.setSuccess(false);
        r.setCode(ResultCode.ERROR);
        r.setMessage("失败");
        return r;
    }
    //使用下面四个方法,方面以后使用链式编程
    // R.ok().success(true)
    //  r.message("ok).data("item",list)
    public R success(Boolean success){
        this.setSuccess(success);
        return this; //当前对象  R.success(true).message("操作成功").code().data()
    }
    public R message(String message){
        this.setMessage(message);
        return this;
    }
    public R code(Integer code){
        this.setCode(code);
        return this;
    }
    public R data(String key, Object value){
        this.data.put(key, value);
        return this;
    }
    public R data(Map<String, Object> map){
        this.setData(map);
        return this;
    }
}
public interface ResultCode {
    Integer SUCCESS=20000;
    Integer ERROR=20001;
}

2.3.4 基于token的鉴权机制

1 什么是JWT?

然后将其进行base64加密,得到Jwt的第二部分。

signature

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • payload (base64后的)
  • secret (盐-密钥)

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

JwtUtils工具类

package cn.twsj.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.crypto.SecretKey;
import jakarta.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
/**
 * JWT工具类
 */
public class JwtUtils {

    // 有效期:1小时
    public static final Long JWT_TTL = 60 * 60 * 1000L;
    // 秘钥(建议配置到配置文件)
    public static final String JWT_KEY = "qW21YIU&^%$";

    // 生成UUID作为JWT ID
    public static String getUUID() {
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    /**
     * 生成JWT
     * @param subject 存储的用户数据(JSON字符串)
     * @return JWT字符串
     */
    public static String createJWT(String subject) {
        return createJWT(subject, JWT_TTL);
    }

    /**
     * 生成JWT
     * @param subject 存储的用户数据
     * @param ttlMillis 过期时间(毫秒)
     * @return JWT字符串
     */
    public static String createJWT(String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());
        return builder.compact();
    }

    /**
     * 生成JWT(自定义ID)
     * @param id JWT ID
     * @param subject 存储的用户数据
     * @param ttlMillis 过期时间
     * @return JWT字符串
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);
        return builder.compact();
    }

    // 构建JWT的核心逻辑
    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        // 处理过期时间
        if (ttlMillis == null || ttlMillis <= 0) {
            ttlMillis = JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);

        return Jwts.builder()
                .setId(uuid)                    // JWT唯一ID
                .setSubject(subject)            // 主题(存储用户数据)
                .setIssuer("hxzy")              // 签发者
                .setIssuedAt(now)               // 签发时间
                .signWith(signatureAlgorithm, secretKey) // 签名算法+秘钥
                .setExpiration(expDate);        // 过期时间
    }

    /**
     * 生成加密秘钥
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JWT_KEY);
        return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
    }

    /**
     * 解析JWT
     * @param jwt JWT字符串
     * @return 载荷信息
     * @throws Exception 解析失败异常
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

2.3.5 认证的实现

1 配置数据库校验登录用户

从之前的分析我们可以知道,我们可以自定义一个UserDetailsService,让SpringSecurity使用我们的UserDetailsService。我们自己的UserDetailsService可以从数据库中查询用户名和密码。

我们先创建一个用户表, 建表语句如下:

CREATE TABLE `sys_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` VARCHAR(64) NOT NULL COMMENT '用户名',
  `nick_name` VARCHAR(64) NOT NULL COMMENT '昵称',
  `password` VARCHAR(128) NOT NULL COMMENT '密码(加密存储)',
  `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
  `sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
  `user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` TINYINT(1) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_name` (`user_name`) -- 用户名唯一
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
@Data
public class LoginUser implements UserDetails {
    private User user;

    //用来返回权限信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    //获取密码
    public String getPassword() {
        return user.getPassword();
    }

    //获取用户名
    public String getUsername() {
        return user.getUserName();
    }

    //判断账号是否未过期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //判断账号是否没有锁定
    @Override
    public boolean isAccountNonLocked() {
        return "0".equals(user.getStatus());
    }

    //判断账号是否没有超时
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //判断账号是否可用
    @Override
    public boolean isEnabled() {
        return "0".equals(user.getStatus());
    }
}

创建一个类实现UserDetailsService接口,重写其中的方法。使用用户名从数据库中查询用户信息

package cn.twsj.service.impl;

import cn.twsj.entity.LoginUser;
import cn.twsj.entity.User;
import cn.twsj.mapper.UserMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * 自定义用户信息加载逻辑
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 查询用户(LambdaQueryWrapper更优雅)
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserName, username)
                   .eq(User::getDeleted, 0); // 只查未删除的用户
        User user = userMapper.selectOne(queryWrapper);

        // 2. 用户不存在则抛出异常(SpringSecurity规范)
        if (Objects.isNull(user)) {
            throw new UsernameNotFoundException("用户名不存在");
        }

        // 3. 后续可补充查询用户权限(如角色、菜单)
        // List<GrantedAuthority> authorities = ...;

        // 4. 封装为LoginUser返回
        return new LoginUser(user);
    }
}

相同的明文,它内部使用了随机的盐,导致产生不同的密文。为了安全性 比较明文与加密后的密文

3 自定义登陆接口

接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。

认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

package cn.twsj.controller;

import cn.twsj.common.R;
import cn.twsj.entity.User;
import cn.twsj.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 用户控制器
 */
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 登录接口
     */
    @PostMapping("/login")
    public R login(@RequestBody User user) {
        String token = userService.login(user);
        if (token != null && !token.isEmpty()) {
            return R.ok().message("登录成功").data("token", token);
        }
        return R.error().message("用户名或密码错误");
    }
}
package cn.twsj.service.impl;

import cn.twsj.entity.LoginUser;
import cn.twsj.entity.User;
import cn.twsj.mapper.UserMapper;
import cn.twsj.service.UserService;
import cn.twsj.utils.JwtUtils;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 用户服务实现类
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public String login(User user) {
        // 1. 封装认证Token
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());

        // 2. 调用AuthenticationManager认证
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if (Objects.isNull(authenticate)) {
            throw new RuntimeException("登录失败");
        }

        // 3. 获取用户信息,生成JWT
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        Long userId = loginUser.getUser().getId();
        String token = JwtUtils.createJWT(userId.toString());

        // 4. 用户信息存入Redis(有效期与JWT一致)
        redisTemplate.opsForValue()
                .set("login:" + userId, loginUser, JwtUtils.JWT_TTL, TimeUnit.MILLISECONDS);

        return token;
    }
}

使用AuthenticationManager对象 在AuthenticationManager中找到authenticate方法,查看参数authentication,在参数的类型上按住Ctrl+Alt点击左键,可以查看到这个接口的全部实现类列表,这里使用UsernamePasswordAuthenticationToken实现类

4 SecurityConfig配置文件

4.1 HttpSecurity参数说明

SecurityFilterChain: 一个表示安全过滤器链的对象

  • http.antMatchers(...).permitAll() 通过 antMatchers 方法,你可以指定哪些请求路径不需要进行身份验证。
  • http.authorizeRequests() 可以配置请求的授权规则。 例如,.anyRequest().authenticated() 表示任何请求都需要经过身份验证。
  • http.requestMatchers 表示某个请求不需要进行身份校验,permitAll 随意访问。
  • http.httpBasic() 配置基本的 HTTP 身份验证。
  • http.csrf() 通过 csrf 方法配置 CSRF 保护。
  • http.sessionManagement() 不会创建会话。这意味着每个请求都是独立的,不依赖于之前的请求。适用于 RESTful 风格的应用。
4.2 具体配置
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    //创建BCryptPasswordEncoder注入容器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //配置关闭csrf机制
        http.csrf(csrf -> csrf.disable());
        // 关闭session(无状态认证)
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        //配置请求拦截方式
        //permitAll:随意访问
        http.authorizeHttpRequests(auth -> auth.requestMatchers("/user/login")
                .permitAll().
                anyRequest()
                .authenticated());
        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

5 认证校验过滤器

我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。 使用userid去redis中获取对应的LoginUser对象。 然后封装Authentication对象存入SecurityContextHolder

package cn.twsj.filter;

import cn.twsj.entity.LoginUser;
import cn.twsj.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Objects;

/**
 * JWT认证过滤器(每次请求执行一次)
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 1. 获取Token(从请求头)
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            // 无Token,放行(交给后续过滤器处理)
            filterChain.doFilter(request, response);
            return;
        }

        // 2. 解析Token
        Claims claims = null;
        try {
            claims = JwtUtils.parseJWT(token);
        } catch (Exception e) {
            throw new RuntimeException("Token无效或已过期");
        }
        String userId = claims.getSubject();

        // 3. 从Redis获取用户信息
        String redisKey = "login:" + userId;
        LoginUser loginUser = (LoginUser) redisTemplate.opsForValue().get(redisKey);
        if (Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录");
        }

        // 4. 存入SecurityContextHolder
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        // 5. 放行
        filterChain.doFilter(request, response);
    }
}

3.2.2 封装权限信息

public class UserDetailsServiceImpl implements UserDetailsService {

我们之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改。


3.3.2 准备工作

MySQL在5.5.3之后增加了这个utf8mb4的编码,mb4就是most bytes 4的意思,专门用来兼容四字节的unicode。好在utf8mb4是utf8的超集,除了将编码改为utf8mb4外不需要做其他转换。当然,为了节省空间,一般情况下使用utf8也就够了。

mysql查询权限SQL语句

SELECT
    DISTINCT t4.`perms`
FROM
    sys_user_role t1


public class Menu implements Serializable {
    private Long id;
    /**
    * 菜单名
    */
    private String menuName;
    /**
    * 路由地址
    */
    private String path;
    /**
    * 组件路径
    */
    private String component;
    /**
    * 菜单状态(0显示 1隐藏)
    */
    private String visible;
    /**
    * 菜单状态(0正常 1停用)
    */
    private String status;
    /**
    * 权限标识
    */
    private String perms;
    /**
    * 菜单图标
    */
    private String icon;
    private Long createBy;
    private Date createTime;
    private Long updateBy;
    private Date updateTime;
    /**
    * 是否删除(0未删除 1已删除)
    */
    private Integer delFlag;
    /**
    * 备注
    */
    private String remark;
    //TODO 查询用户对应的权限信息

3.3.4 测试接口权限

4. 项目优化-自定义处理器

我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。

在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

4.1 自定义验证异常类

创建exception包,在exception包下创建自定义CustomerAuthenticationException类,继承AuthenticationException类

4.2 编写认证用户无权限访问处理器

在cn.twsj.handler包下创建CustomerAccessDeniedHandler认证用户访问无权限资源时处理器类。

4.3 编写匿名用户访问资源处理器

在cn.twsj.handler包下创建 AnonymousAuthenticationHandler匿名用户访问资源处理器类。

result = JSON.toJSONString(R.error()
        .code(HttpServletResponse.SC_UNAUTHORIZED)
        .message("用户名为空!"),

4.4 自定义认证失败处理器

message = "账户被禁用,登录失败!";
}else if(exception instanceof LockedException){
    message = "账户被锁,登录失败!";
}else if(exception instanceof InternalAuthenticationServiceException){
    message = "账户不存在,登录失败!";
}else if(exception instanceof CustomerAuthenticationException){
    message = "登录失败!";
}

4.5 配置SecurityConfig

import cn.twsj.handler.AnonymousAuthenticationHandler;
import cn.twsj.handler.CustomerAccessDeniedHandler;
import cn.twsj.handler.LoginFailureHandler;

private CustomerAccessDeniedHandler customerAccessDeniedHandler;

4.6 用户退出系统

因为JWT是无状态的,去中心化的,在服务器端无法清除,服务器一旦进行颁发,就只能等待自动过期才会失效,所以需要redis配合才能完成登录状态的记录。

实现思路:

登录后在redis中添加一个白名单,把认证成功的用户的JWT添加到redis中。

在退出的时候,服务清空springsecurit保存认证通过的Authentication对象,其次在redis中进行删除

改造登录接口:

退出后台代码实现:

//判断redis中是否存在该token
String tokenKey = "token_" + token;
String redisToken = stringRedisTemplate.opsForValue().get(tokenKey);
//如果redis里面没有token,说明该token失效
if (ObjectUtils.isEmpty(redisToken)) {
    throw new CustomerAuthenticationException("token已过期");
}