Sa-Token过滤器引发的CORS误判问题

有一段时间没有运行ttsx-backend这个项目了,打开的时候看了一下Sa-Token的相关配置,好像是使用的Sa-Token拦截器进行权限处理。

这个拦截器在使用的时候会有一点问题,在未登录状态下拦截请求时会抛出异常,提示Sa-Token上下文未初始化,于是又改成了通过过滤器进行认证处理。

后端代码

下面是配置Sa-Token过滤器的代码:都是根据官方文档进行配置的。

  • 排除了匿名接口(使用了@SaIgnore注解的控制器接口)
  • 查询非匿名接口信息,配置需要指定的权限才能访问
  • Knife4j只有超管用户允许访问
  • 自定义过滤器异常处理,根据异常类型分类处理
    • 设置不同的HTTP响应状态码,并返回对应的提示
    • 在过滤器中直接返回,阻止请求被继续处理
java 复制代码
package cn.edu.sgu.www.ttsx.config;

import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.context.model.SaResponse;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import cn.dev33.satoken.filter.SaFilterAuthStrategy;
import cn.dev33.satoken.filter.SaFilterErrorStrategy;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.fun.SaFunction;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.edu.sgu.www.common.restful.JsonResult;
import cn.edu.sgu.www.common.restful.ResponseCode;
import cn.edu.sgu.www.pms.dubbo.PermissionDubboService;
import cn.edu.sgu.www.pms.entity.Permission;
import cn.edu.sgu.www.ttsx.config.property.CorsProperties;
import com.alibaba.fastjson2.JSON;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;
import java.util.List;

/**
 * Sa-Token配置类
 * @author 沐雨橙风ιε
 * @version 1.0
 */
@Configuration
public class SaTokenConfig {

    /**
     * Knife4j的资源
     */
    private static final String[] knife4jUrls;

    static {
        knife4jUrls = new String[] {
                "/doc.html",
                "/v2/api-docs",
                "/swagger-ui.html",
                "/swagger-resources",
                "/swagger-resources/configuration/ui",
                "/swagger-resources/configuration/security"
        };
    }

    @Value("${spring.application.name}")
    private String service;

    @DubboReference
    private PermissionDubboService permissionDubboService;

    /**
     * 注册Sa-Token全局过滤器
     */
    @Bean
    public SaServletFilter saTokenFilter(CorsProperties corsProperties) {
        // 查询匿名接口的url列表
        List<String> list = permissionDubboService.selectAnonymityUrls(service);

        // 不拦截knife4j的资源,否则Knife4j的页面将不可访问
        list.addAll(Arrays.asList(knife4jUrls));

        // 查询需要权限访问的资源
        List<Permission> permissions = permissionDubboService.selectPermissions(service);

        SaServletFilter saServletFilter = new SaServletFilter();

        saServletFilter.addInclude("/**");
        // 排除的路径:匿名访问接口
        saServletFilter.addExclude(list.toArray(new String[] { }));

        // 处理认证
        saServletFilter.setAuth(new SaFilterAuthStrategy() {
            @Override
            public void run(Object obj) {
                /*
                 * 需要权限访问的资源
                 */
                // 超管才能访问Knife4j的资源
                SaRouter.match(knife4jUrls).check(new SaFunction() {
                    @Override
                    public void run() {
                        StpUtil.checkLogin();

                        // 已经登录才校验权限
                        if (StpUtil.isLogin()) {
                            // 超管才有权限查看接口文档
                            StpUtil.checkRole("ttsx:system-admin");
                        }
                    }
                });

                if (!permissions.isEmpty()) {
                    for (Permission permission : permissions) {
                        SaRouter.match(permission.getUrl(), new SaFunction() {
                            @Override
                            public void run() {
                                StpUtil.checkLogin();
                                StpUtil.checkPermission(permission.getValue());
                            }
                        });
                    }
                }
            }
        });

        // 处理认证异常
        saServletFilter.setError(new SaFilterErrorStrategy() {
            @Override
            public Object run(Throwable throwable) {
                // 响应状态码:403 Forbidden
                ResponseCode responseCode = ResponseCode.FORBIDDEN;

                // 构建响应数据
                String message = null;

                // 不同类型的异常区别处理
                if (throwable instanceof NotLoginException) {
                    message = "请登录后访问!";
                } else if (throwable instanceof NotRoleException || throwable instanceof NotPermissionException) {
                    message = "正在访问未授权的资源!";

                    // 设置响应状态码:401 Unauthorized
                    responseCode = ResponseCode.UNAUTHORIZED;
                }

                // 获取响应对象
                SaResponse response = SaHolder.getResponse();

                // 设置响应状态码
                response.setStatus(responseCode.getCode());

                JsonResult<Void> jsonResult = JsonResult.error(responseCode, message);

                return JSON.toJSONString(jsonResult);
            }
        });

        return saServletFilter;
    }

}

