Spring Security是一个强大的通用认证和访问控制框架。在基于spring的应用程序的安全保护方面,Spring Security已成为事实上的标准。
Spring Security具有以下功能:
- 对身份验证和授权的全面的、可扩展的支持
- 防止会话固定、点击劫持、跨站请求伪造等攻击
- Servlet API集成
- 可选的Spring Web MVC集成
- 其他
简单地说,Spring Security的核心是一系列Servlet过滤器。通过这些过滤器,你可以将认证和授权添加到你的web应用中。
Spring Security能很好地与Spring Web MVC或Spring Boot等框架以及OAuth2或SAML等标准集成。它还会自动生成登录/注销页面,并防止常见的漏洞攻击,如CSRF。
基本介绍
在理解Spring Security之前,需要先理解清楚Authentication、Authorization和Servlet Filters这几个概念。
Authentication即认证,一般就是身份校验,比如最常见的用户名和密码校验。在一些简单的应用中,可能只需要认证就够了。但是大部分应用还有权限的概念。比如一个购物网站,普通用户输入用户名和密码之后,拥有浏览、购物、评论等权限。商家通过自己的用户名和密码登录之后,拥有查看销售数据、上传商品、设置商品价格等权限。网站管理员通过用户名和密码登录之后,拥有管理商家、设置网站营销活动等权限。
所有的用户都需要登录,这就是身份认证。但身份认证成功只能保证你是合法用户,至于你登录之后可以做什么,认证是没办法控制的,这时候就需要Authorization(授权)了。通过授予不同的用户不同的权限,可以让普通用户浏览商品、购物和评价商品,商家可以查看销售数据、上传商品,网站管理员可以管理商家、设置网站营销活动等。
只要对Spring有所了解,应该知道这样一个事实:几乎所有的Spring web应用都可以看作是一个Servlet,即DispatcherServlet。这个Servlet将进来的Http请求转发到@Controller
或者@RestController
。而这个DispatcherServlet中并没有安全保护相关的代码。与此同时,你估计也不想在你的Controller中添加原始的HTTP Basic Auth请求头参数,然后在业务代码中去做认证授权相关的操作。那能不能在请求经过DispatcherServlet转发之前来做认证和授权呢?正好Java中可以用过滤器来做这种事。
过滤器
下面是一个简单的安全过滤器示例:
public class SecurityServletFilter extends HttpFilter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
UsernamePasswordToken token = extractUsernameAndPasswordFrom(request);
if (notAuthenticated(token)) {
// 错误的用户名或密码,认证不通过
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
if (notAuthorized(token, request)) {
// 认证通过,但没有授权
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
chain.doFilter(request, response);
}
private UsernamePasswordToken extractUsernameAndPasswordFrom(HttpServletRequest request) {
// 从Basic Auth HTTP头中获取用户名和密码;或者从请求参数中获取用户名和密码
return checkVariousLoginOptions(request);
}
private boolean notAuthenticated(UsernamePasswordToken token) {
return false;
}
private boolean notAuthorized(UsernamePasswordToken token, HttpServletRequest request) {
return false;
}
}
以上过滤器确实可以用于认证和授权,但它迟早会变成一个包含大量用于各种身份验证和授权机制的代码的怪物过滤器。因此一般不会将认证和授权机制都放在一个过滤器里面来实现,而是分为多个过滤器,以责任链模式来实现,这就是所谓的FilterChain过滤器链。
Spring Security 过滤器
假设现在在Springboot项目中引入Spring Security,只需引入依赖即可
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
启动项目,可以看到如下日志:
o.s.s.web.DefaultSecurityFilterChain : Will secure any request with
[org.springframework.security.web.session.DisableEncodeUrlFilter@7327a447,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@67022ea,
org.springframework.security.web.context.SecurityContextHolderFilter@3127cb44,
org.springframework.security.web.header.HeaderWriterFilter@6d0be7ab,
org.springframework.security.web.csrf.CsrfFilter@7b3cde6f,
org.springframework.security.web.authentication.logout.LogoutFilter@3b4a1a75,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@1c3259fa,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@71adfedd,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@2954f6ab,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@58b5a2f3,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@631cb129,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@2dd8ff1d,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@58fbd02e,
org.springframework.security.web.access.ExceptionTranslationFilter@5584d9c6,
org.springframework.security.web.access.intercept.AuthorizationFilter@2fe74516]
从中可以看到,Spring Security 带来的并不仅仅只是一个过滤器,而是一个过滤器链,包含15个过滤器。执行顺序就是按照日志中打印出来的过滤器顺序。
如果想了解Spring Security的全部过滤器,可以去看源代码。这里仅对其中几个重要的过滤器进行介绍。
UsernamePasswordAuthenticationFilter
该过滤器是Spring Security中用于处理基于表单的认证的过滤器。它处理来自客户端的用户名和密码登录请求,并将这些凭证转换为一个Authentication对象,然后它将该对象委托给认证管理器(AuthenticationManager)进行认证。
DefaultLoginPageGeneratingFilter
该过滤器将生成一个默认登录页面。当你访问该系统时,将自动跳转到登录页面,如下所示:
这个页面以表单形式进行登录认证。因此,当你输入用户名和密码后,点击登录时会自动进入UsernamePasswordAuthenticationFilter
过滤器,进行用户名和密码校验。
DefaultLogoutPageGeneratingFilter
该过滤器将生成一个默认登出页面。
BasicAuthenticationFilter
该过滤器从请求中获取Basic Auth 请求头,并进行用户名和密码认证。
Basic Auth在不同的工具中设置是不同的。例如在curl中以-u参数提供:
curl -u user:passwd http://xxx.com
,当然,也可以用头参数Authorization: Basic base64(user:passwd)
的形式提供。
AuthorizationFilter
该过滤器用于处理授权操作。
通过以上这几个过滤器,Spring Security提供了登录/登出界面,同时也提供了以Basic Auth或表单登录的能力。当然,除了这几个过滤器之外,还有很多其他过滤器,提供了诸如CSRF攻击防护等能力。
Spring Security的这么多过滤器基本囊括了常用的认证、授权和安全防护等方面的问题。剩下的工作其实只要进行适当的配置,以便让这些过滤器正确地运行起来就可以了。
配置Spring Security
在最新的Spring Security版本中,配置Spring Security的方式是创建一个配置类,并添加如下配置:
- 注解
@EnableWebSecurity
。 - 继承
WebSecurityConfigurer
,该类提供了一个配置DSL/方法。通过这些方法,可以指定需要保护的路径,或者启用/禁用某些过滤器。
下面是一个示例:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll().anyRequest().authenticated().and()
.formLogin()
.loginPage("/login") .permitAll().and()
.logout()
.permitAll().and()
.httpBasic();
}
}
以上示例通过覆写configure方法,以DSL方式配置了过滤器链。所有的访问/
或/home
的请求都是直接允许的,其他请求则需要进行认证。换句话说,除了/
和/home
之外的请求,都要进行登录。登录时需要在自定义登录页面(/login
)通过表单提交用户名和密码。
这里显式指定了登录页面,因此就不能用Spring Security自带的登录页面了,去掉
.loginPage("/login")
就可以。
在Springboot中,@EnableWebSecurity注解是自动开启的,无需额外添加。
在Spring Security 5.7及之后的版本中WebSecurityConfigurerAdapter被弃用,Spring Security将转向将转向基于组件的安全配置。在组件化配置中,如果要实现某些功能,可以直接生产一一个该类型的Bean通过@Bean注解注入到IOC容器,覆盖Springboot的默认配置即可。这种方式显然更加方便,无需实现接口再重写方法,还能尽可能的减少配置类的数量。具体来说,就是直接生产一个SecurityFilterChain实例。如下所示:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
return http.build();
}
}
Spring Security认证
说到身份认证,大致有三种场景:
- 默认情况下,用户名和密码存在本地系统中,例如数据库或者内存,是可以直接访问到的。
- 不太常见的情况,用户名和密码是无法直接访问到的,而是要通过第三方的其他服务进行认证。
- 还有一种比较流行的场景,那就是使用OAuth2或者“通过Google/Twitter/xx登录”。
下面介绍前两种情况下的认证。
UserDetailService
在上文介绍Spring Security的核心过滤器时说到过,UsernamePasswordAuthenticationFilter
过滤器用于处理以表单方式提交的用户名和密码认证。在使用Spring Security提供的默认登录页面的情况下,必然是在此Filter中进行认证的。
Spring Security默认的用户名为user,临时密码可以从控制台日志中看到。
在其doFilter
方法中,调用了attemptAuthentication
方法,该方法的最后一行是:return this.getAuthenticationManager().authenticate(authRequest)
。这里的认证管理器为ProviderManager
,在该管理器的认证方法中,选取合适的认证provider进行认证:result = provider.authenticate(authentication)
。
经调试发现,合适的认证provider为DaoAuthenticationProvider
,继承了AbstractUserDetailsAuthenticationProvider
的认证方法。
进一步进入其中发现:
- 首先通过缓存获取用户信息。
- 其次通过
DaoAuthenticationProvider
的retrieveUser方法获取用户信息,即:UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username)
。拿到用户信息后,做一些基本check操作和真正的用户信息check操作。
根据以上分析可知,只要覆写loadUserByUsername
方法,并定义合适的PasswordEncoder
即可(用于用户密码check逻辑)。
下面是一个简单示例。
定义一个bean,实现UserDetailServices接口:
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SecurityUser securityUser = new SecurityUser();
if(username == null || !username.equals(securityUser.getUsername())){
throw new UsernameNotFoundException("该用户不存在");
}
return securityUser;
}
}
这样在活获取用户信息时就拿到了SecurityUser中的用户名和密码。SecurityUser如下所示:
public class SecurityUser implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return new BCryptPasswordEncoder().encode("123456");
}
@Override
public String getUsername() {
return "test";
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
拿到用户民和密码之后后续验证逻辑就在check方法中了。具体实现其实很简单,就是判断从页面上输入的用户名和密码与SecurityUser中的用户名和密码是否一致了。只有一个点需要注意,即密码编码器。在SecurityUser中,密码是经过BCryptPasswordEncoder
编码的。因此,需要提供此编码器的bean,在check逻辑中才能正确地匹配上。如下所示:
@Configuration
public class SecurityConfig {
@Bean
public static BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里将用户名和密码写在了SecurityUser中是为了方便,实际中用户信息也可以是在数据库的。
除了自定义的用户认证逻辑之外,实际上Spring Security中也有现成的用户认证实现,可以从UserDetailsService的实现中看到,比如InMemoryUserDetailsManager
。
InMemoryUserDetailsManager
在某些情况下只需要对接口进行简单的请求控制,无需将认证粒度细化到具体的用户。比如针对某个敏感API,通过API User可以访问,否则不能访问。那实际上就只要有一个用户即可,此时可以将用户信息放在内存中存储。内存用户认证可以通过InMemoryUserDetailsManager
实现。
在InMemoryUserDetailsManager
的loadUserByUsername
方法中可以看到,此时用户是从this.users
中获取的。那说明只要将用户信息放到InMemoryUserDetailsManager
的users
里面去就可以了。只需要在SecurityConfig中配置就行:
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 在内存中配置用户名、密码和角色信息
auth
.inMemoryAuthentication()
.withUser("test").password(new BCryptPasswordEncoder().encode("123456")).roles("USER");
}
显然,InMemoryUserDetailsManager中的users是支持多用户的。
HTTP Basic 认证
上面介绍用户认证是通过表单方式提交用户名和密码的,这也是Spring Security的默认方式。
如果不想使用表单来登录,而是要使用其他的方式,比如Http Basic认证,也是可以的。此时生效的过滤器是BasicAuthenticationFilter
,其他的流程完全一模一样。
唯一的区别修改一下Spring Security的认证方式:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(request ->
request.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
public static BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里通过SecurityFilterChain
接口来配置自定义的过滤器链。
PasswordEncoders
上文提到,要定义一个Bean来表示密码加密方式。在认证过程的check逻辑中,会通过此种加密方式对参数password进行加密,然后和系统中的用户密码进行对比。
如果不同的用户密码有不同的加密方式呢?此时一个PasswordEncoder是不够的。Spring Security提供了多加密实现方案。
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
这样会根据密码前缀自动选取对应的编码器,如{bcrypt}xxxxxxxxxxxxx
会自动选择BCryptPasswordEncoder。
无法直接访问用户信息
部分场景下,用户信息存储在第三方系统中,无法直接在本系统内访问。此时系统默认的认证provider即DaoAuthenticationProvider
是不适合的,可以自定义provider。
设想一下,你的Jira,Confluence系统的用户信息均存储在Atlassian Crowd系统中,此时你要判断当前的请求用户是否是合法用户,需要通过Atlassian Crowd进行认证。
这里隐含两个信息:一是Atlassian Crowd不可能给你用户密码;二是Atlassian Crowd提供了认证的Rest接口,你可以通过此接口进行用户认证。
在此种情况下,UserDetailsService
是没有用的(第三方认证系统不会给你用户密码信息),但你可以自定义一个认证provider:
@Component
public class CustomAuthenticateProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getPrincipal().toString();
String password = authentication.getCredentials().toString();
User user = callAtlassianCrowdRestService(username, password);
Assert.notNull(user, "could not login");
return new UsernamePasswordAuthenticationToken(username, password, Collections.emptyList());
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
...
}
此时认证逻辑就不是走DaoAuthenticationProvider
的authenticate
方法了,而是走这里自定义的认证方法。在自定义认证方法中,实际的认证逻辑是通过callAtlassianCrowdRestService(假设这是AtlassianCrowd的接口)实现的。
参考资料
[1]. https://www.marcobehler.com/guides/spring-security
[2]. https://docs.spring.io/spring-security