概述

很早以前我们开发web应用的时候,是面向servlet的编程,我们的每个页面都会对应一个servlet,这样产生了大量的模板代码,spring mvc带来的变革是只生成了一个dispatchservlet,这个组件会接受所有的请求,并将请求分发给对应的controller,这样我们就可以只用关注业务代码,忽略模板代码了。

spring security 就是通过对dispactherservlet的保护来实现整个应用保护的,其通过filter chain实现基于filter的保护机制,具体如下:

上图中spring mvc的边界就是dispatcherservlet,spring security的边界就是filterchain,spring security边界之外对应了tomcat的filter。也就是请求到达spring security之前就已经到达了tomcat容器之中。

详解

快速开始

我们通过在spring的web应用中引入对应的依赖就可以实现应用的安全防护,对应的pom依赖如下:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

这样我们在启动web应用的时候就会启用默认的配置实现应用的防护:

原理讲解

在开始讲解整体的代码逻辑之前,我们先来详细的看一下spring security内部默认的情况下是如何实现安全认证的,如下图所示:

  • 认证请求被usernamepasswordauthenticationfilter拦截
  • spring security将请求发送给authenmanager
  • authenmanager负责管理authenprovider,并将请求依次发送给provider,manager可以认为是一个策略的管理者
  • provider可以认为是策略的提供者,会接受manager发送过来的请求进行验证,这里就是验证策略的具体实现
  • userdetailservice用来将provider的逻辑和加载用户信息的实现进行解耦

接下来,我们可以看一下默认的处理流程 可以看到其业务的处理流程正如上面所示。

这里的provider的具体实现是 DaoAuthenticationProvider,这个类最终会调用UserDetailService的实现类来完成用户的验证,默认实现了这个接口的类是 InMemoryUserDetailManager,InMemoryUserDetailManager 这个类相当于一个内存数据库,其中存放了用户的信息,如下方法最终会被调用来获取数据库中的用户信息(这里是用内存模拟了一个数据库):

1
2
3
4
5
6
7
8
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}

spring mvc 启动的时候会加载DelegatingFilterProxy这个bean,通过这个java bean的名字可以知道,它是一个委派过滤器的代理,也就是说其本质是一个代理类,用于实现请求的委派的filter,在引入了spring security之后,DelegatingFilterProxy的委派代理类就是FilterChainProxy,这个 FilterChainProxy 是一个Filter的集合,里面集中了spring security所有的过滤器,通过这个类就可以完成的,下面为spring security 提供的filter及其默认的顺序:

  • org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
  • org.springframework.security.web.context.SecurityContextPersistenceFilter
  • org.springframework.security.web.header.HeaderWriterFilter
  • org.springframework.security.web.authentication.logout.LogoutFilter
  • org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
  • org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
  • org.springframework.security.web.authentication.AnonymousAuthenticationFilter
  • org.springframework.security.web.session.SessionManagementFilter
  • org.springframework.security.web.access.ExceptionTranslationFilter
  • org.springframework.security.web.access.intercept.FilterSecurityInterceptor

spring security在对请求过滤的时候就是按照上面的filter一个一个的进行匹配,doFilter参数里面同样会包含一个filterChain字段,通过filterChain实现了filter的串联。

定制化逻辑

