11、Spring Security 实战 - 前后端分离表单认证:自定义过滤器替换 UsernamePasswordAuthenticationFilter

1. 简介

在看本文章之前,建议去看下传统web项目表单认证的原理:SpringSecurity系列一:10 传统Web项目表单认证: UsernamePasswordAuthenticationFilter 过滤器

在前后端分离的项目中,前端会以 json 格式来传递参数,这就需要我们自定义登录过滤器链来实现。登录参数的提取是在UsernamePasswordAuthenticationFilter过滤器中完成的,如果要使用 Json 格式登录,只要模仿UsernamePasswordAuthenticationFilter过滤器自定义自己的过滤器,再将自定义的额过滤器放到UsernamePasswordAuthenticationFilter过滤器所在的位置即可。

 

1. 自定义过滤器使用Json数据格式认证登录

①定义一个LoginFilter 继承自UsernamePasswordAuthenticationFilter,当进行认证时,请求不再进入UsernamePasswordAuthenticationFilter,而是进入LoginFilter

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
   
     

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
   
     
        // 1. 判断是否是post请求方式
        if(!request.getMethod().equals("POST")){
   
     
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        // 2. 判断是否是json格式请求类型
        if(request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)){
   
     
            // 3. 从json数据中获取用户输入用户名和密码认证:{"uname":"xxx","password":"xxx"}
            try {
   
     
                Map<String,String> userInfo = new HashMap<>();
                userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
                String username = userInfo.get(getUsernameParameter());
                String password = userInfo.get(getPasswordParameter());
                System.out.println("username = " + username);
                System.out.println("password = " + password);
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password);
                setDetails(request,authenticationToken);
                return this.getAuthenticationManager().authenticate(authenticationToken);
            } catch (IOException e) {
   
     
                throw new RuntimeException(e);
            }
        }
        // 4. 如果不是json格式,那么按照父类的表单登录逻辑处理
        return super.attemptAuthentication(request,response);
    }
}

②在SpringSecurity配置类中配置替换UsernamePasswordAuthenticationFilter:

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
   
     

    @Bean
    public UserDetailsService userDetailsService(){
   
     
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("admin").build());
        return inMemoryUserDetailsManager;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
   
     
        auth.userDetailsService(userDetailsService());
    }

    // 暴露自定义的authenticationManager
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
   
     
        return super.authenticationManager();
    }

    @Bean
    public LoginFilter loginFilter() throws Exception {
   
     
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setFilterProcessesUrl("/doLogin");
        loginFilter.setUsernameParameter("uname");
        loginFilter.setPasswordParameter("passwd");
        loginFilter.setAuthenticationManager(authenticationManager());
        loginFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        loginFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
   
     
        // 开启请求的权限管理
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable();
        // 将loginFilter过滤器添加到UsernamePasswordAuthenticationFilter过滤器所在的位置
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
   
     
    private final ObjectMapper objectMapper = new ObjectMapper() ;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
   
     
        Map<String,Object> map = new HashMap<>();
        map.put("msg","登录成功");
        map.put("code",200);
        map.put("authentication",authentication);
        response.setContentType("application/json;charset=UTF-8");
        String s = objectMapper.writeValueAsString(map);
        response.getWriter().println(s);
    }
}

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
   
     

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
   
     
        Map<String,Object> map = new HashMap<>();
        map.put("msg","登录失败:"+exception.getMessage());
        map.put("code",-1);
        response.setContentType("application/json;charset=UTF-8");
        String s = objectMapper.writeValueAsString(map);
        response.getWriter().println(s);
    }
}

③访问控制器:

@Controller
public class LoginController {
   
     
    @RequestMapping("/login.html")
    public String login() {
   
     
        return "login";
    }
}

④启动项目,测试:

 

⑤认证成功时,默认SpringSecurity会发送一个Cookie:

 

访问其他请求时 http://localhost:8080/hello 需要带上Cookie,Postman默认会带上Cookie:

 

