blog/source/_drafts/spring-security-webmvc.md

24 KiB
Raw Blame History

title tags categories keywords
在Spring Web MVC中的配置Spring Security
Java
Spring
Spring Security
Spring Web MVC
安全认证
JVM
Spring
Spring,Spring Security,UserDetails,SecurityContext,Servlet

其实在Spring应用中使用Spring Security并不困难最复杂的事情应该就是如何完成Spring Security的配置了。一旦Spring Security的配置成功完成那么在Controller中就可以直接使用Spring Security提供的注解来使用Spring Security安全认证的结果。

要完成Spring Web MVC应用中的Spring Security配置需要至少完成以下两项内容

  1. 完成设置HTTP的认证流程。
  2. 完成认证所需要使用的类。

以下将从这两个方向分别说明如何在Spring Web MVC应用中配置Spring Security。

本篇关于 Spring Security 的文章主要由三部分组成,分别是:

  1. {% post_link spring-security-basic %}
  2. {% post_link spring-security-webmvc %}
  3. 在Spring WebFlux中的配置Spring Security

HttpSecurity

要在一个Spring Web MVC应用中启用Spring Security除了要在主类或者配置类上添加@EnableWebSecurity注解以外,更重要的是建立一个继承了WebSecurityConfigurerAdapter抽象类的配置类。这个配置类在我们的代码中最常见的形式就是下面这个样子。

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 调用参数http中的方法来进行配置例如
        http
            .authorizeRequests(authorize -> authorize
                .anyRequest().authenticated())
            .formLogin(withDefaults())
            .httpBasic(withDefaults());
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        // 构建一个UserDetailsService实例用于完成用户认证
    }
}

在这个配置类中,最核心的一个类就是HttpSecurity。下面来看看这个类的大致结构,由于HttpSecurity继承的抽象类和实现的接口都是提供一些基础的操作,所以这些不常用用到的内容就不在下面的类图中展示了,而且由于参与到HttpSecurity类的配置类过多,这里仅拣选几个比较常见和常用的类来做示例。

{% oss_image spring-security/spring-security-httpsecurity.svg "HttpSecurity类的关联" %}

在上面这个图中,我省略了几乎所有返回HttpSecurity实例的配置方法,这个配置方法接受一个Customizer<T>接口类型的参数,而这个Customizer<T>中的泛型参数恰恰就是上图中所有同名配置方法的返回类型。也就是说这两个方法的功能是一致的,只是调用的方式不同。Customizer<T>接口类型是一个函数式接口,其中只有一个方法需要实现:custom(T t)。所以在使用的时候,可以直接使用Customizer<T>接口的泛型类型作为Lambda表达式的参数即可。例如有方法HeadersConfigurer<HttpSecurity> headers(),那么就会有一个对应的使用函数式接口的方法HttpSecurity headers(Customizer<HeadersConfigurer<HttpSecurity>> customizer)。如果在使用的时候没有发现使用函数式接口的方法,那么就说明这个配置方法并适合使用函数式接口进行配置。

ExpressionUrlAuthorizationConfigurer

这个配置类被拿出来单独说明的原因主要是这个类控制的是需要认证之后才可以访问的服务范围。ExpressionUrlAuthorizationConfigurer类配置其几个内部类ExpressionInterceptUrlRepositoryAuthorizedUrl,就可以完成大部分的服务路径与授权配置之间的映射。

首先还是来看一下这个类的具体结构。

{% oss_image spring-security/spring-security-ExpressionUrlAuthorizationConfigurer.svg "ExpressionUrlAuthorizationConfigurer类图" %}

从这张图中可以看出来,AuthorizedUrl类才是整个ExpressionUrlAuthorizationConfigurer类配置过程的核心。而ExpressionUrlAuthorizationConfigurer配置类最主要的功能就是为各个指定的URL生成表示其所需权限的SpEL表达式这样就可以使使用注解在Controller上配置的权限和在WebSecurityConfigurerAdapter中统一配置的权限能够合并成为一套处理方法处理。

以下是一个比较常见的配置示例。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .antMathers("/", "/home").permitAll()   // 允许用户在不经过认证的情况下访问/和/home
            .anyRequest().authenticated()           // 除之前的具体设置以外,所有请求都需要经过认证
            .and()           // 重置配置链使配置链返回HttpSecurity实例
        .logout()
            .permitAll();   // 允许随意访问登出功能
}

