Spring Security Of Filter Chain Related Applications


ObjectPostProcessor 使用

1、简介

之前了解了 ObjectPostProcessor 的基本概念,所有的过滤器都由对应的配置类负责创建,配置类在将过滤器创建成功之后,会调用父类的 postProcess 方法,该方法最终会调用到 CompositeObjectPostProcessor 对象的 postProcess 方法,在该方法中,会遍历 CompositeObjectPostProcessor 对象所维护的 List 集合中存储的所有 ObjectPostProcessor 对象,并调用其 postProcess 方法对对象进行后置处理。

默认情况下,CompositeObjectPostProcessor 对象中所维护的 List 集合中只有一个对象那就是 AutowireBeanFactoryObjectPostProcessor,调用 AutowireBeanFactoryObjectPostProcessorpostProcess 方法可以将对象注册到 Spring 容器中去。

开发者可以自定义 ObjectPostProcessor 对象,并添加到 CompositeObjectPostProcessor 所维护的 List 集合中,此时,当一个过滤器在创建成功之后,就会被两个对象后置处理器处理,第一个是默认的对象后置处理器,负责将对象注册到 Spring 容器中;第二个是我们自定义的对象后置处理器,可以完成一些个性化配置。

自定义 ObjectPostProcessor 对象比较典型的用法是动态权限配置。

2、测试

这里先举一个简单的例子来熟悉 ObjectPostProcessor 的用法:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .withObjectPostProcessor(
                        new ObjectPostProcessor<UsernamePasswordAuthenticationFilter>() {
                            @Override
                            public <O extends UsernamePasswordAuthenticationFilter> O postProcess(O object) {
                                
                                object.setAuthenticationSuccessHandler(((request, response, authentication) -> {
                                    response.getWriter().write("login success");
                                }));
                                
                                return object;
                            }
                        }
                )
                .and()
                .csrf().disable();
    }
}

这个案例中,调用 formLogin 方法之后,开启了 FormLoginConfigurer 的配置,FormLoginConfigurer 的作用是为了配置 UsernamePasswordAuthenticationFilter 过滤器,在 formLogin 方法执行完毕之后,我们调用 withObjectPostProcessor 方法对 UsernamePasswordAuthenticationFilter 过滤器进行二次处理,主要是配置一个登录成功的处理器,当然还可以进行其他配置。

多种用户定义方式

之前介绍的定义用户主要是两种方式:

  • 第一种方式是通过重写 configure(AuthenticationManagerBuilder) 方法的方式,给全局的 AuthenticationManager 提供我们自定义的用户获取方式(UserDetailsService);
  • 第二种方式是当定义了多种数据源时,我们直接向 Spring 容器中注入了 UserDetailsService 对象。

那么这两种用户定义方式有什么区别呢?

根据前面的源码分析可知,在 Spring Security 中存在两种类型的 AuthenticationManager,一种是全局的 AuthenticationManager,另一种是局部的 AuthenticationManager

局部的 AuthenticationManagerHttpSecurity 进行配置,而全局的 AuthenticationManager 可以不用配置,系统会默认提供一个全局的 AuthenticationManager 对象,也可以通过重写 configure(AuthenticationManagerBuilder) 方法进行全局配置。

当进行用户身份验证时,首先会通过局部的 AuthenticationManager 对象进行验证,如果验证失败,则会调用其 parent 也就是全局的 AuthenticationManager 再次进行验证。

所以开发者自定义用户时,也分为两种情况,一种是针对局部 AuthenticationManager 定义的用户,另一种则是针对全局 AuthenticationManager 定义的用户。

为了方便演示,接下来直接使用 InMemoryUserDetailsManager 来构建用户对象。

1、针对局部 AuthenticationManager

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        users.createUser(User.withUsername("naivekyo").password("{noop}123456").roles("admin").build());

        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .permitAll()
                .and()
                .userDetailsService(users)
                .csrf().disable();
    }
}

在上面这段代码中,我们基于内存来管理用户,并向 users中添加了一个用户,将配置好的 users 对象添加到 HttpSecurity 中,也就是配置到局部的 AuthenticationManager 中。

配置完成后,启动项目。项目启动成功后,我们可以使用 naivekyo/123456 登录系统。

但是在启动项目时,控制台输出的日志可以看到以下内容:

这个是系统自动生成的用户,那么我们是否可以使用系统自动生成的用户进行登录呢?答案是可以的。

之前分析过,系统自动提供的用户对象实际上就是向 Spring 容器中注册了一个 InMemoryUserDetailsManager 对象。而之前的代码中,我们没有重写 configure(AuthenticationManagerBuilder) 方法,这意味着全局 AuthenticationManager 是通过 AuthenticationConfiguration#AuthenticationManager 方法自动生成的,在生成的过程中,会从 Spring 容器中查找对应的 UserDetailsUser 实例进行配置(具体配置在 InitializeUserDetailsManagerConfigurer 类中)。所以系统自动提供的用户实际上相当于是全局 InMemoryUserDetailsManager 对应的用户。

