--- title: 在Spring MVC中的配置Spring Security tags: - Java - Spring - Spring Security - Spring MVC - 安全认证 categories: - - JVM - Spring keywords: 'Spring,Spring Security,UserDetails,SecurityContext,Servlet' date: 2021-08-10 09:19:31 --- 其实在Spring应用中使用Spring Security并不困难,最复杂的事情应该就是如何完成Spring Security的配置了。一旦Spring Security的配置成功完成,那么在Controller中就可以直接使用Spring Security提供的注解来使用Spring Security安全认证的结果。 要完成Spring MVC应用中的Spring Security配置,需要至少完成以下两项内容: 1. 完成设置HTTP的认证流程。 1. 完成认证所需要使用的类。 以下将从这两个方向分别说明如何在Spring MVC应用中配置Spring Security。 本篇关于 Spring Security 的文章主要由三部分组成,分别是: 1. {% post_link spring-security-basic %} 1. {% post_link spring-security-webmvc %} 1. {% post_link spring-security-webflux %} ## HttpSecurity 要在一个Spring MVC应用中启用Spring Security,除了要在主类或者配置类上添加`@EnableWebSecurity`注解以外,更重要的是建立一个继承了`WebSecurityConfigurerAdapter`抽象类的配置类。这个配置类在我们的代码中最常见的形式就是下面这个样子。 ```java @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`接口类型的参数,而这个`Customizer`中的泛型参数恰恰就是上图中所有同名配置方法的返回类型。也就是说这两个方法的功能是一致的,只是调用的方式不同。`Customizer`接口类型是一个函数式接口,其中只有一个方法需要实现:`custom(T t)`。所以在使用的时候,可以直接使用`Customizer`接口的泛型类型作为Lambda表达式的参数即可。例如有方法`HeadersConfigurer headers()`,那么就会有一个对应的使用函数式接口的方法`HttpSecurity headers(Customizer> customizer)`。如果在使用的时候没有发现使用函数式接口的方法,那么就说明这个配置方法并适合使用函数式接口进行配置。 ### ExpressionUrlAuthorizationConfigurer 这个配置类被拿出来单独说明的原因主要是这个类控制的是需要认证之后才可以访问的服务范围。`ExpressionUrlAuthorizationConfigurer`类配置其几个内部类`ExpressionInterceptUrlRepository`和`AuthorizedUrl`,就可以完成大部分的服务路径与授权配置之间的映射。 首先还是来看一下这个类的具体结构。 {% oss_image spring-security/spring-security-ExpressionUrlAuthorizationConfigurer.svg "ExpressionUrlAuthorizationConfigurer类图" %} 从这张图中可以看出来,`AuthorizedUrl`类才是整个`ExpressionUrlAuthorizationConfigurer`类配置过程的核心。而`ExpressionUrlAuthorizationConfigurer`配置类最主要的功能就是为各个指定的URL生成表示其所需权限的SpEL表达式,这样就可以使使用注解在Controller上配置的权限和在`WebSecurityConfigurerAdapter`中统一配置的权限能够合并成为一套处理方法处理。 以下是一个比较常见的配置示例。 ```java @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()`,表示匹配所有的请求,通常用来设置所有的内容都需要进行认证操作。这个方法不接受任何参数。 1. `.antMatchers()`,使用ant表达式来定义URL匹配规则,例如`/js/**/*.js`表示匹配`js`目录下的所有后缀为`.js`的文件。其规则大致如下。 - `?`匹配一个字符。 - `*`匹配0个或者多个字符。 - `**`匹配0个或者多个目录。 1. `.regexMatchers()`,使用正则表达式进行匹配,例如`.+[.]js`表示匹配所有后缀为`.js`的文件。 1. `.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`接口,该接口主要用于获取用户的用户名、密码、拥有权限等信息。 1. `PasswordEncoder`接口,该接口主要用于对用户提供的明文密码进行加密,并与系统中存储的密码加以比对。 所以一般只要在应用中实现了这两个类,就可以按照Spring Security会自动采用`DaoAuthenticationProvider`并按照设定的流程完成用户的认证。 ### UserDetailsService 在继承`WebSecurityConfigurerAdapter`抽象类的配置类中,除了需要声明`HttpSecurity`配置方法以外,还需要声明的一个Bean是`UserDetailsService`实例,当然也可以使用`@Component`或者是`@Service`等注解来声明。`UserDetailsService`是加载用户数据的核心接口,其中只有一个方法需要实现:`UserDetails loadUserByUsername(String name)`。 以下是一个`UserDetailsService`的实现示例。 ```java @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 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`。 1. `pbkdf2`,采用`Pbkdf2PasswordEncoder`。 1. `argon2`,采用`Argon2PasswordEncoder`。 1. `ldap`,采用`LdapShaPasswordEncoder`。 1. `scrypt`,采用`SCryptPasswordEncoder`。 1. `MD4`,采用`Md4PasswordEncoder`。 1. `MD5`,采用`MessageDigestPasswordEncoder`。 1. `SHA-1`,采用`MessageDigestPasswordEncoder`。 1. `SHA-256`,采用`MessageDigestPasswordEncoder`。 1. `sha256`,采用`StandardPasswordEncoder`。 1. `noop`,采用`NoOpPasswordEncoder`,这个`PasswordEncoder`将保存密码明文。 !!! caution "废弃API提示" 不过需要注意的是,在目前的Spring Security版本中,`Md4PasswordEncoder`、`MessageDigestPasswordEncoder`、`LdapShaPasswordEncoder`和`StandardPasswordEncoder`都已经被废弃,如果需要这样的编码方式,尽量还是采用自定义编码的方式。 如果需要自定义`PasswordEncoder`,只需要像`UserDetailsService`一样,构建一个实现了`PasswordEncoder`接口的Bean即可。但此时就不再需要使用`DelegatingPasswordEncoder`需要的`encoding`标签了。 ### EntryPoint EntryPoint是Spring Security建立的一个概念模型接口,表示一个认证入口点。这个EntryPoint主要完成的功能就是在处理用户请求的过程中如果遇到了认证异常,那么`ExceptionTranslationFilter`就会启动用于特定认证方案(Authentication Schema)的流程。Spring Security中所有的入口点都会实现`AuthenticationEntryPoint`接口。`AuthenticationEntryPoint`接口的内容十分简单,其剥除了注释以后的源码如下。 ```java 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`状态码,并不会启动后续的流程。 1. 返回一个特定的HTTP状态码可以可使用`HttpStatusEntryPoint`,也同样不会启动后续的流程。 1. 将用户重定向到一个登录页面可以使用`LoginUrlAuthenticationEntryPoint`。 1. 启动标准Http Basic认证流程可以使用`BasicAuthenticationEntryPoint`。 1. 启动标准Http Digest认证流程可以使用`DigestAuthenticationEntryPoint`。 1. 将认证任务委托给其他的EntryPoint对象可以使用`DelegatingAuthenticationEntryPoint`。 以下是一个配置使用`BasicAuthenticationEntryPoint`的简单示例。 ```java @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`中,用来保存当前用户的认证信息。 1. 自定义登录请求处理Filter,需要继承`UsernamePasswordAuthenticationFilter`或者`AbstractAuthenticationProcessingFilter`。用来对指定的登录路径进行自动的用户认证信息进行验证处理,这样就可以在处理用户登录请求的Controller中直接根据用户登录信息生成Token了。 1. 自定义用户认证授权Filter,需要继承`BasicAuthenticationFilter`或者`OncePerRequestFilter`。用来对请求中携带的Token进行处理,将其转换为Spring Security工作所需要的`Authentication`实例,并将其保存在`SecurityContext`中。 1. 自定义`AuthenticationProvider`,需要实现`AuthenticationProvider`接口。如果不使用`DaoAuthenticationProvider`的话,就需要使用这个自定义的`AuthenticationProvider`了,但一般情况下`DaoAuthenticationProvider`已经足够使用。 1. 配置`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`。 ```java 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`基类的示例。 ```java 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的序号相同。 1. `addFilterAfter(A, B)`在添加过滤器的时候,被添加的过滤器A的序号比指定过滤器B的序号大1。 1. `addFilterBefore(A, B)`在添加过滤器的时候,被添加的过滤器A的序号比指定过滤器B的序号小1。 1. `addFilter(A)`在添加过滤器的时候,被添加的过滤器A会排在其他系统默认过滤器的前面。 !!! note "" 注意,如果在Spring Security中的`SecurityFilterChain`中的过滤器存在相同的顺序号,那么自定义的过滤器将优先执行。 ## 完整配置示例 结合上面这个自定义Token的示例,以下是一个比较完整的配置类示例,在实际项目开发时可以参考使用。 ```java @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/**"); } } ```