但是,新的问题又出现了,原本在未登录状态下发起未授权的请求,会提示"请登陆后重试"的提示信息。而现实是操作无反应,打开浏览器控制台能看到错误信息。

很显然,CORS的OPTIONS预检请求失败了,因为还没有登录,会被过滤器拦截处理,返回了403状态码,所以浏览器认为CORS失败,也就会引发浏览器的CORS错误处理了。

由于同源策略的限制,浏览器不能读取响应的数据,也就无法对HTTP响应进行处理了。

前端代码

javascript 复制代码
// 添加响应拦截器
instance.interceptors.response.use(function (resp) {
    return resp.data;
}, function (err) {
    console.log("invoke onRejected method of response interceptor.");

    error(err); // 执行这行代码时浏览器报错了,CORS失败而无法读取响应数据

    return Promise.reject(err);
});

原因分析

从上面浏览器控制台截图可以看到,浏览器发送的CORS预检请求返回了403状态码,浏览器就会认为CORS失败,也就无法读取服务端的HTTP响应了。

预检请求

CORS预检请求:请求方法为OPTIONS的请求,用于检查服务端是否允许接受跨域请求。

"预检"一词翻译于preflight,相关文档地址如下:

javascript 复制代码
https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#functional_overview

问题解决

为了防止预检请求失败,需要对Sa-Token的过滤器进行一些改进。

现在我们已知的是CORS预检请求的请求方法为OPTIONS,所以我们可以通过对OPTIONS请求放行来简单地避免这个CORS误判问题。

对此,需要重写过滤器的具体执行过滤的方法。

重写过滤器

在过滤器的doFilter()方法中放行,并通过return语句阻止请求被继续执行后面的处理认证的代码。

java 复制代码
package cn.edu.sgu.www.ttsx.support;

import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.util.SaTokenConsts;
import cn.edu.sgu.www.ttsx.util.HttpUtils;
import org.springframework.core.annotation.Order;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * 自定义Sa-Token过滤器
 * @author 沐雨橙风ιε
 * @version 1.0
 */
@Order(SaTokenConsts.ASSEMBLY_ORDER)
public class SaTokenFilter extends SaServletFilter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        /*
         * CORS预检请求(OPTIONS)直接放行
         * 如果预检请求因为执行认证时失败,会被误判为CORS失败
         * 由于OPTIONS请求不会携带token请求头,通过Sa-Token过滤器处理认证时必然失败
         */
        HttpServletRequest request = HttpUtils.getRequest();

        if (isPreCheckRequest(request)) {
            // 放行请求
            chain.doFilter(req, resp);

            return;
        }

        super.doFilter(req, resp, chain);
    }

    /**
     * 是否CORS预检请求
     * @param request HttpServletRequest对象
     */
    private boolean isPreCheckRequest(HttpServletRequest request) {
        String method = request.getMethod();

        // CORS预检请求的请求方法为OPTIONS
        return "OPTIONS".equalsIgnoreCase(method);
    }

}

