likes
comments
collection
share

实战指南:使用Spring Security和JWT构建安全的身份验证系统

作者站长头像
站长
· 阅读数 2

使用Spring Security和JWT构建安全的身份验证系统

首先我们需要创建一个配置,而 SecurityFilterChain 是 SpringSecurity 中的一个关键,用于定义一组安全过滤链,会按照顺序依次执行。用于处理认证、授权。

本文主要是说明前后端分离开发模式需要做的配置

禁用 CSRF

先说一下 CSRF 是做什么的吧,CSRF 叫做跨站请求伪造,是一种网络攻击的方式,攻击者通过欺骗用户在已登录网站上执行非预期的动作。

为了防范 CSRF 攻击,常见的做法是使用令牌(Token),服务器在响应中随机生成一个 CSRF 令牌,在后续的请求中都需要携带此令牌,保证请求的正确性。

两种方式都是确保请求的是否是合法用户,而 JWT 是一种基于 JSON 的令牌,它由三部分头部、载荷和签名组成,JWT 优点就是在不同系统之间传递身份信息,允许无状态验证。

既然我们使用了 JWT 无状态认证,那么就不再需要使用 Session 去维护用户的信息,也减轻服务器的压力。

所以下面我们禁用 Csrf 和 Session

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    // 禁用 csrf,前后端分离模式不需要,因为是无状态的
    httpSecurity.csrf(AbstractHttpConfigurer::disable)
            // 禁用 Session存储 -- SessionCreationPolicy.STATELESS 表示永远不会创建会话
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}

设置允许请求

配置验证码,登录请求安全开放,通过 antMatchers 匹配设置允许的请求

通过 .anyRequest().authenticated()) 设置其他请求需要认证

httpSecurity.csrf(AbstractHttpConfigurer::disable)
        // 配置允许请求
        .authorizeRequests(expressionInterceptUrlRegistry ->
                expressionInterceptUrlRegistry.antMatchers("/login", "/captchaImage").permitAll()
                        // 下面是追加对资源的释放,并设置get请求
                        .antMatchers(HttpMethod.GET, "/", "222.html").permitAll()
                        // 其他请求需要认证
                        .anyRequest().authenticated())

禁用 X-Frame-Options

禁用 X-Frame-Options 是一种安全机制,用于防止网页被嵌入到 iframe 中,也是一种安全的行为。

httpSecurity
    // 禁用网页嵌套
    .headers().frameOptions().disable();

配置过滤器链

因为默认是没有过滤器链的,无法实现拦截相关的不合法的请求,以及认证,首先将过滤器链交给 SpringSecurity

@Autowired
private SecurityAdminConfig securityAdminConfig;

httpSecurity.apply(securityAdminConfig);

具体的过滤器链需要继承 SecurityConfigurerAdapter

@Configuration
public class SecurityAdminConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

在这里添加过滤器,过滤器链中可以添加认证、授权的具体实现,例如:设置授权规则。

SecurityConfigurerAdapter 是 SpringSecurity 提供的用于配置 Security 的适配器,通过继承它,可以操作过滤器链。

SecurityConfigurerAdapter 中有一个 configure 方法,在这里面实现,身份验证的过滤器。

创建身份验证过滤器

身份验证器的作用是处理认证,包括配置认证失败,登录失败等等,认证的方法,认证的路由等

需要继承 AbstractAuthenticationProcessingFilter 类,它提供了通用的身份验证处理框架

其中需要实现方法是 attemptAuthentication 在这个里面需要对身份验证

那我们就需要将路由转到身份验证中

在下面的构造方法中,创建了一个 /login 的 post 请求路径,表示拦截此路由到这里。

public AdminUsernamePasswordAuthenticationFilter() {
    // 默认匹配 "/admin/login" 路径的 POST 请求
    super(new AntPathRequestMatcher("/login", "POST"));
}

获取用户密码

我这里是前端是通过 JSON 的方式传递的,所以获取用户密码,需要读取流,后面 JWT 认证需要这个对象的数据,在这里存入

// 由于前端传递的是 JSON,首先要读取流,转换为字符串,解析出来对象类型
        LoginBody loginBody = JSONUtil.toBean(
                new String(IoUtil.readBytes(request.getInputStream()), StandardCharsets.UTF_8), LoginBody.class);
        // 后面 JWT 认证需要这个对象的数据,在这里存入
        // 存入请求头中,这样后面才能使用
        request.setAttribute("LoginBody", loginBody);
                

