使用JSON格式登录

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

通过 3.1.4 小节的介绍,大家己经明白登录参数的提取是在 UsernamePasswordAuthenticationFilter 过滤器中完成的。如果我们要使用 JSON 格式登录,只需要模仿 UsernamePasswordAuthenticationFilter 过滤器定义自己的过滤器,再将自定义的过滤器放到 UsernamePasswordAuthenticationFilter 过滤器所在的位置即可。

思路理清了,我们来看代码实现。首先我们自定义一个 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_VALUE) || request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_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 对象,然后调用 AuthenticationManager 的 authenticate 方法执行认证操作。

其实 LoginFilter中,从请求中提取出 JSON 参数之后的认证逻辑和父类 UsernamePasswordAuthenticationFilter 中的认证逻辑是一致的,读者可以回顾第 3 章中关于 UsernamePasswordAuthenticationFilter 的分析。

LoginFilter 定义完成后,接下来我们将其添加到 Spring Security 过滤器链中,代码如下:

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

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

    @Bean
    LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        loginFilter.setAuthenticationSuccessHandler((req, resp, auth) -> {
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            out.write(new ObjectMapper().writeValueAsString(auth));
        });
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable();
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}
  1. 首先重写 configure 方法来定义一个登录用户。

  2. 重写父类的 authenticationManagerBean 方法来提供一个 AuthenticationManager 实例,一会将配置给 LoginFilter。

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

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

配置完成后,重启项目,此时我们就可以使用 JSON 格式的数据来进行登录操作了,如图 4-11 所示。

image 2024 04 12 17 05 16 865
Figure 1. 图4-11 使用JSON格式登录

有读者可能会注意到,当我们想要获取一个 AuthenticationManager 实例时,有两种不同的方式,第一种方式是通过重写父类的 authenticationManager 方法获取,第二种则是通过重写父类的 authenticationManagerBean 方法获取。表面上看两种方式获取到的 AuthenticationManager 实例在这里都可以运行,但实际上是有区别的。区别在于第一种获取到的是全局的 AuthenticationManager 实例,而第二种获取到的是局部的 AuthenticationManager 实例,而 LoginFilter 作为过滤器链中的一环,显然应该配置局部的 AuthenticationManager 实例,因为如果将全局的 AuthenticationManager 实例配置给 LoginFilter,则局部 AuthenticationManager 实例所对应的用户就会失效,例如如下配置:

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

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

    @Bean
    LoginFilter loginFilter() throws Exception {
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setAuthenticationManager(authenticationManagerBean());
        loginFilter.setAuthenticationSuccessHandler((req, resp, auth) -> {
            resp.setContentType("application/json;charset=utf-8");
            PrintWriter out = resp.getWriter();
            out.write(new ObjectMapper().writeValueAsString(auth));
        });
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        users.createUser(User.withUsername("javagirl").password("{noop}123").roles("admin").build());
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable()
                .userDetailsService(users);
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

在上面这段代码中,我们将无法使用 javagirl/123 进行登录,因为 LoginFilter 中指定了全局的 AuthenticationManager 来做验证,所以局部的 AuthenticationManager 实例失效了。

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