2. 退出登录

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
   
     

   // ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
   
     
        // 开启请求的权限管理
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
            	// 退出登录
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new MyLogoutSuccessHandler())
                .and()
                .csrf().disable();
        // 将loginFilter过滤器添加到UsernamePasswordAuthenticationFilter过滤器所在的位置
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
   
     
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
   
     
        Map<String,Object> map = new HashMap<>();
        map.put("msg","注销登录");
        map.put("code",200);
        map.put("authentication",authentication);
        response.setContentType("application/json;charset=UTF-8");
        String s = objectMapper.writeValueAsString(map);
        response.getWriter().println(s);
    }
}

 

3. 没有认证时的异常处理

如果没有认证就访问某个需要认证的请求,会进入登录页面,但是前后端分离一般不会跳转到登录页面,而是自定义处理,因此可以配置未认证时的异常处理:

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
   
     

    // ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
   
     
        // 开启请求的权限管理
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
            	// 未认证时的异常处理
                .exceptionHandling()
                .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new MyLogoutSuccessHandler())
                .and()
                .csrf().disable();
        // 将loginFilter过滤器添加到UsernamePasswordAuthenticationFilter过滤器所在的位置
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
   
     
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
   
     
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println("请认证之后再去处理");
    }
}

 

4. 更改认证数据源为数据库认证

 
 
 

①引入依赖:

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.15</version>
</dependency>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.8</version>
</dependency>

②配置SpringBoot

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8&useSSL=false&serverTimezone=Hongkong
spring.datasource.username=root
spring.datasource.password=root

mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.hh.entity

logging.level.com.baizhi=debug

③User 实体类

@Data
public class User implements UserDetails{
   
     
    private Integer id;
    private String username;
    private String password;
    private Boolean enabled;
    private Boolean accountNonExpired;
    private Boolean accountNonLocked;
    private Boolean credentialsNonExpired;
    private List<Role> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
   
     
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        roles.forEach(role -> grantedAuthorities.add(new SimpleGrantedAuthority(role.getName())));
        return grantedAuthorities;
    }

    @Override
    public String getPassword() {
   
     
        return password;
    }

    @Override
    public String getUsername() {
   
     
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
   
     
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
   
     
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
   
     
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
   
     
        return enabled;
    }
}

④Role 实体类

@Data
public class Role {
   
     
    private Integer id;
    private String name;
    private String nameZh;
}

⑤UserDao

@Mapper
public interface UserDao {
   
     
   // "根据用户名查询用户
    User loadUserByUsername(String username);

    // "根据用户id查询⻆色
    List<Role> getRolesByUid(Integer uid);
}

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hh.dao.UserDao">

    <select id="loadUserByUsername" resultType="com.hh.entity.User">
        select * from user where username ={username}
    </select>
    <select id="getRolesByUid" resultType="com.hh.entity.Role">
        select
            r.id,
            r.name,
            r.name_zh nameZh
        from role r, user_role ur
        where r.id = ur.rid
        and ur.uid ={uid}
    </select>
</mapper>

⑥MyUserDetailsService

@Service
public class MyUserDetailsService implements UserDetailsService {
   
     

    @Autowired
    private UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
   
     
        User user = userDao.loadUserByUsername(username);
        if(Objects.isNull(user)){
   
     
            throw new RuntimeException("用户不存在");
        }
        user.setRoles(userDao.getRolesByUid(user.getId()));
        return user;
    }
}

⑦配置SpringSecurity

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
   
     

    // 更改数据源
    @Autowired
    private UserDetailsService userDetailsService;

    // 配置数据源
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
   
     
        auth.userDetailsService(userDetailsService);
    }

    // 暴露自定义的authenticationManager
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
   
     
        return super.authenticationManager();
    }

    @Bean
    public LoginFilter loginFilter() throws Exception {
   
     
        LoginFilter loginFilter = new LoginFilter();
        loginFilter.setFilterProcessesUrl("/doLogin");
        loginFilter.setUsernameParameter("uname");
        loginFilter.setPasswordParameter("passwd");
        loginFilter.setAuthenticationManager(authenticationManager());
        loginFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        loginFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
        return loginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
   
     
        // 开启请求的权限管理
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new MyAuthenticationEntryPoint())
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(new MyLogoutSuccessHandler())
                .and()
                .csrf().disable();
        // 将loginFilter过滤器添加到UsernamePasswordAuthenticationFilter过滤器所在的位置
        http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

⑧启动项目测试: