Spring Boot+Vue+Spring Security OAuth2的前后端分离项目实现研究

业余在开发一个Spring Boot+Vue+Spring Security OAuth2的一个前后端分离项目,其中遇到不少如跨域、OPTIONS请求处理、PreAuthorize注解无效、Token失效处理等问题,记录如下。

在此项目中,资源服务与授权服务在同一应用中,使用端口8081;前端应用使用端口8080。前端使用axios进行ajax调用。
关于Spring Security OAuth2相关内容,可参考:http://liumoran.cn/topic/detail?id=1

1. 后端处理跨域请求问题

如果前后端部署在不同应用服务器上,后端需要配置跨域访问才成。如未配置跨域访问,浏览器F12打开高度页面的Console界面中,可以看到以下信息:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8081/getLoginUser. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing)

Spring Boot中配置跨域请求,可以通过定义Filter的方式实现:

@Component
public class MyCorsFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) res;

        response.setHeader("Access-Control-Allow-Origin","http://localhost:8080");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, PATCH, DELETE, PUT, OPTIONS");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "Content-Type, x-requested-with, X-Custom-Header, Authorization");
        chain.doFilter(req, res);
    }

    @Override
    public void init(FilterConfig filterConfig) {}

    @Override
    public void destroy() {}

}

2. OPTIONS报401问题

为什么在正常的请求前要发送OPTIONS请求?

用户登录成功后,当前端往后端发送的请求中包含有Authorization头,并且是跨域请求时,浏览器将会往后端发送对应的OPTIONS请求,以确认后台服务是否支持对受保护资源的跨域访问。

由于OPTIONS请求中并不带有Token,因此请求被Spring Security OAuth2拦截到后,检查发现没有Token,直接返回401给前端页面了。

如何配置?

可以在Spring Security的配置类中配置允许所有OPTIONS请求访问,如下所示:

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .anonymous()
                .and()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS, "**").permitAll()
                .anyRequest().authenticated();
    }

其中省略掉了其它的资源访问授权配置。

但在配置后前端访问仍旧报401!!经过排查,按前面所说,我是在Spring Security的配置类中添加这个配置的,但实际上,资源服务器中对请求是否有权限访问的处理,是在资源服务器的配置中进行的,因此在单独的Spring Security配置类中进行配置并不会生效!必须在继承自ResourceServerConfigurerAdapter的配置类中进行该项配置。

在资源服务中,实际上是不需要单独的Spring Security的配置类的,直接使用ResourceServerConfigurerAdapter即可完成对各类资源的授权配置。此时的配置类如下:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfigurer extends ResourceServerConfigurerAdapter {
    @Autowired
    private RealAuthenticationProvider authenticationProvider;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("testResource")
                .stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                    .antMatchers(HttpMethod.OPTIONS, "**").permitAll()
                    .antMatchers("/*", "/css/**/*", "/js/**/*", "/icons/**/*", "/upload/*",
                            "/h2-console/", "/h2-console/**/*", "/topic/**/*").permitAll()
                    .anyRequest().authenticated()
                .and()
                .formLogin()
                    .loginPage("/login")
                    .loginProcessingUrl("/iLogin")
                    .permitAll()
                .and().logout().logoutUrl("/logout").logoutSuccessUrl("/")
//                .and().sessionManagement().invalidSessionUrl("/timeout")
                .and().cors();
        http.headers().frameOptions().sameOrigin().httpStrictTransportSecurity().disable();
    }

    @Bean
    public static NoOpPasswordEncoder passwordEncoder() {
        return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }
}

3. 资源服务器中使用PreAuthorize无效问题

个人习惯使用PreAuthorize注解来对需要控制权限的方法进行保护。但在资源服务器中,配置了EnableGlobalMethodSecurity后,发现这个注解仍旧是未生效状态。猜测原因应该是这个注解在Spring Security的上下文中生效,但在Spring Security OAuth2的上下文中并未生效。具体原因留待后续再研究。

通过在ResourceConfigurer类中添加自定义的Voter可以解决这个问题:

http.authorizeRequests().withObjectPostProcessor(new ObjectPostProcessor() {
    @Override
    public  O postProcess(O object) {
        object.getDecisionVoters().add(appVoter);
        return object;
    }
});

其中,自定义Voter实现如下:

@Component
public class AppVoter implements AccessDecisionVoter {
    @Resource
    private RequestMappingHandlerMapping requestMappingHandlerMapping;

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }
    
    @Override
    public int vote(Authentication authentication, Object object, Collection collection) {
        WebExpressionVoter webExpressionVoter = new WebExpressionVoter();
        webExpressionVoter.setExpressionHandler(new DefaultWebSecurityExpressionHandler());

        FilterInvocation invocation = (FilterInvocation) object;
        List<ConfigAttribute> configAttributes = getAttributes(invocation);
        if (0 == configAttributes.size()) {
            return ACCESS_GRANTED;
        }
        return webExpressionVoter.vote(authentication, invocation, configAttributes);
    }

    public List<ConfigAttribute> getAttributes(FilterInvocation invocation) {
        try {
            HandlerExecutionChain handlerExecutionChain = requestMappingHandlerMapping.getHandler(invocation.getRequest());
            HandlerMethod handlerMethod = (HandlerMethod) handlerExecutionChain.getHandler();
            if (!handlerMethod.getBeanType().isAnnotationPresent(PreAuthorize.class)
                    && !handlerMethod.getMethod().isAnnotationPresent(PreAuthorize.class)) {
                return new ArrayList<>(0);
            }

            List<ConfigAttribute> configAttributes = new ArrayList<>(16);
            PreAuthorize preAuthorize = handlerMethod.getBeanType().getAnnotation(PreAuthorize.class);
            if (null != preAuthorize) {
                String str = preAuthorize.value();
                ConfigAttribute configAttribute = new SecurityConfig(str);
                configAttributes.add(configAttribute);
            }

            preAuthorize = handlerMethod.getMethod().getAnnotation(PreAuthorize.class);
            if (null != preAuthorize) {
                String str = preAuthorize.value();
                ConfigAttribute configAttribute = new SecurityConfig(str);
                configAttributes.add(configAttribute);
            }

            return configAttributes;
        } catch (Exception e) {
            e.printStackTrace();
            return new ArrayList<>(0);
        }
    }
    
    @Override
    public boolean supports(Class clazz) {
        return clazz.equals(FilterInvocation.class);
    }
}