从这个示例可以看出,ExpressionUrlAuthorizationConfigurer类就是在配置各个路径的访问策略。所以这就有必要仔细了解一下如何定义路径的匹配。

Matchers

在Spring Security中授权访问控制规则所使用的路径的匹配是由一系列的Matcher类来完成的。从上面ExpressionUrlAuthorizationConfigurer的类图中可以看出基本上所有的Matcher都是由AbstractRequestMatcherRegistry抽象类定义的。而且由于内部类ExpressionInterceptUrlRegistry通过层层继承扩展了这个抽象类,所以在HttpSecurity的类图中,authorizeRequests()方法只需要返回ExpressionInterceptUrlRegistry类型的实例即可完成所有Matcher的定义。

常用的Matcher声明方法主要有.anyRequest().antMatchers().regexMatchers().mvcMatchers()四个,其使用方法也比较简单。

  1. .anyRequest(),表示匹配所有的请求,通常用来设置所有的内容都需要进行认证操作。这个方法不接受任何参数。
  2. .antMatchers()使用ant表达式来定义URL匹配规则例如/js/**/*.js表示匹配js目录下的所有后缀为.js的文件。其规则大致如下。
    • ?匹配一个字符。
    • *匹配0个或者多个字符。
    • **匹配0个或者多个目录。
  3. .regexMatchers(),使用正则表达式进行匹配,例如.+[.]js表示匹配所有后缀为.js的文件。
  4. .mvcMatchers(),用于定义了spring.mvc.servlet.path的情况,可以直接使用@RequestMapping()中设置的路径。例如.mvcMatchers("orders").servletPath("/api").antMatchers("/api/orders")的匹配是一样的。

对于匹配规则的定义,授权效果是与配置顺序紧密相关的,一般情况下需要遵循越具体的规则越向前放,越笼统、越通用的规则放在最后的编排方式。

参与认证的组件类

对于用户身份的认证不仅是需要控制用户的访问授权最核心的内容是对于用户身份的确定也就是认证过程。虽然不同的系统中用户的认证过程各不相同但是Spring Security还是从中提取出了一套比较通用的认证组件。其实仔细观察一下用户的认证过程一般都是遵循以下流程的。

{% oss_image spring-security/spring-security-authflow.svg 用户通用认证流程 550 %}

所以根据这个比较通用的流程Spring Security通过DefaultPasswordEncoderAuthenticationManagerBuilder提供了几个用户认证的组件作为认证的功能节点,允许将自定义的策略插入到这个通用的认证流程中来。其中比较常用的是以下几个。

  1. UserDetailsService接口,该接口主要用于获取用户的用户名、密码、拥有权限等信息。
  2. PasswordEncoder接口,该接口主要用于对用户提供的明文密码进行加密,并与系统中存储的密码加以比对。

所以一般只要在应用中实现了这两个类就可以按照Spring Security会自动采用DaoAuthenticationProvider并按照设定的流程完成用户的认证。

UserDetailsService

在继承WebSecurityConfigurerAdapter抽象类的配置类中,除了需要声明HttpSecurity配置方法以外还需要声明的一个Bean是UserDetailsService实例,当然也可以使用@Component或者是@Service等注解来声明。UserDetailsService是加载用户数据的核心接口,其中只有一个方法需要实现:UserDetails loadUserByUsername(String name)

以下是一个UserDetailsService的实现示例。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private UserRepository userRepo;
    private RoleRepository roleRepo;

    public UserDetailsServiceImpl(UserRepository userRepo, RoleRepository roleRepo) {
        this.userRepo = userRepo;
        this.roleRepo = roleRepo;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepo.findUserByUsername(name)

        if (Objects.isNull(user)) {
            throw new UsernameNotFoundException("用户不存在");
        }

        List<GrantedAuthority> authorities = roleRepo.findRoleByUserId(user.id)
            .stream()
            .map(Role::getName)
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList())

        return new org.springframework.security.core.userdetails.User(
            user.getName(),
            user.getPassword(),
            authorities
        );
    }
}