开始身份验证

要进行身份验证,需要先创建验证令牌类,它默认是无状态的,状态随着验证过程逐渐改变UsernamePasswordAuthenticationToken,unauthenticated 方法可以获取无状态的验证类

UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);

那怎么验证呢

首先要获取当前过滤器链所属的 AuthenticationManager 身份验证类,调用 authenticate 方法将携带用户信息的验证令牌类传递进去进行验证。

this.getAuthenticationManager().authenticate(authRequest);

下面我们写好了,就要使用 身份验证类了

在过滤器链中配置身份验证类,其中需要设置一个身份验证类,这个类是进行身份验证的,否则在验证中就会获得 null

通过 builder.getSharedObject(AuthenticationManager.class) 来获取已配置的AuthenticationManager 实例

又通过 addFilterAt 将自定义过滤器,放到默认过滤器 UsernamePasswordAuthenticationFilter 之前

@Configuration
public class SecurityAdminConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Override
    public void configure(HttpSecurity builder) {
        // 自定义身份验证过滤器
        AdminUsernamePasswordAuthenticationFilter adminUsernamePasswordAuthenticationFilter = new AdminUsernamePasswordAuthenticationFilter();
        // 设置身份验证类--每个过滤器链都要设置
        adminUsernamePasswordAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));

        builder.addFilterAt(adminUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

认证失败类

在身份验证类中,需要做一些校验规则,比如下面这样对账户密码进行了校验、验证码校验等,如果为空,就抛出 BadCredentialsException 异常,这表示参数为空,400

// 由于前端传递的是 JSON,首先要读取流,转换为字符串,解析出来对象类型
LoginBody loginBody = JSONUtil.toBean(
        new String(IoUtil.readBytes(request.getInputStream()), StandardCharsets.UTF_8), LoginBody.class);

if (StringUtils.isEmpty(loginBody.getUsername()))
        throw new BadCredentialsException("用户名不能为空!");

if (StringUtils.isEmpty(loginBody.getPassword()))
        throw new BadCredentialsException("密码不能为空!");

抛出了异常,就要拦截该异常,做一些友好的操作,创建一个类实现 AuthenticationFailureHandler 类,重写 onAuthenticationFailure 方法,之前抛出的异常就会拦截到此处,这里切换不要设置 Code,因为前后端分离汇中,一般会使用 axios 检测后端返回的 response code 码,如果不等于 200,会进入前端的 Error 方法中

@Component
public class FailHandle implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        // response.setStatus(400);----设置200,或者不要设置
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
         // 转 JSON 友好的返回数据 
        response.getWriter().write(JSONUtil.toJsonStr(AjaxResult.error(exception.getMessage())));
    }
}

配置好,我们装载这个认证失败类

在配置过滤器链类中对身份验证过滤器安装,通过 setAuthenticationFailureHandler 方法装载

@Autowired
private FailHandle failHandle; // 刚刚配置的认证失败类

// 自定义身份验证过滤器
AdminUsernamePasswordAuthenticationFilter adminUsernamePasswordAuthenticationFilter = new AdminUsernamePasswordAuthenticationFilter();
// 设置身份验证类--每个过滤器链都要设置
adminUsernamePasswordAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
// 设置认证失败异常类
adminUsernamePasswordAuthenticationFilter.setAuthenticationFailureHandler(failHandle);

密码校验

BCryptPasswordEncoder 是 SpringSecurity 提供的加密器之一,密码加密是为了保护用户密码的安全性,BCryptPasswordEncoder 是基于 Blowfish 密码哈希算法的强大哈希函数,是单向不可逆的,使用了盐增加密码的安全性。

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder();
}

这一步是由 SpringSecurity 自动校验的,但是我们需要修改为从数据库查询,上面已经配置了密码校验器,也配置了校验功能,就是下面这个

// 使用认证管理器进行验证
this.getAuthenticationManager().authenticate(authRequest);

我们首先要创建类去实现 UserDetailsService接口,它是 Spring Security 提供的接口,用于从特定的数据源加载用户信息。

loadUserByUsername 方法是该接口中唯一的方法,它接收一个用户名作为参数,并返回一个实现了 UserDetails 接口的对象。

该对象一般会包含用户的角色,用户的信息,用户的部门和权限等。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return null;
    }
}

下面我们就来看一下实现了 UserDetails 接口的对象

