记录一次SpringBoot跨域的踩坑经历——SpringSecurity跨域解决方案(/oauth/token 401)
目录
项目场景:
目前在重构一个导购后端系统,我负责用户的模块和登录鉴权的整个业务的架构设计和代码编写
利用SpringBoot + SpringSecurity + Oauth2完成了简单的登录和鉴权
登录功能:首先需要调用/oauth/token接口,根据用户名密码获取toke,拿到token后将token放入请求头Header中再去请求其它接口。
接口开发完成并使用Postman测试通过,再由前端去画页面联调接口。
问题描述:
问题就出现在了前端接口联调这里,前端是用Vue完成的,安装好Nodejs环境使用 npm run dev 本地运行
此时前端的服务端口是8080,后端的用户鉴权服务使用的端口为8081,这里端口不一致就出现了跨域问题(端口、协议、域名不一致都会出现跨域问题)
前端和后端当然不可能同时使用8080端口
这里Vue提供了一个代理服务器,只需要在index.js中配置proxyTable相关的信息即可
proxyTable: { // 这里配置 '/api' 就等价于 target , 你在链接里访问 /api === http://localhost:8081 '/api': { target: 'http://localhost:8081/', // 真实服务器的接口地址 secure: true, // 如果是 https ,需要开启这个选项 changeOrigin: true, // 是否是跨域请求 pathRewirte: { // 这里是追加链接,比如真是接口里包含了 /api,就需要这样配置. '/^api': 'api/', // 等价于 // step 1 /api = http://localhost:8081/ // step 2 /^api = /api + api == http://localhost:8081/api } } }
这当然是一种解决方案,还有一种是前端工程打包放入Nginx,由Nginx做请求转发,这个只适用于生产环境。
但是我们遇到的问题是配置的代理不生效,使用了所有的办法proxyTable配置居然不生效,所以跨域的这个问题交给了后端。
解决方案:
这里不再对proxyTable不生效的问题进行分析,而是结合目前使用的框架进行后端跨域的配置。
通用
因为使用的是SpringBoot+SpringSecurity框架,所以需要编写Cors跨域的配置,并在WebSecurityConfigurerAdapter安全适配器中添加
Cors配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { // 允许所有请求跨域 registry .addMapping("/**") .allowedMethods("*") .allowedOrigins("*") .allowCredentials(true) .allowedHeaders("*") .maxAge(3600); } private CorsConfiguration buildConfig() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.addAllowedOrigin("*"); // 1 corsConfiguration.addAllowedHeader("*"); // 2 corsConfiguration.addAllowedMethod("*"); // 3 return corsConfiguration; } @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", buildConfig()); // 4 return new CorsFilter(source); } }
WebSecurityConfigurerAdapter配置:
@Override protected void configure(HttpSecurity http) throws Exception { http // 跨域 .cors() .and() // 禁用csrf .csrf().disable();
在这里加上cors()之后,会从Spring容器中拿到一个名字为CorsFilter的过滤器Bean,也就是刚开始配置的过滤器(CorsConfig),从对应的源码也能看到相应的注释
/** * Adds a {@link CorsFilter} to be used. If a bean by the name of corsFilter is * provided, that {@link CorsFilter} is used. Else if corsConfigurationSource is * defined, then that {@link CorsConfiguration} is used. Otherwise, if Spring MVC is * on the classpath a {@link HandlerMappingIntrospector} is used. * * @return the {@link CorsConfigurer} for customizations * @throws Exception */ public CorsConfigurer<HttpSecurity> cors() throws Exception { return getOrApply(new CorsConfigurer<>()); }
一般情况下,配置完这个跨域过滤器之后就可以解决跨域的问题了
再次尝试请求/oauth/token接口,这下可以成功获取token,前端工程端口8080,后端服务端口8081
之后拿着token去请求其它接口,在这一步再次出现问题。
前端js没有问题:
methods: { // 获取省份列表 getProvinceList() { let that = this that.axios.get('/provinceList',{ headers:{ 'Authorization': 'Bearer ' + localStorage.getItem('access_token') } } ).then(data => { .....
页面对/provinceList发起了两次请求,其中有一次请求为OPTIONS
Request URL:http://127.0.0.1:8081/provinceList
Request Method:OPTIONS
Status Code:401
Request Headers:
Accept:*/*
Accept-Encoding:gzip, deflate, br
Accept-Language:zh-CN,zh;q=0.9
Access-Control-Request-Headers:authorization
Access-Control-Request-Method:GET
之后的Get请求也是失败
原来在请求接口之前会有一次预检请求,以检测实际请求是否可以被服务器所接受。预检请求报文中的Access-Control-Request-Method 首部字段告知服务器实际请求所使用的 HTTP 方法;Access-Control-Request-Headers 首部字段告知服务器实际请求所携带的自定义首部字段。服务器基于从预检请求获得的信息来判断,是否接受接下来的实际请求。
从上面的请求信息也知道了请求失败的原因:
由于SpringSecurity对该OPTIONS请求进行了拦截,发现头部没有携带Token信息,直接返回了401的状态,浏览器认为预检请求不通过,之后的真实请求当然也认为是不通过的。
所以通用方案只能起部分作用。
终极方案
既然知道了原因,那解决起来就容易多了。
方案一:
直接放行OPTIONS请求。
这种方案简单粗暴,既然被SpringSecurity拦截了,那就直接在配置中放行OPTIONS请求。
@Override protected void configure(HttpSecurity http) throws Exception { http // 放行所有OPTIONS请求 .antMatchers(HttpMethod.OPTIONS).permitAll() // 其他的需要登陆后才能访问 .anyRequest().authenticated() .and() // 跨域 .cors() .and() // 禁用csrf .csrf().disable();
但是这种方案存在一定缺陷,因为我们在编写接口时,有些接口对请求方式没有做具体要求(例如:@RequestMapping("/xxx")),这会导致某些资源没有做权限校验就拿到了,安全性不高。
方案二:
在SpringSecurity进行权限校验之前拦截OPTIONS请求,直接返回200。这种方案不会造成服务器资源的安全问题。
操作步骤也很简单,在SpringSecurity的FilterChain拦截链条中添加自定义的拦截器,设置这个拦截器的顺序在权限校验拦截器之前,在拦截器里直接过滤掉OPTIONS请求。
自定义拦截器:
public class OptionsFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 判断请求是否为OPTIONS if (req.getMethod().equals(HttpMethod.OPTIONS.name())) { // 响应状态设置为200 resp.setStatus(HttpStatus.SC_OK); // 响应头 resp.setHeader("Access-Control-Allow-Origin", "*"); resp.setHeader("Access-Control-Allow-Methods", "*"); resp.setHeader("Access-Control-Max-Age", "3600"); resp.setHeader("Access-Control-Allow-Headers", "*"); return; } chain.doFilter(request, response); } }
加入SpringSecurity的拦截链条:
@Override protected void configure(HttpSecurity http) throws Exception { http // 放行所有OPTIONS请求 .antMatchers(HttpMethod.OPTIONS).permitAll() // 其他的需要登陆后才能访问 .anyRequest().authenticated() .and() // 过滤OPTIONS请求 .addFilterBefore(new OptionsFilter(), WebAsyncManagerIntegrationFilter.class) .and() // 跨域 .cors() .and() // 禁用csrf .csrf().disable();
代码中把OptionsFilter过滤器加到了WebAsyncManagerIntegrationFilter这个过滤器之前,当然换成其他也可以,但必须是在读取头部Token之前
保存重启工程,前端请求没有问题,至此就彻底解决了跨域的问题。
骚操作:
除了以上两种解决方案,还有一种
直接上代码
// web安全适配器 @Configuration @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) @Order(-1) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { ..... @Override protected void configure(HttpSecurity http) throws Exception { http.requestMatchers().antMatchers(HttpMethod.OPTIONS, "/oauth/token", "/rest/**", "/api/**") .and() // 跨域 .cors() .and() // 禁用csrf .csrf().disable(); .....
骚操作最简单,至于原理就不细说了