业余在开发一个Spring Boot+Vue+Spring Security OAuth2的一个前后端分离项目,其中遇到不少如跨域、OPTIONS请求处理、PreAuthorize注解无效、Token失效处理等问题,记录如下。
在此项目中,资源服务与授权服务在同一应用中,使用端口8081;前端应用使用端口8080。前端使用axios进行ajax调用。
关于Spring Security OAuth2相关内容,可参考:http://liumoran.cn/topic/detail?id=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() {}
}
为什么在正常的请求前要发送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);
}
}
个人习惯使用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注解生效。
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)
}
)
最终,完整的资源服务的配置类如下所示:
@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));
}
}