这里需要注意的是,UserDetailsService接口的实现需要返回一个UserDetails类的实例。最简单的方法就是像示例中一样返回一个Spring Security提供的User类实例。不过当然也可以自己定义一个继承了UserDetails的类只是一般即便是使用JWT作为令牌其中也不会保存其他过多的信息Spring Security提供的User类已经基本上足够使用了。

PasswordEncoder

目前Spring Security默认采用的PasswordEncoder接口的实现是DelegatingPasswordEncoder,这个类支持采用{encoding}password的格式来保存密码以及密码的加密方式。DelegatingPasswordEncoder可以根据密码文本中的{}中定义的encoding标记自动选择相应的PasswordEncoder来对用户输入的密码进行加密和比对。

Spring Security内置可以使用的PasswordEncoder标记主要有以下几个:

  1. bcrypt,采用BCryptPasswordEncoder
  2. pbkdf2,采用Pbkdf2PasswordEncoder
  3. argon2,采用Argon2PasswordEncoder
  4. ldap,采用LdapShaPasswordEncoder
  5. scrypt,采用SCryptPasswordEncoder
  6. MD4,采用Md4PasswordEncoder
  7. MD5,采用MessageDigestPasswordEncoder
  8. SHA-1,采用MessageDigestPasswordEncoder
  9. SHA-256,采用MessageDigestPasswordEncoder
  10. sha256,采用StandardPasswordEncoder
  11. noop,采用NoOpPasswordEncoder,这个PasswordEncoder将保存密码明文。

!!! caution "废弃API提示" 不过需要注意的是在目前的Spring Security版本中Md4PasswordEncoderMessageDigestPasswordEncoderLdapShaPasswordEncoderStandardPasswordEncoder都已经被废弃,如果需要这样的编码方式,尽量还是采用自定义编码的方式。

如果需要自定义PasswordEncoder,只需要像UserDetailsService一样,构建一个实现了PasswordEncoder接口的Bean即可。但此时就不再需要使用DelegatingPasswordEncoder需要的encoding标签了。

EntryPoint

EntryPoint是Spring Security建立的一个概念模型接口表示一个认证入口点。这个EntryPoint主要完成的功能就是在处理用户请求的过程中如果遇到了认证异常那么ExceptionTranslationFilter就会启动用于特定认证方案Authentication Schema的流程。Spring Security中所有的入口点都会实现AuthenticationEntryPoint接口。AuthenticationEntryPoint接口的内容十分简单,其剥除了注释以后的源码如下。

public interface AuthenticationEntryPoint {
    void commence(
        HttpServletRequest request, 
        HttpServletResponse response, 
        AuthenticationException authException
    ) throws IOException, ServletException;
}

AuthenticationEntryPoint接口的源码可以看出来,认证入口点中实际的处理方法只有一个commence()。其第一个参数HttpServletRequest表示一个遇到了认证异常的用户请求,第二个参数HttpServletResponse表示一个需要返回给用户的响应。所以整个commence()方法的主旨就是针对相应的认证异常,按照认证方案修改HttpServletResponse并将其返回给用户,使用户能够进入想定的认证流程。

Spring Security内置了几个常用的认证处理方案并将其形成了一些EntryPoint类主要可以用于以下场景。

  1. 拒绝用户请求可以使用Http403ForbiddenEntryPoint,将直接返回403状态码,并不会启动后续的流程。
  2. 返回一个特定的HTTP状态码可以可使用HttpStatusEntryPoint,也同样不会启动后续的流程。
  3. 将用户重定向到一个登录页面可以使用LoginUrlAuthenticationEntryPoint
  4. 启动标准Http Basic认证流程可以使用BasicAuthenticationEntryPoint
  5. 启动标准Http Digest认证流程可以使用DigestAuthenticationEntryPoint
  6. 将认证任务委托给其他的EntryPoint对象可以使用DelegatingAuthenticationEntryPoint

以下是一个配置使用BasicAuthenticationEntryPoint的简单示例。

@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .antMatchers("/management/**")
            .authorizeRequests().anyRequest().hasRole("ADMIN")
            .and()
            .httpBasic().authenticationEntryPoint(httpBasicEntryPoint());
    }

    @Bean
    public AuthenticationEntryPoint httpBasicEntryPoint() {
        BasicAuthenticationEntryPoint entryPoint = new BasicAuthenticationEntryPoint()
        entryPoint.setRealmName("management realm");
        return entryPoint;
    }
}