以上面的代码为例,当我们开始执行登录后,Spring Security 首先会调用局部 AuthenticationManager 去进行登录校验,如果登录的用户名/密码是 naivekyo/123456,那就直接登录成功,否则登录失败。

当登录失败时,会继续调用局部 AuthenticationManager 的 parent 继续进行校验,此时如果登录的用户名/密码是 user/33964902-65f3-4479-94da-14c4822de6dd,则登录成功,否则登录失败。

这是针对局部 AuthenticationManager 定义的用户,我们也可以将定义的用户配置给全局的 AuthenticationManager,由于默认的全局 AuthenticationManager 在配置时会从 Spring 容器中查找 UserDetailsService 实例,所以我们如果针对全局 AuthenticationManager 配置用户,只需要往 Spring 容器中注入一个 UserDetailsService 实例即可,代码如下:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    UserDetailsService us() {

        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        users.createUser(User.withUsername("root").password("{noop}123456").roles("admin").build());
        
        return users;
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("naivekyo").password("{noop}123456").roles("admin").build());

        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .successHandler((request, response, authentication) -> {
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().write("登录成功!");
                })
                .permitAll()
                .and()
                .userDetailsService(manager)
                .csrf().disable();
    }
}

配置完成,重启项目,此时全局的 AuthenticationManager 在配置时会去 Spring 容器中查找 UserDetailsService 实例,找到的就是我们自定义的 UserDetailsService 实例。

此时我们进行登录时,系统根据我们输入的用户名/密码,先去和 naivekyo/123456 进行匹配,如果匹配失败,再去和 root/123456 进行匹配。

2、针对全局 AuthenticationManager

当然,开发者也可以不使用 Spring Security 提供的默认的全局 AuthenticationManager 对象,而是通过重写 configure(AuthenticationManagerBuilder) 方法来自定义全局 AuthenticationManager 对象:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        
        auth.inMemoryAuthentication().withUser("admin")
                .password("{noop}123456")
                .roles("admin");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("naivekyo").password("{noop}123456").roles("admin").build());

        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .successHandler((request, response, authentication) -> {
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().write("登录成功!");
                })
                .permitAll()
                .and()
                .userDetailsService(manager)
                .csrf().disable();
    }
}

根据之前对 WebSecurityConfigurerAdapter 的源码分析可知,一旦我们重写了 configure(AuthenticationManagerBuilder) 方法,则全局的 AuthenticationManager 对象将不再通过 AuthenticationConfiguration#getAuthenticationManager 方法来构建,而是通过 WebSecurityConfigurerAdapterlocalConfigureAuthenticationBldr 变量来构建,该变量也是我们重写的 configure(AuthenticationManagerBuilder) 方法的参数。

配置完成后,重启项目,全局的 AuthenticationManager 在构建时直接使用我们重写方法的 auth 变量去构建,使用的用户也是我们配置给 auth 变量的用户。

注意

需要注意的是,当我们重写了 configure(AuthenticationManagerBuilder) 方法后,全局AuthenticationManager 对象中使用的用户,将以该方法定义的用户为准。此时,如果我们还向 Spring 容器中注入了另外一个 UserDetailsService 实例,那么该实例定义的用户将不会生效(因为 AuthenticationConfiguration#getAuthenticationManager 方法没有被调用)。

定义多个过滤器链

在 Spring Security 中可以同时存在多个过滤器链,一个 WebSecurityConfigurerAdapter 实例就可以配置一条过滤器链。

看如下代码:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    UserDetailsService us() {
        
        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        users.createUser(User.withUsername("root").password("{noop}123456").roles("admin").build());
        
        return users;
    }
    
    @Configuration
    @Order(1)
    static class SecurityConfig01 extends WebSecurityConfigurerAdapter {

        @Override
        protected void configure(HttpSecurity http) throws Exception {

            InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
            users.createUser(User.withUsername("bar").password("{noop}123456").roles("admin").build());
            
            http.antMatcher("/bar/**")
                    .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    .formLogin()
                    .loginProcessingUrl("/bar/login")
                    .successHandler((request, response, authentication) -> {
                        response.setContentType("application/json;charset=utf-8");
                        String str = new ObjectMapper().writeValueAsString(authentication);
                        response.getWriter().write(str);
                    })
                    .permitAll()
                    .and()
                    .csrf().disable()
                    .userDetailsService(users);
        }
    }
    
    @Configuration
    @Order(2)
    static class SecurityConfig02 extends WebSecurityConfigurerAdapter {

        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            
            auth.inMemoryAuthentication().withUser("naivekyo")
                    .password("{noop}123456")
                    .roles("admin");
        }

        @Override
        protected void configure(HttpSecurity http) throws Exception {

            InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
            users.createUser(User.withUsername("foo").password("{noop}123456").roles("admin").build());
            
            http.antMatcher("/foo/**")
                    .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    .formLogin()
                    .loginProcessingUrl("/foo/login")
                    .successHandler((request, response, authentication) -> {
                        response.setContentType("application/json;charset=utf-8");
                        String str = new ObjectMapper().writeValueAsString(authentication);
                        response.getWriter().write(str);
                    })
                    .permitAll()
                    .and()
                    .csrf().disable()
                    .userDetailsService(users);
        }
    }
    
}