super.doFilter()就是调用原来SaServletFilter里的doFilter()方法。

也就是处理认证的代码,可以在后端代码的对应位置去查看。

最后,替换掉创建的Sa-Token过滤器。

java 复制代码
SaServletFilter saServletFilter = new SaTokenFilter();

重启后端项目,然后重新操作一次,这次点击按钮还是没有反应。

查看控制台,CORS预检请求已经成功了,错误信息里少了一条CORS的错误提示。

但是,很多人对这个仅剩的1条跨域失败的亮眼的错误信息给整不会了,不是CORS成功了吗,为什么还会显示CORS错误。

其实提示里已经说明了原因,缺少Access-Control-Allow-Origin这个响应头。

添加CORS响应头

原因分析

为什么请求会缺少Access-Control-Allow-Origin这个响应头呢?

要弄明白这个问题,就需要查看我们Sa-Token过滤器处理认证错误的代码了。

java 复制代码
// 处理认证异常
saServletFilter.setError(new SaFilterErrorStrategy() {
    @Override
    public Object run(Throwable throwable) {
        // 响应状态码:403 Forbidden
        ResponseCode responseCode = ResponseCode.FORBIDDEN;

        // 构建响应数据
        String message = null;

        // 不同类型的异常区别处理
        if (throwable instanceof NotLoginException) {
            message = "请登录后访问!";
        } else if (throwable instanceof NotRoleException || throwable instanceof NotPermissionException) {
            message = "正在访问未授权的资源!";

            // 设置响应状态码:401 Unauthorized
            responseCode = ResponseCode.UNAUTHORIZED;
        }

        // 获取响应对象
        SaResponse response = SaHolder.getResponse();

        // 设置响应状态码
        response.setStatus(responseCode.getCode());

        JsonResult<Void> jsonResult = JsonResult.error(responseCode, message);

        return JSON.toJSONString(jsonResult);
    }
});

很显然,浏览器里显示的403状态码就是来自与这里的。

也就是说,在这个方法里已经给浏览器客户端发送响应了。

那问题就简单多了,这里有设置CORS响应头吗?答案显而易见,并没有!


既然没有设置CORS响应头,那加上设置的代码不就行了。

但是Access-Control-Allow-Origin响应头的值是什么呢?

别忘了,前面已经有了成功的OPTIONS请求,查看一下请求里的响应头就可以啦。

博主一眼就看到了4个和Access-Control-Allow-Origin名字很像的响应头。

而Access-Control-Allow-Origin响应头的值是前端项目的地址(IP+端口号)。


前端项目的地址可以通过这两个请求头获取。

只需要获取并设置到Access-Control-Allow-Origin响应头的值就可以了。

除了Access-Control-Allow-Origin响应头以外,其他几个的作用根据它们的名字和值也能猜出来。

  • Access-Control-Allow-Methods:允许的CORS请求方法
  • Access-Control-Allow-Headers:CORS请求中允许携带的请求头
  • Access-Control-Max-Age:这个响应头的值是博主自己设置的
  • Access-Control-Allow-Credentials:这个响应头的值是boolean类型的,直接设置成一样

代码展示

根据上面的分析,对处理认证错误的代码进行修改,设置上面提到的5个CORS相关的响应头。