/**
 * 登录用户身份权限
 */
@Data
public class LoginUser implements UserDetails {

    private static final long serialVersionUID = 1L;

    /**
     * 用户ID
     */
    private Long userId;

    /**
     * 部门ID
     */
    private Long deptId;

    /**
     * 用户唯一标识
     */
    private String token;

    /**
     * 登录时间
     */
    private Long loginTime;

    /**
     * 过期时间
     */
    private Long expireTime;

    /**
     * 登录IP地址
     */
    private String ipaddr;

    /**
     * 登录地点
     */
    private String loginLocation;

    /**
     * 浏览器类型
     */
    private String browser;

    /**
     * 操作系统
     */
    private String os;

    /**
     * 权限列表
     */
    private Set<String> permissions;

    /**
     * 用户信息
     */
    private SysUser user;

    public LoginUser() {
    }

    @JSONField(serialize = false)
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    /**
     * 账户是否未过期,过期无法验证
     */
    @JSONField(serialize = false)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 指定用户是否解锁,锁定的用户无法进行身份验证
     */
    @JSONField(serialize = false)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
     */
    @JSONField(serialize = false)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 是否可用 ,禁用的用户不能身份验证
     */
    @JSONField(serialize = false)
    @Override
    public boolean isEnabled() {
        return true;
    }

    /**
     * 权限集合
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

}

返回该对象即可完成身份校验

认证成功类

与认证失败步骤一样,实现 AuthenticationSuccessHandler 接口,重写第一个,三个参数的方法,最后在身份验证类中装载

/**
 * 认证成功
 */
@Component
public class SuccessHandle implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(AjaxResult.success("登录成功")));
    }

}

注意:认证成功之后是需要写入 JWT 的,这里先使用,下一段讲解一下 JWT 的基础知识

首先需要准备一个 Map,第一个加入的是令牌的前缀,我们需要根据这个token获取存入 Redis 的对象

Map<String, Object> claims = new HashMap<>();
claims.put("login_user_key", token);
//增加标识,可以加多个
claims.put("222", "2222");
@Autowired
private SuccessHandle successHandle;

// 自定义身份验证过滤器
AdminUsernamePasswordAuthenticationFilter adminUsernamePasswordAuthenticationFilter = new AdminUsernamePasswordAuthenticationFilter();
// 设置认证成功类
adminUsernamePasswordAuthenticationFilter.setAuthenticationSuccessHandler(successHandle);

到这里就已经配置完成了。

JWT 认证

什么是 JWT 呢,就是将用户数据存在客户端,服务器身份验证后,生成 JSON 对象发送回用户,当用户与服务器进行通信时,发回 JSON 对象,在生成对象时添加签名,对发回的数据进行验证。

这样做的好处就是,可以防止用户恶意修改用户数据。

JWT 由三部分组成

  1. Header 头部:原数据的JSON对象
  2. Payload 载荷:包含传递的数据,和一些默认字段可供选择,签发人、主题、用户、过期时间、生效时间、签发时间和标识JWT
  3. signature 签名:需要指定一个存在服务器上面并且不能向外公开的 secret,这个部分需要使用 base64 URL加密的 head 和 base64 url加密的 Payload 并使用点连接的字符串,还要使用 head 中声明的加密算法进行加盐、secret 组合加密。最后得出签名,并且无法反向解密。

注意这是一行,不需要换行

验证过程

  1. header 做 base64 url 解密
  2. 对 header 和 payload 做一次签名
  3. 比较签名

下面我们先创建一个 Token 过滤器,为了验证 JWT

需要继承 OncePerRequestFilter 过滤器,主要为了验证 Token 的有效性,它集成自 GenericFilterBean 确保在请求处理过程中只会执行一次,不会重复执行。