SecurityConfig 中分别定义了两个静态内部类 SecurityConfig01SecurityConfig02,两个配置类都继承自 WebSecurityConfigurerAdapter,可以分别配置一条过滤器链。

1、SecurityConfig01

在 Security01 中,我们设置过滤器链的拦截规则是 /bar/**,即如果请求路径是 /bar/** 格式的,则进入到 Security01 的过滤器链中进行处理。同时我们配置了局部的 AuthenticationManager 对应的用户是 bar/123456,由于没有重写 configure(AuthenticationManagerBuilder auth) 方法,所以注册到 Spring 容器中的 UserDetailsService 将作为局部 AuthenticationManager 对应的用户。也就是说,如果登录的路径是 /bar/login,那么开发者可以使用 bar/123456root/123456 两个用户登录。

2、SecurityConfig02

在看看 SecurityConfig02,在 Security02 中,我们设置的过滤器链的拦截规则是 /foo/**,即如果请求路径是 /foo/** 格式的,则进入到 Security02 的过滤器链中处理,同时我们配置了局部 AuthenticationManager 对应的用户是 foo/123456,由于重写了 configure(AuthenticationManagerBuilder auth) 方法,在该方法中定义了局部 AuthenticationManager 的 parent 对应的用户,此时注册到 Spring 容器中的 UserDetailsService 对于 /foo/** 过滤器链不再生效。换句话说,如果登录路径是 /foo/login,开发者可以使用 naivekyo/123456foo/123456 两个用户进行登录,而不可以使用 root/123456 进行登录。

3、总结

需要注意的是,如果配置了多个过滤器链,需要使用 @Order 注解来标记不同位置的优先级(即不同过滤器链的优先级),数字越大优先级越低。

当请求到来时,会按照过滤器链的优先级从高到低,依次进行匹配。

静态资源过滤

在一个实际项目中,并非所有的请求都需要经过 Spring Security 过滤器,有一些特殊的请求,例如静态资源等等,一般来说并不需要经过 Spring Security 过滤器链,用户如果访问这些静态资源,直接返回对应的资源即可。

回顾之前分析 WebSecurity 时,提到它里面维护了一个 ignoredRequests 变量,该变量记录的就是所有需要被忽略的请求,这些被忽略的请求将不再经过 Spring Security 过滤器,比如说项目中经常使用的 css、js、image 等资源:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
   
    @Override
    public void configure(WebSecurity web) throws Exception {
        
        web.ignoring()
                .antMatchers("/login.html", "/css/**", "/js/**", "/image/**");
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .and()
            .csrf().disable();
    }
}

重写 configure(WebSecurity web) 方法,并配置需要忽略的请求,这些需要忽略的地址,最终都会被添加到 ignoredRequests 集合中,并最终以过滤器链的方式呈现出来。

换句话说,上面的配置中一共包含了五个过滤器链: configure(WebSecurity web) 方法中配置的四个以及 HttpSecurity 中配置的一个(即 /**)。

使用 JSON 格式登录

Spring Security 中默认的登录参数传递格式是 key/value 形式,也就是表单登录方式,在实际项目中,我们可能会通过 JSON 格式来传递参数,这就需要我们自定义登录过滤器来实现。

之前分析过登录参数的提取是在 UsernamePasswordAuthenticationFilter 过滤器中完成的。如果我们需要使用 JSON 格式登录,只需要模仿 UsernamePasswordAuthenticationFilter 过滤器定义自己的过滤器,再将定义的过滤器放到 UsernamePasswordAuthenticationFilter 过滤器所在的位置即可。

1、LoginFilter

代码实现如下,先定义一个 LoginFilter 继承自 UsernamePasswordAuthenticationFilter

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        
        if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_VALUE)
            || request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {

            Map<String, String> userInfo = new HashMap<>();
            
            try {
                userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
                
                String username = userInfo.get(getUsernameParameter());
                String password = userInfo.get(getPasswordParameter());

                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                
                setDetails(request, authRequest);
                
                return this.getAuthenticationManager().authenticate(authRequest);
                
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        
        return super.attemptAuthentication(request, response);
    }
}

(1)首先确保进入该过滤器的请求是 POST 请求;

(2)根据请求的 content-type 来判断参数是 JSON 格式还是 key/value 格式的,如果是 JSON 格式,则直接在当前方法中处理;如果是 key/value 格式的,那就直接调用父类的 attemptAuthentication 方法处理即可;

(3)如果请求参数是 JSON 格式,则首先利用 jackson 提供的 ObjectMapper 工具,将输入流转换为 Map 对象,然后从 Map 对象中分别提取出用户名/密码信息并构造出 UsernamePasswordAuthenticationToken 对象,然后调用 AuthenticationManagerauthenticate 方法执行认证操作。

其实 LoginFilter 中,从请求中提取去 JSON 参数之后的认证逻辑就和父类 UsernamePasswordAuthenticationFilter 中的认证逻辑是一样的。

2、添加到过滤器链

定义了 LoginFilter 后,我们还需要将其添加到 Spring Security 过滤器链中,代码如下:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        
        auth.inMemoryAuthentication()
                .withUser("naivekyo")
                .password("{noop}123456")
                .roles("admin");
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        
        return super.authenticationManagerBean();
    }
    
    @Bean
    LoginFilter loginFilter() throws Exception {

        LoginFilter loginFilter = new LoginFilter();
        
        loginFilter.setAuthenticationManager(this.authenticationManagerBean());
        loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            response.getWriter().write(new ObjectMapper().writeValueAsString(authentication));
        });
        
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable();
        
        // 我们自定义的过滤器需要顶替 UsernamePasswordAuthenticationFilter 在过滤器链中的位置
        http.addFilterAt(this.loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

(1)首先重写 configure(AuthenticationManagerBuilder auth) 方法来定义一个登录用户;

(2)重写父类的 authenticationManagerBean 方法来提供一个 AuthenticationManager 实例,后面要配置给 LoginFilter

(3)配置 loginFilter 实例,同时将 AuthenticationManager 实例设置给 loginFilter,然后再设置登录成功回调。当然,我们也可以再 loginFilter 中配置用户名/密码的参数名或者登录失败的回调;

(4)最后在 HttpSecurity 中,调用 addFilterAt 方法将 loginFilter 过滤器添加到 UsernamePasswordAuthenticationFilter 过滤器所在的位置。

配置完成后,重启项目,此时我们就可以使用 JSON 格式的数据来进行登录操作了:

3、总结

当我们想要获取一个 AuthenticationManager 实例时,有两种不同的方式:

  • 第一种是重写父类的 authenticationManager 方法获取;
  • 第二种是通过重写父类的 authenticationManagerBean 方法来获取。

表面上看这两种方式获取到的 AuthenticationManager 在这里都可以运行,但实际上是有区别的。区别在于第一种获取到的是全局 AuthenticationManager 实例,而第二种获取到的是局部的 AuthenticationManager 实例,而 LoginFilter 作为过滤器链中的一环,显然是应该配置给局部的 AuthenticationManager 实例,因为如果将全局的 AuthenticationManager 实例配置给 LoginFilter,则局部的 AuthenticationManager 实例所对应的用户就会失效,例如如下配置:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        
        auth.inMemoryAuthentication()
                .withUser("naivekyo")
                .password("{noop}123456")
                .roles("admin");
    }

    @Override
    @Bean
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Bean
    LoginFilter loginFilter() throws Exception {

        LoginFilter loginFilter = new LoginFilter();
        
        loginFilter.setAuthenticationManager(this.authenticationManager());
        
        loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
            response.getWriter().write(new ObjectMapper().writeValueAsString(authentication));
        });
        
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        users.createUser(User.withUsername("user").password("{noop}123456").roles("user").build());

        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable()
                .userDetailsService(users);
        
        http.addFilterAt(this.loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

在这段代码中,我们可以使用 naivekyo/123456 以 JSON 格式进行登录,因为它是配置给全局 AuthenticationManager 的用户,但是无法使用 user/123456 以 JSON 格式登录,因为 LoginFilter 中指定了全局的 AuthenticationManager 来做验证,所以局部的 AuthenticationManager 实例就失效了。

在实际应用中,如果需要自己配置一个 AuthenticationManager 实例,大部分情况下,我们都是通过重写 authenticationManagerBean 方法来获取的。

小结

Spring Security 初始化过程对于开发者而言是非常重要的,这主要体现在两个方面:

  • 第一:理解 Spring Security 初始化流程之后,开发者就可以随心所欲地根据自己的项目需求去定制 Spring Security;
  • 第二:如果在 Spring Security 使用的过程中出现了问题,理解了初始化流程之后,问题的排查也会变得非常容易。

Author: NaiveKyo
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source NaiveKyo !
  TOC