自定义Token

要在Spring Security中使用自定义的Token需要做以下几件事情。

  1. 自定义Token类需要继承AbstractAuthenticationToken抽象类或者实现Authentication接口。这主要是一个自定义Token中用户认证信息的容器可以用于被放置到SecurityContext中,用来保存当前用户的认证信息。
  2. 自定义登录请求处理Filter需要继承UsernamePasswordAuthenticationFilter或者AbstractAuthenticationProcessingFilter。用来对指定的登录路径进行自动的用户认证信息进行验证处理这样就可以在处理用户登录请求的Controller中直接根据用户登录信息生成Token了。
  3. 自定义用户认证授权Filter需要继承BasicAuthenticationFilter或者OncePerRequestFilter。用来对请求中携带的Token进行处理将其转换为Spring Security工作所需要的Authentication实例,并将其保存在SecurityContext中。
  4. 自定义AuthenticationProvider,需要实现AuthenticationProvider接口。如果不使用DaoAuthenticationProvider的话,就需要使用这个自定义的AuthenticationProvider了,但一般情况下DaoAuthenticationProvider已经足够使用。
  5. 配置HttpSecurity,使用addFilter()addFilterAt()AddFilterBefore()addFilterAfter()将自定义的Filter注入到SecurityFilterChain的处理流程里。

当然对于自定义的Token的颁发就不在这一节的讨论之列了Token的颁发一般都是由专门处理用户登录请求的Controller来完成的。所以自定义的Filter需要做的事情实际上就是对请求中提供的Token进行验证和鉴权将其转化为应用可以使用的Authentication实例。而自定义的Filter能够实现对Token的认证也还是需要依靠AuthenticationManager实例。所以这样一来要完成的工作就非常清楚了。

首先来看一下如果继承AbstractAuthenticationProcessingFilter需要做哪些事情。AbstractAuthenticationProcessingFilter拥有四个构造函数,基本上每一个构造函都需要提供一个RequestMatcher实例或者是一个URL字符串来让AbstractAuthenticationProcessingFilter知道自己的工作目标。此外就是可选的提供一个AuthenticationManager实例。这也是为什么一般都会选择继承UsernamePasswordAuthenticationFilter的原因,因为UsernamePasswordAuthenticationFilter内部已经指定了Filter的工作目标URL是/login而这也是一般项目中用于处理登录比较通用的URL定义。如果项目中处理用户登录的路径比较特殊那么就可以使用AbstractAuthenticationProcessingFilter来指定一个特殊的路径了。

这是一个继承了AbstractAuthenticationProcessingFilter的示例。在这个示例中使用了一个特殊的登录URL/token/authorize

public class TokenAuthorizeRequestProcessingFilter extends AbstractAuthenticationProcessingFilter {
    public static final String USERNAME_KEY = "username";

	public static final String PASSWORD_KEY = "password";

	private static final AntPathRequestMatcher REQUEST_MATCHER = new AntPathRequestMatcher("/token/authorize", "POST");

    private final AuthenticationManager authenticationManager;

    public ToeknAuthorizeRequestProcessingFilter(AuthenticationManager authenticationManager) {
        super(REQUEST_MATCHER);
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication Request method is not supported: " + request.getMethod());
        }
        // 接下来可以从request中获取用户名和密码内容然后形成之前定义的Authentication接口实例
        CustomizedAuthenticationToken authRequest = new CustomizedAuthenticationToken(username, password);
        return this.authenticationManager.authenticate(authRequest);
    }
}

注意,在这个示例中,TokenAuthorizeRequestProcessingFilter并没有对请求中提供的用户认证信息的正确性进行检验,因为对于用户认证信息的检验是AuthenticationManager实例来完成的,用户提供的认证信息是否成功通过检验,也是AuthenticationManager处理以后记录在Authentication接口实例中的。

当然,对于AuthenticationManager是不需要自定义其实现的,我在之前的文章中也提到过,AuthenticationManager内部是通过组合AuthenticationProvider来完成认证过程的。到这一步就比较简单了,因为平时比较常用的就是DaoAuthenticationProvider,而且它在使用时也不需要手工实例化,只需要实现其依赖的UserDetailsService实例即可。所以我们只需要实现一个自定义的用于获取用户信息的UserDetailsService类,然后再实现一个UserDetails类和一个PasswordEncoder类就可以完成对于认证过程的配置了。