/**
 * token过滤器 验证token有效性
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

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

需要重写 doFilterInternal 方法,该方法可以处理一下逻辑

我们还需要将 jwt 过滤器装载到过滤器链中,我们为什么要放到密码校验之前呢,因为我们需要首先判断 JWT 存不存在,存在就进行身份校验,不存在直接交给下一个过滤器

/**
 * jwt 认证过滤器
 */
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Override
public void configure(HttpSecurity builder) {
    builder.addFilterAt(adminUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            // 装载
            .addFilterBefore(jwtAuthenticationTokenFilter, AdminUsernamePasswordAuthenticationFilter.class);
}

那我们开始 JWT 认证吧

首先我们应该在登录成功之后,将 JWT 令牌存入起来,并且返回给前端。

这里要做的呢,就是生成 JWT 令牌,并且存入 Redis,下面代码传入的 principal 是实现了 UserDetails 的用户对象

// 生成令牌
String token = tokenService.createToken(principal);

看下面代码,UUID 是用于在 redis 中获取信息,再继续我们就设置登录时间,过期时间等信息,并存入 Redis,过期时间 = 当前登录时间 * 30 * 60 * 10000 就等于 30 分钟的毫秒数,拼接 redis key 为:login_tokens:9193dec8-db26-4261-9192-00496564675e

刷新令牌有效期

// 生成一个 UUID
String token = IdUtils.fastUUID();
// 存入
loginUser.setToken(token);
// 设置登录时间
loginUser.setLoginTime(System.currentTimeMillis());
// 设置有效期
loginUser.setExpireTime(loginUser.getLoginTime() + 30 * 60 * 1000);
// 拼接 redis key
String userKey = "login_tokens:" + token;
// 存入 redis,设置过期时间 30 分钟
stringRedisTemplate.opsForValue().set(userKey, JSONUtil.toJsonStr(loginUser), 30, TimeUnit.MINUTES);

到这里呢,只是存入了,但是并没有 JWT 加密呢,下面这段代码前面已经说过了,是做了一个基本的信息,使用这个信息呢,开始加密。

Map<String, Object> claims = new HashMap<>();
claims.put("login_user_key", token);
//增加标识,可以加多个
claims.put("222", "2222");

下面我们开始加密,第一步,创建一个 JWT 构建器对象,用于构建 JWT,加入的 claims 是包含有关用户和其他数据的 JSON 对象。通常,它包含标准的声明(例如,过期时间、发行时间等)以及自定义的声明。而我们存入的是一个 key,根据key 获取用户信息即可。

加密算法,对 JWT 进行签名。在这里,使用 HS512 算法进行签名,其中 secret 是用于生成签名的密钥。而 secret 盐是自定义的,可以写到 yml文件中。最后构建即可。

返回给前端最终的 JWT 令牌即可。

// 创建 JWT 构建器
JwtBuilder builder = Jwts.builder();
// JWT 的声明
builder.setClaims(claims);
// 声明加密算法
builder.signWith(SignatureAlgorithm.HS512, secret);
// 构建并返回最终的 JWT 字符串
String compact = builder.compact();

认证流程

我们看一下下面代码,跳转到下面代码后,首先判断是否携带 jwt token,如果携带了,是访问资源,如果未携带访问的是公共请求请求。

如果为空,直接放行。如果携带了,就去解密 JWT 字符串。

// 获取用户信息
LoginUser loginUser = tokenService.getLoginUser(token);

看下具体实现,首先第一步肯定是解析 JWT 字符串,然后获取 JWT 字符串内容,根据之前存入的 UUID 获取 Redis 中的用户信息。

首先 JwtParser 创建一个 JWT 解析器对象,拿到了解析器对象,第一步先验证之前的盐也就是 secret 是否一致,盐都不一致的话,这个 Token 肯定是被篡改了。

然后我们解析 Token,获取 Jws<Claims>,这里包含了 JWT的声明和签名信息。

最后获取 JWT 声明的主体部分。

// 创建 JWT 解析器对象
JwtParser parser = Jwts.parser();

// 设置解析器的签名密钥,用于验证 JWT 的签名是否有效
parser.setSigningKey(secret);

// 使用解析器解析传入的 JWT 字符串
// parseClaimsJws 方法返回一个 Jws<Claims> 对象,其中包含了 JWT 的声明(claims)和签名信息
Jws<Claims> claimsJws = parser.parseClaimsJws(token);

// 从 Jws 对象中获取 JWT 的声明部分
// Claims 对象包含了 JWT 中存储的各种声明信息,比如用户ID、过期时间等
Claims body = claimsJws.getBody();

还记得我们前面存入的用户信息的 key 吗,我们获取 JWT 中的信息

// 解析对应的权限以及用户信息
String uuid = (String) claims.get("login_user_key");
// 拼接一下
String userKey = Constants.LOGIN_TOKEN_KEY + uuid;
// 通过redis获取用户信息
String cacheObject = stringRedisTemplate.opsForValue().get(userKey);
// 获取用户信息
LoginUser loginUser = JSONUtil.toBean(cacheObject, LoginUser.class);

拿到用户信息后,如果用户信息是空的,那代表授权已经过期了,或者无效。直接就抛出异常就可以了。如果不是直接就放行。

我们看下面这一段代码,重要

检查 Authentication 是否为空,表示获取当前已经通过认证的用户信息,如果没有则表示可以认证。

// 判断是否已经认证过了
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (StringUtils.isNull(authentication)) {
            
}

如果没有认证过,下面我们就可以进行认证了

首先刷新有效期,为什么会有刷新有效期呢,原因很简单,用户携带了 Token,但是 Security 没有认证信息,这个时候是需要刷新 JWT 的,并重新给 Security 认证。

20 * 60 * 1000L 表示20分钟

Security 认证步骤:

UsernamePasswordAuthenticationToken 要 SpringSecurity 提供的 Authentication 接口的实现类,通过传入一个实现 UserDetails 的实体类,包含用户信息以及具体的权限信息,进行验证。

UsernamePasswordAuthenticationToken表示验证信息,具体参数为;参数一:是用户信息,参数二:是凭证信息,但是之前密码是已经验证过的,无需再次验证。参数三:是所拥有的权限。

setDetails 方法设置认证的详细信息 其中 new WebAuthenticationDetailsSource() 对象封装认证相关的web请求信息,目的是将认证有关的信息存入到 Authentication 对象中。

SecurityContextHolder.getContext().setAuthentication(authenticationToken); 表示通过认证。

// 刷新令牌有效期
tokenService.verifyToken(loginUser);
// 获取过期时间
long expireTime = loginUser.getExpireTime();
// 获取当前时间
long currentTime = System.currentTimeMillis();
// 如果有效期不足 20 分钟,就自动刷新缓存
if (expireTime - currentTime <= 20 * 60 * 1000L) {
    // 下面执行的代码,上面已经说过了,刷新令牌有效期
    
    // Security 认证步骤
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}

下面是具体代码。

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private TokenService tokenService;

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

        // 获取请求携带的令牌
        String token = tokenService.getToken(request);

        // 如果为空直接放行
        if (StringUtils.isEmpty(token)) {
            chain.doFilter(request, response);
            return;
        }

        // 获取用户信息
        LoginUser loginUser = tokenService.getLoginUser(token);
        if (StringUtils.isNull(loginUser)) {
            throw new BadCredentialsException("令牌无效");
        }

        if (StringUtils.isNull(SecurityContextHolder.getContext().getAuthentication())) {
            // 刷新令牌有效期
            tokenService.verifyToken(loginUser);
            // 认证
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }

        chain.doFilter(request, response);
    }
}