SaTokenConfig.java
java 复制代码
// 处理认证异常
saServletFilter.setError(new SaFilterErrorStrategy() {
    @Override
    public Object run(Throwable throwable) {
        // 响应状态码:403 Forbidden
        ResponseCode responseCode = ResponseCode.FORBIDDEN;

        // 构建响应数据
        String message = null;

        // 不同类型的异常区别处理
        if (throwable instanceof NotLoginException) {
            message = "请登录后访问!";
        } else if (throwable instanceof NotRoleException || throwable instanceof NotPermissionException) {
            message = "正在访问未授权的资源!";

            // 设置响应状态码:401 Unauthorized
            responseCode = ResponseCode.UNAUTHORIZED;
        }

        // 获取响应对象
        SaResponse response = SaHolder.getResponse();

        // 设置响应状态码
        response.setStatus(responseCode.getCode());

        /*
         * 设置CORS响应头
         */
        // 获取请求对象
        HttpServletRequest request = HttpUtils.getRequest();
        // 获取Origin请求头
        String origin = request.getHeader("Origin");

        // 获取CORS配置
        String maxAge = corsProperties.getMaxAge().toString();
        String allowedHeaders = String.join(",", corsProperties.getAllowedHeaders());
        String allowedMethods = String.join(",", corsProperties.getAllowedMethods());

        response.setHeader("Access-Control-Max-Age", maxAge);
        response.setHeader("Access-Control-Allow-Origin", origin);
        response.setHeader("Access-Control-Allow-Methods", allowedMethods);
        response.setHeader("Access-Control-Allow-Headers", allowedHeaders);
        response.setHeader("Access-Control-Allow-Credentials", "true");

        JsonResult<Void> jsonResult = JsonResult.error(responseCode, message);

        return JSON.toJSONString(jsonResult);
    }
});
CorsProperties .java

CorsProperties 就是一个配置文件的读取类。

java 复制代码
package cn.edu.sgu.www.ttsx.config.property;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.io.Serializable;

/**
 * 跨域资源配置
 * @author 沐雨橙风ιε
 * @version 1.0
 */
@Data
@Component
@ConfigurationProperties("cors")
public class CorsProperties implements Serializable {
    private static final long serialVersionUID = 18L;

    /**
     *
     */
    private Long maxAge;

    /**
     * 允许的请求头
     */
    private String[] allowedHeaders;

    /**
     * 允许的跨域请求方式
     */
    private String[] allowedMethods;

    /**
     * 允许的跨域网站地址
     */
    private String[] allowedOrigins;
}
application.yml

配置文件中的配置内容如下,其实项目中用到的自定义请求头只有一个。

java 复制代码
cors:
  max-age: 1800
  allowed-headers: # 允许的跨域请求头
    - '*'
  allowed-methods: # 允许的跨域请求方式
    - GET
    - POST
    - OPTIONS
  allowed-origins: # 允许的跨域网站地址
    - http://localhost:8087

重新操作

完成上面的修改之后,重新启动后端项目,再次点击操作按钮。

久违的错误提示已经出来了,这里用的是Element-UI的消息提示框。

浏览器的控制台也没有CORS的错误提示了。

文章结语

至此,问题已经被完美解决~

写这篇文章也是想分享一下自己遇到的问题,也许会对别人有所帮助。

好了,文章分享到这里了,如果觉得文章对你有所帮助(或者觉得博主写的好),不要吝啬你的一键三连哦~

相关推荐
毕设十刻2 小时前
基于Vue的酒店管理系统4yv4w(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
梦6502 小时前
Vue3 响应式原理与响应式属性 详解
前端·javascript·vue.js
zhengxianyi5152 小时前
ruoyi-vue-pro数据大屏优化——解决go-view同一个大屏报表在数据库中存储大量的图片的问题
前端·vue.js·前后端分离·数据大屏·ruoyi-vue-pro优化
这里是杨杨吖2 小时前
SpringBoot+Vue古建筑文化宣传交流系统 附带详细运行指导视频
vue.js·spring boot·系统·古建筑·文化宣传
一 乐2 小时前
动漫交流与推荐平台|基于springboot + vue动漫交流与推荐平台系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端
Irene199112 小时前
Vue 官方推荐:kebab-case(短横线命名法)
javascript·vue.js
一只小阿乐14 小时前
vue-web端实现图片懒加载的方
前端·javascript·vue.js
+VX:Fegn089514 小时前
计算机毕业设计|基于springboot + vue小型房屋租赁系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
萌萌哒草头将军15 小时前
Node.js 存在多个严重安全漏洞!官方建议尽快升级🚀🚀🚀
vue.js·react.js·node.js