此时资源服务器的配置类如下:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfigurer extends ResourceServerConfigurerAdapter {
    @Autowired
    private RealAuthenticationProvider authenticationProvider;

    @Resource
    private AppVoter appVoter;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("testResource")
                .stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                    .antMatchers(HttpMethod.OPTIONS, "**").permitAll()
                    .antMatchers("/*", "/css/**/*", "/js/**/*", "/icons/**/*", "/upload/*",
                            "/h2-console/", "/h2-console/**/*", "/topic/**/*").permitAll()
                    .anyRequest().authenticated()
                .and()
                .formLogin()
                    .loginPage("/login")
                    .loginProcessingUrl("/iLogin")
                    .permitAll()
                .and().logout().logoutUrl("/logout").logoutSuccessUrl("/")
//                .and().sessionManagement().invalidSessionUrl("/timeout")
                .and().cors();
        http.headers().frameOptions().sameOrigin().httpStrictTransportSecurity().disable();
        http.authorizeRequests().withObjectPostProcessor(new ObjectPostProcessor<AffirmativeBased>() {
            @Override
            public <O extends AffirmativeBased> O postProcess(O object) {
                object.getDecisionVoters().add(appVoter);
                return object;
            }
        });
    }

    @Bean
    public static NoOpPasswordEncoder passwordEncoder() {
        return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }
}

通过引入以上Voter,即可使用PreAuthorize注解生效。

4. Token失效时跳到登录页面处理

Token失效后,后端将会返回401错误给前端。此时后端是在OAuth2AuthenticationProcessingFilter这个Filter中处理的,后续的所有Filter包括第一步中所定义的MyCorsFilter也未执行,因此此时返回给前端的报文头中并不包含有跨域相关头信息,前端处理返回时获取不到异常码!

很明显,需要想办法使得MyCorsFilter在OAuth2AuthenticationProcessingFilter之前执行。因此需要修改资源服务器配置,添加以下处理:

http.addFilterBefore(myCorsFilter, WebAsyncManagerIntegrationFilter.class);

此后前端即可通过判断返回的处理码及错误消息来判断是否是Token失效了,如果发现是Token失效,则移除缓存的Token并跳转到登录页面,处理如下:

// 返回数据统一处理
Axios.interceptors.response.use(
  response => {
    ...
  },
  error => {
    console.error(error)
    let statusCode = error.response.status
    if (statusCode === 401 && error.response.data.error === 'invalid_token') {
      // Token失效
      console.log('invalid_token')
      window.localStorage.removeItem('accessToken')
      store.dispatch('updateLoginUser')
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

5. 完整配置

最终,完整的资源服务的配置类如下所示:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfigurer extends ResourceServerConfigurerAdapter {
    @Autowired
    private RealAuthenticationProvider authenticationProvider;

    @Resource
    private AppVoter appVoter;

    @Resource
    private MyCorsFilter myCorsFilter;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("testResource")
                .stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                    .antMatchers(HttpMethod.OPTIONS, "**").permitAll()
                    .antMatchers("/*", "/css/**/*", "/js/**/*", "/icons/**/*", "/upload/*",
                            "/h2-console/", "/h2-console/**/*", "/topic/**/*").permitAll()
                    .anyRequest().authenticated()
                .and()
                .formLogin()
                    .loginPage("/login")
                    .loginProcessingUrl("/iLogin")
                    .permitAll()
                .and().logout().logoutUrl("/logout").logoutSuccessUrl("/")
//                .and().sessionManagement().invalidSessionUrl("/timeout")
                .and().cors();
        http.headers().frameOptions().sameOrigin().httpStrictTransportSecurity().disable();
        http.authorizeRequests().withObjectPostProcessor(new ObjectPostProcessor<AffirmativeBased>() {
            @Override
            public <O extends AffirmativeBased> O postProcess(O object) {
                object.getDecisionVoters().add(appVoter);
                return object;
            }
        });

        http.addFilterBefore(myCorsFilter, WebAsyncManagerIntegrationFilter.class);
    }

    @Bean
    public static NoOpPasswordEncoder passwordEncoder() {
        return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }
}

授权服务的配置如下:(与资源服务在同一服务中)

@Configuration
public class AuthenticationConfiguration extends AuthorizationServerConfigurerAdapter {
    @Resource
    private RealAuthenticationProvider realAuthenticationProvider;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("codelife")
                .resourceIds("testResource")
                .authorizedGrantTypes("password")
                .authorities("ROLE_CLIENT")
                .scopes("read", "write")
                .secret("secret")
                .redirectUris("http://localhost:8080");
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.tokenKeyAccess("permitAll()")
                .checkTokenAccess("hasAuthority('ROLE_CLIENT')")
                .allowFormAuthenticationForClients();
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authentication -> realAuthenticationProvider.authenticate(authentication));
    }

}

你可能感兴趣的:(Spring,Security,OAuth2,前后端分离,Vue,Spring,Boot,axios,Spring)