在完成对于用户信息的认证过程以后还需要完成一个对于用户提供的Token进行认证和授权的自定义Filter。这个用于对Token进行鉴权的Filter就需要继承BasicAuthenticationFilter或者OncePerRequestFilter了,这两个基类在集成的时候只需要重写其中的doFilterInternal()方法即可。在这个Filter中索要完成的主要功能目标就是把请求中提供的Token转换成Authentication接口实例,保存到SecurityContextHolder中。

以下是一个继承了BasicAuthenticaitonFilter基类的示例。

public class TokenAuthorizationFilter extends BasicAuthenticationFilter {
    public TokenAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String tokenHeader = request.getHeader("Authorization");
        if (tokenHeader == null || !tokenHeader.startWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }
        // 利用从tokenHeader中获取的Token将其转换成Authentication接口实例。
        SecurityContextHolder.getContext().setAuthentication(authentication);
        super.doFilterInternal(request, response, chain);
    }
}

接下来所需要做的事情就是把定义好的Filter放入到SecurityFilterChain里了。对于addFilter()addFilterAt()AddFilterBefore()addFilterAfter()这些添加自定义Filter的方法这里越过贴源码的阶段直接说明其配置结果。Spring Security内部利用FilterComparator类保存了Spring Security中定义的Filter的顺序。这样在对自定义Filter进行添加的时候Spring Security就有了一套可以参考的顺序体系。

  1. addFilterAt(A, B)在添加过滤器的时候被添加的过滤器A的序号与指定过滤器B的序号相同。
  2. addFilterAfter(A, B)在添加过滤器的时候被添加的过滤器A的序号比指定过滤器B的序号大1。
  3. addFilterBefore(A, B)在添加过滤器的时候被添加的过滤器A的序号比指定过滤器B的序号小1。
  4. addFilter(A)在添加过滤器的时候被添加的过滤器A会排在其他系统默认过滤器的前面。

!!! note "" 注意如果在Spring Security中的SecurityFilterChain中的过滤器存在相同的顺序号,那么自定义的过滤器将优先执行。

完整配置示例

结合上面这个自定义Token的示例以下是一个比较完整的配置类示例在实际项目开发时可以参考使用。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private UserDetailsService userDetailsService;
    private PasswordEncoder passwordEncoder;
    private AuthenticationProvider authenticationProvider;

    public WebSecurityConfig(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder, AuthenticationProvider authenticationProvider) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
        this.authenticationProvider = authenticationProvider;
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        String[] permitAllUrls = new String[]{"/", "/js/**"};
        http
            // 定义需要被保护的URL
            .authorizeRequests()
                // 设置不需要经过认证和授权就可以访问的URL
                .antMatchers(permitAllUrls)
                    .permitAll()
                // 设置需要认证和授权以后才可以访问的URL
                .anyRequest()
                    .authenticated()
            .and()
                // 添加用户账号的验证
                .addFilter(new TokenAuthorizeRequestProcessingFilter(authenticationManager()))
                // 添加用户权限的验证
                .addFilter(new TokenAuthorizationFilter(authenticationManager()))
                // 关闭Session功能
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                // 增加异常处理将HTTP 403异常统一转交到自定义的EntryPoint处理
                .exceptionHandling()
                    .authenticationEntryPoint(new TokenAuthenticaionEntryPoint())
            .and()
                // 定义需要用户登录时需要转向到的页面
                .formLogin()
                    .loginPage("/login/fail")
                    .loginProcessingUrl("/login")
                    .defaultSuccessUrl("/login/success")
                        .permitAll()
                    .usernameParameter("username")
                    .passwordParameter("password")
                        .permitAll()
            .and()
                .httpBasic();
        // 禁用CSRF以便于使用Ajax提交表单
        http.csrf().disable();
        http.logout();
    }

    // 配置AuthenticationManager的构建器
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .authenticationProvider(authenticationProvider);
    }

    // 配置拦截器,处理静态资源被拦截的问题
    @Override
    public void configure(WebSecurity web) {
        web
            .ignoring()
                .antMatchers("/js/**", "/css/**", "/img/**");
    } 
}