JWT 认证失败怎么处理

在 Security 配置类中添加一下代码,其中 httpSecurity.exceptionHandling 是 Spring Security 配置中用于处理异常的一部分,其中 ex.authenticationEntryPoint 用于处理访问未授权的资源。我们添加一个实现 AuthenticationEntryPoint 接口的类即可

@Autowired
private AuthenticationEntryPointImpl authenticationEntryPoint;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

    httpSecurity.exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint));
    return httpSecurity.build();
}

我们具体看一下这个处理认证失败的类 AuthenticationEntryPointImpl

/**
 * 认证失败处理类 返回未授权
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(AjaxResult.error(msg)));
    }
}

退出类

首先定义这样一个 Bean

这里就不过多介绍了,就是记得删除 redis 中的数据。有个注意事项哈,就是退出过滤器是需要认证的,不能公开。

@Service
public class LogoutSuccessHandle implements LogoutSuccessHandler {

    @Autowired
    private TokenService tokenService;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String token = tokenService.getToken(request);
        if (token != null) {
            LoginUser loginUser = tokenService.getLoginUser(token);
            if (StringUtils.isNotNull(loginUser)) {
                // 删除用户的缓存
                tokenService.delLoginUser(loginUser.getToken());
            }
        }
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(JSONUtil.toJsonStr(AjaxResult.error("退出登录成功!")));
}

安装在 Security 的配置类中

@Autowired
private LogoutSuccessHandle logoutSuccessHandle;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

    // 退出过滤器
    httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandle);
    return httpSecurity.build();
}

最后补充一下,处理身份验证和注销请求之前,先进行 CORS 过滤,以确保跨域请求能够正确处理。

因为现在前后端都在不同的域下

在 Security 配置类中添加一下代码,暂时留空