spring security提供了支持扩展的能力,扩展的关键点在于 WebSecurityConfigurerAdapter类,这是一个配置类,用于配置我们自定义的实现类,我们将以jwt token为例,提供以下几种扩展方式:

  • authenticationProvider:这种情况下我们需要实现AuthenticationProvider这个接口,实现这个接口需要实现两个方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
      public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String userName = authentication.getPrincipal().toString();
    String password = authentication.getCredentials().toString();
    String pwd = userDetailsService.loadUserByUsername(userName).getPassword();
    if (passwordEncoder.matches(password, pwd)) {
    return new UsernamePasswordAuthenticationToken(userName, password, new ArrayList<>());
    }
    throw new BadCredentialsException("user not found!");
    }

    @Override
    public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }

    在quickstart那里我们看到了请求的认证流程,authenticationManager会接受用户信息(authentication),然后传递给authenticationProvider,authenticationProvider自然就需要对接收的参数的合法性进行判定,这就是上述supports的来源,如果我们需要自定义待认证的model的话,这里的support就需要对类型进行识别,为了方便支持多个authentication对象的认证,我们可以实现多个provider,并将其和authenticationManager进行关联即可,在用户认证的时候会首先调用support类,如果返回为true,就继续认证,否则进入下一个provider来验证。我们在实现上述认证的时候并不是通过filter将请求拦截并传递给authenticationManager的,而是主动的调用authenticationManager的auticate方法进行认证,这点和默认的认证机制是不同的,需要明确注意

    上面我们实现了自定义的用户认证逻辑,接下来我们需要将其装配给authenticationManager,装配的过程如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    // 禁止csrf是因为我们并不会使用formlogin的方式,而是使用rest的方式进行验证的,因此这里是不必要配置csrf的
    http.csrf().disable()
    // 只允许login在没有认证的情况下访问,并不允许其他请求的访问
    .authorizeRequests().antMatchers("/login").permitAll().anyRequest().authenticated()
    // 由于我们并不使用form+session的方式进行认证,因此这里永远不去创建session
    .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    http.addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

    上面的第一个方法用于返回一个authenticationManager类型的bean,这是spring security给我们提供的默认的实现bean,通过这种方式,我们可以在应用的其他地方通过@Autowired的方式将其引入,并对用户登录的请求进行校验。

    接下来的两个配置我们可以根据其传入的参数进行猜测,对于AuthenticationManagerBuilder类型参数的配置方法,其是用来装配authenticationManager的,在这里我们可以指定使用哪种Provider,也可以配置多种provider。多个provider的验证逻辑是:当其中一个provider验证失败的时候会继续进行下一个provider的验证,直到验证成功或者失败。

    最后一个configre传入的参数是HttpSecurity类型的,也就是和http请求的安全性相关的配置,其主要是用来实现对请求的拦截,规定了哪些请求可以放过,哪些请求必须认证,由于我们使用了token的方式进行认证,因此这里需要关闭session的创建,也就是SessionCreationPolicy.STATELESS,由于http的请求涉及到请求的拦截,因此这里我们需要定制一个filter对请求拦截,并将其加入到适当的位置,由于我们只希望用户的请求只被过滤一次,因此该filter需要实现类oncePerRequestFilter(通常就是实现这个类):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @Service
    public class CustomAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // 获取请求头,这个请求头是约定的,可以是任意的参数
    String authenticationHeader = request.getHeader("Authentication");
    if (authenticationHeader == null || authenticationHeader.isEmpty() || !authenticationHeader.startsWith("at")) {
    filterChain.doFilter(request,response);
    return;
    }
    String token = authenticationHeader.split(" ")[1].trim();
    if (!jwtUtil.valid(token)) {
    filterChain.doFilter(request,response);
    return;
    }
    String username = jwtUtil.getUsername(token);
    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());
    usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
    filterChain.doFilter(request, response);
    }
    }

    上述这个filter会对配置的请求进行拦截,只有携带了正确的token才可以继续,否则就会抛出异常;当然也可以是我们再configure里面配置的允许访问的路径。

    上面的认证过程中,我们看到了在认证成功后,我们调用了这么一段代码SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);才继续filter的处理的,这一段代码很重要,我们将请求信息(用户名、密码等其他信息)绑定到了请求的上下文里面,这样就和请求关联了起来,使得请求来源的用户信息可以被后续识别出来,这样我们可以在后续的controller通过静态的SecurityContextHolder.getContext()获取到用户的信息,方便我们针对用户开展业务。回想我们使用session的时候如果不将session信息保存到contextholder,那么后续需要获取用户的时候都要在Controller接受的参数里面加上HttpSession类型的参数。

  • UserDetailService:我们也可以不识闲authenticationProvider,而是简单的视线userDetailService来实现验证的逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Service
    public class CustomUserDetailServiceImpl implements UserDetailsService {

    @Autowired
    PasswordEncoder encoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Map<String, String> db = new HashMap<>();
    db.put("wes", encoder.encode("123"));
    if (db.containsKey(username)) {
    return new User(username, db.get(username), new ArrayList<>());
    }
    throw new UsernameNotFoundException("user not founded!");
    }
    }

    实现了上述的代码之后,我们依然需要绑定到authenticationManager->authenticationProvider的整个链路上,这依然是在webSecurityConfiguerAdapter中的configure实现的:

    1
    auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);

    我们的filter依然是使用上面的filter,http拦截的逻辑也依然是上面的逻辑。

最后顺便提一下jwt token,通常我们的web应用如果是单节点的应用可以使用session的认证机制,如果是多个后端或者将开发的业务集成到现有的框架中通常采用token认证的方式。jwt token带来的好处是服务端变成了无状态的了,也就是说服务端不会留存token的任何信息,用户所有的信息都将保存到token里面了,凭借这个token我们可以知道对应的用户、token失效的时间等,这给后端带来了极大的扩展,多移动端或者前后端分离的web应用。

TODO: 后续继续补充session的相关机制

小结

spring security 推荐学习视频:学习材料

contextholder