Spring Security简介与基础原理

时间:2024-01-20 22:06:58

 对当前项目中使用到的Spring Security做一个简单的理解总结,方便以后查阅。文章有疏漏之处,欢迎指正。

Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访 问控制解决方案的安全框架。它提供了一组可以在 Spring 应用上下文中配置的 Bean,充分利用了Spring IOC和AOP功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。应用程序层面的安全大概可以归为两类:身份认证和授权,Spring Security 在架构设计上就将两者分开了,在每个架构上都留有扩展点。

1 身份认证架构

 

1.1 AuthenticationManager

身份认证的核心接口,只包含一个方法:
public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
}

authenticate 方法可能产生三种结果:
a) 如果身份认证成功,返回完备的 Authentication 对象(一般来说, authenticated=true)
b) 如果身份认证失败,抛出 AuthenticationException
c) 如果无法认证,则返回 null

1.2 ProviderManager

是最常用的 AuthenticationManager 接口的实现类,它将工作委托给 AuthenticationProvider 链。AuthenticationProvider 的接口定义类似于 AuthenticationManager,只不过多了个方法让调用者判断其是否支持传入的 Authentication 类型。

ProviderManager 会遍历 AuthenticationProvider 链,先判 断其是否支持传入的 Authentication 类型,如果支持,则调用 authenticate 方法, 如返回不为 null 的 Authentication,则身份认证成功。

for (AuthenticationProvider provider : getProviders()) {
            if (!provider.supports(toTest)) {
                continue;
            }

            if (debug) {
                logger.debug("Authentication attempt using "
                        + provider.getClass().getName());
            }

            try {
                result = provider.authenticate(authentication);

                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }
            catch (AccountStatusException e) {
                prepareException(e, authentication);
                // SEC-546: Avoid polling additional providers if auth failure is due to
                // invalid account status
                throw e;
            }
            catch (InternalAuthenticationServiceException e) {
                prepareException(e, authentication);
                throw e;
            }
            catch (AuthenticationException e) {
                lastException = e;
            }
        }
View Code

2 授权架构

2.1 AccessDecisionManager

授权的核心接口,包含三个方法:
public interface AccessDecisionManager {

    void decide(Authentication authentication, Object object,
            Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
            InsufficientAuthenticationException;

    boolean supports(ConfigAttribute attribute);

    boolean supports(Class<?> clazz);
}

decide 方法决定是否允许访问被保护的对象,传入的参数中,authentication 是被身份认证通过后的完备对象,object是被保护的对象, configAttributes是被保护对象的属性信息。

2.2 AbstractAccessDecisionManager

实现了 AccessDecisionManager 接口的抽象类,它管理了一个 AccessDecisionVoter 列表,在执行授权操作时,调用每个 AccessDecisionVoter 的 vote 方法得到单独投票结果,对每个 voter 投票结果的仲裁则由其子类来完成。

Spring Security 提供了 3 个子类,每个子类的仲裁逻辑如下:

AffirmativeBased:只要有 voter投了赞成票,则授权成功;在没有赞成票的情况下,只要有反对票,则授权失败;在全部弃权的情况下,根据 isAllowIfAllAbstainDecisions 方法的返回值决定是否授权。

ConsensusBased: 根据少数服从多数的原则决定是否授权,如果赞成票和反对票相等,根据 isAllowIfAllAbstainDecisions 方法的返回值决定是否授权。

UnanimousBased:只有全部是赞成票或者弃权才授权成功,只要有反对票则授权失败。如果全部弃权,根据 isAllowIfAllAbstainDecisions 方法的返回值决定是否授权。

2.3 AccessDecisionVoter

对是否授权进行投票,核心方法是 vote, 该方法只能返回 3 种 int 值: ACCESS_GRANTED(1)ACCESS_ABSTAIN(0)ACCESS_DENIED(-1)

这儿就是一个扩展点,项目可以实现自己的voter,例如: SimpleDecisionVoter implements AccessDecisionVoter<FilterInvocation>

 

3 Web 层安全

Spring Security 在 web 层的应用是基于 Servlet Filter 实现的,具体的实现类是 DelegatingFilterProxy,该代理类又会委托 Spring 容器管理的 FilterChainProxy 来处理,

FilterChainProxy 维护了一系列内部的 filter, 正是这些内部的 filter 实现 了全部的安全逻辑。关系图如下:

在web.xml在配置即可:

 

<filter>

  <filter-name>springSecurityFilterChain</filter-name>

  <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>

 </filter>

 <filter-mapping>

  <filter-name>springSecurityFilterChain</filter-name>

  <url-pattern>/*</url-pattern>

 </filter-mapping>

 

3.1 核心过滤器

以我所在的项目组对Spring Security的使用为例,一次web请求的filter chain 如下:

3.1.1 SecurityContextPersistenceFilter

此filter先尝试从servlet session中获取 SecurityContext ,如果没有获取到,则创建一个空的 SecurityContext 对象,SecurityContextHolder 是 SecurityContext 的存放容器,使用 ThreadLocal 存储并将此对象跟当前线程关联。

下图显示了整个交互过程:

3.1.2 LogoutFilter

先判断请求的 url 是否匹配 logout-url,如果匹配则重定向到指定的 url,否则直接调用下一个 filter。下图显示了整个交互过程:

属性名 作用
logout-url 表示此请求做为退出登录的默认地址
invalidate-session 表示是否要在退出登录后让当前session失效,默认为true。
delete-cookies 指定退出登录后需要删除的cookie名称,多个cookie之间以逗号分隔。
logout-success-url 指定成功退出登录后要重定向的URL。需要注意的是对应的URL应当是不需要登录就可以访问的。
success-handler-ref 指定用来处理成功退出登录的LogoutSuccessHandler的引用。

 

 

 

 

 

 

 

 

 

简单看一下LogoutFilter的源码

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (requiresLogout(request, response)) {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();

            if (logger.isDebugEnabled()) {
                logger.debug("Logging out user \'" + auth
                        + "\' and transferring to logout destination");
            }

            this.handler.logout(request, response, auth);

            logoutSuccessHandler.onLogoutSuccess(request, response, auth);

            return;
        }

        chain.doFilter(request, response);
    }

如果 requiresLogout(request, response)为true,则分别调用 CompositeLogoutHandler 的 logout(request, response, auth) 方法和 LogoutSuccessHandler 的 onLogoutSuccess(request, response, auth);

在这两个方法中,logout 和 onLogoutSuccess 除了执行Spring Security自己的一些内部方法,比如 SecurityContextLogoutHandler 的 logout,我们也可以自己定义自己的方法,退出登录需要删除自定义的cookie等。

3.1.3 UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter

先看看 AbstractAuthenticationProcessingFilter 的 doFilter 方法

 1 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
 2             throws IOException, ServletException {
 3 
 4         HttpServletRequest request = (HttpServletRequest) req;
 5         HttpServletResponse response = (HttpServletResponse) res;
 6 
 7         if (!requiresAuthentication(request, response)) {
 8             chain.doFilter(request, response);
 9 
10             return;
11         }
12 
13         if (logger.isDebugEnabled()) {
14             logger.debug("Request is to process authentication");
15         }
16 
17         Authentication authResult;
18 
19         try {
20             authResult = attemptAuthentication(request, response);
21             if (authResult == null) {
22                 // return immediately as subclass has indicated that it hasn\'t completed
23                 // authentication
24                 return;
25             }
26             sessionStrategy.onAuthentication(authResult, request, response);
27         }
28         catch (InternalAuthenticationServiceException failed) {
29             logger.error(
30                     "An internal error occurred while trying to authenticate the user.",
31                     failed);
32             unsuccessfulAuthentication(request, response, failed);
33 
34             return;
35         }
36         catch (AuthenticationException failed) {
37             // Authentication failed
38             unsuccessfulAuthentication(request, response, failed);
39 
40             return;
41         }
42 
43         // Authentication success
44         if (continueChainBeforeSuccessfulAuthentication) {
45             chain.doFilter(request, response);
46         }
47 
48         successfulAuthentication(request, response, chain, authResult);
49     }

20行的 attemptAuthentication(request, response) 就是 UsernamePasswordAuthenticationFilter 是尝试认证过程。

--------------------------------------UsernamePasswordAuthenticationFilter 分析开始---------------------------------------------------------------

身份认证过程:根据 form 表单中的用户名获取用户信息(包含密码),将数 据库中的密码和 form 表单中的密码做比对,若匹配则身份认证成功,并调用 AuthenticationSuccessHandler. onAuthenticationSuccess()。

下图显示的是认证成 功的交互过程: 

因为真实项目登录除了校验用户名和密码外,可能还有有些额外的校验,比如验证码之类的,所有我们会自定义一个类去继承 UsernamePasswordAuthenticationFilter,然后重写 attemptAuthentication() 方法。

类 UsernamePasswordAuthenticationFilter 的 attemptAuthentication(HttpServletRequest request, HttpServletResponse response)方法:

public Authentication attemptAuthentication(HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String username = obtainUsername(request);
        String password = obtainPassword(request);

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }
View Code

然后执行类 ProviderManager 的 authenticate(Authentication authentication) 方法,已经到了身份认证架构部分,

继续调用抽象类 AbstractUserDetailsAuthenticationProvider 的 authenticate(Authentication authentication) 方法:

 1 public Authentication authenticate(Authentication authentication)
 2             throws AuthenticationException {
 3         Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
 4                 messages.getMessage(
 5                         "AbstractUserDetailsAuthenticationProvider.onlySupports",
 6                         "Only UsernamePasswordAuthenticationToken is supported"));
 7 
 8         // Determine username
 9         String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
10                 : authentication.getName();
11 
12         boolean cacheWasUsed = true;
13         UserDetails user = this.userCache.getUserFromCache(username);
14 
15         if (user == null) {
16             cacheWasUsed = false;
17 
18             try {
19                 user = retrieveUser(username,
20                         (UsernamePasswordAuthenticationToken) authentication);
21             }
22             catch (UsernameNotFoundException notFound) {
23                 logger.debug("User \'" + username + "\' not found");
24 
25                 if (hideUserNotFoundExceptions) {
26                     throw new BadCredentialsException(messages.getMessage(
27                             "AbstractUserDetailsAuthenticationProvider.badCredentials",
28                             "Bad credentials"));
29                 }
30                 else {
31                     throw notFound;
32                 }
33             }
34 
35             Assert.notNull(user,
36                     "retrieveUser returned null - a violation of the interface contract");
37         }
38 
39         try {
40             preAuthenticationChecks.check(user);
41             additionalAuthenticationChecks(user,
42                     (UsernamePasswordAuthenticationToken) authentication);
43         }
44         catch (AuthenticationException exception) {
45             if (cacheWasUsed) {
46                 // There was a problem, so try again after checking
47                 // we\'re using latest data (i.e. not from the cache)
48                 cacheWasUsed = false;
49                 user = retrieveUser(username,
50                         (UsernamePasswordAuthenticationToken) authentication);
51                 preAuthenticationChecks.check(user);
52                 additionalAuthenticationChecks(user,
53                         (UsernamePasswordAuthenticationToken) authentication);
54             }
55             else {
56                 throw exception;
57             }
58         }
59 
60         postAuthenticationChecks.check(user);
61 
62         if (!cacheWasUsed) {
63             this.userCache.putUserInCache(user);
64         }
65 
66         Object principalToReturn = user;
67 
68         if (forcePrincipalAsString) {
69             principalToReturn = user.getUsername();
70         }
71 
72         return createSuccessAuthentication(principalToReturn, authentication, user);
73     }
View Code

19行 retrieveUser 方法的具体实现在类 DaoAuthenticationProvider 中,这儿就是从我们系统中根据 username,查询到一个 UserDetails 数据。

40行 preAuthenticationChecks.check(user); 则是为已经查询到的用户,附上在当前系统中的权限数据。

41行 additionalAuthenticationChecks 里面就是密码的校验,具体实现类还是 DaoAuthenticationProvider,自定义的 passwordEncoderHandler 实现了 PasswordEncoder 接口 isPasswordValid 方法,去比较密码是否匹配。

--------------------------------------UsernamePasswordAuthenticationFilter 分析结束---------------------------------------------------------------

继续 AbstractAuthenticationProcessingFilter 的 doFilter 方法分析,

32和38行 unsuccessfulAuthentication(request, response, failed) 是认证失败的处理逻辑,

  认证失败时,首先 SecurityContextHolder.clearContext(),清除认证信息,然后在 SimpleUrlAuthenticationFailureHandler 可以处理自定义的认证失败逻辑

48行的 successfulAuthentication(request, response, chain, authResult) 则是认证成功的逻辑。

  认证成功时,首先 SecurityContextHolder.getContext().setAuthentication(authentication),其次执行 AbstractRememberMeServices 的 loginSuccess 方法,然后执行 SavedRequestAwareAuthenticationSuccessHandler的 onAuthenticationSuccess方法。

RememberMeAuthenticationFilter

这个filter貌似是查询 当前SecurityContextHolder的Authentication是否为null,如果为空则继续走类似上一个filter的身份认证过程,如果不为空,继续下一个filter。

3.1.4 RequestCacheAwareFilter

此 filter 通常用作临时重定向,场景是这样的:在未登陆的情况下访问某个安全 url,会重定向到登陆页,在重定向之前先在 session 中保存访问此安全 url 的请求,在登陆成功后再继续处理它。下图显示的是交互过程:

图中 SAVED_REQUEST = "SPRING_SECURITY_SAVED_REQUEST"

3.1.5 SecurityContextHolderAwareRequestFilter

将 servlet request 封装到 SecurityContextHolderAwareRequestWrapper 类中,此 包装类继承自 HttpServletRequestWrapper,并提供了额外的跟安全相关的方法。

3.1.6 AnonymousAuthenticationFilter

如果到了这步 SecurityContext 中的 Authentication 对象仍然为null, 则创建一个 AnonymousAuthenticationToken,这个对象可以类比网站的匿名用户。下图展示 的是交互过程:

3.1.7 SessionManagementFilter

这个过滤器看名字就知道是管理 session 的了,提供两大类功能: session 固化 保护(通过 session-fixation-protection 配置),session 并发控制(通过 concurrency-control 配置)。

3.1.8 ExceptionTranslationFilter

当请求到达这里时,会将对后续过滤器的调用封装在 try..catch 块中,如果捕获 到 AuthenticationException 异常,则调用 authenticationEntryPoint.commence 方法开启新的身份认证流程(一般来说是跳转到登陆页),

如果捕获到 AccessDeniedException 异常,调用 accessDeniedHandler.handle 方法处理(比如: 展示未授权的错误页面)。下图显示的是交互过程:

3.1.9 FilterSecurityInterceptor

此过滤器是整个过滤器链的最后一环,用于保护 Http 资源的,它需要一个 AccessDecisionManager 和一个 AuthenticationManager 的引用。它会从 SecurityContextHolder 获取 Authentication,

然后通过 SecurityMetadataSource 可以得知当前请求是否在请求受保护的资源。对于请求那些受保护的资源,如果 Authentication.isAuthenticated()返回 false 或者 FilterSecurityInterceptor 的 alwaysReauthenticate 属性为 true ,

那么将会使用其引用的 AuthenticationManager 再认证一次,认证之后再使用认证后的 Authentication替换 SecurityContextHolder 中拥有的那个。然后就是利用 AccessDecisionManager 进行权限的检查,也就进行到了授权架构部分。

下图显示的是交互过程:

3.2其他过滤器 

3.2.1 CsrfFilter 

该过滤器通过 token 防范 CSRF 攻击,将从 tokenRepository 获取的 token 与从 请求参数或者 header 参数中获取的 token 做比较,如果不匹配,调用 accessDeniedHandler.handle 方法来处理。下图展示的是交互过程: 

3.2.2 HeaderWriterFilter 

该过滤器通过写响应安全头的方式来保护浏览器端的安全,下面简单介绍下各种 http 安全头。

content-security-policy:通过定义内容来源来预防 XSS 攻击或者代码植入攻击,下面 的例子只允许当前域或者 google-analytics.com 的脚本执行。

content-security-policy: script-src \'self\' https://www.google-analytics.com 
 

X-XSS-Protection: 又称 XSS 过滤器,通过指示浏览器阻止含有恶意脚本的响应来预防 XSS 攻击。 

x-xss-protection: 1; mode=block 
 

strict-transport-security(HSTS):该头指示浏览器只能使用 https 访问 web server。 

strict-transport-security: max-age=31536000; includeSubDomains; preload 

 

X-Frame-Options:来确保自己网站的内容没有被嵌到别人的网站中去,也从而避免了点击 劫持 (clickjacking) 的攻击。X-Frame-Options 有三个值: DENY(表示该页面不允许 在 frame 中展示,即便是在相同域名的页面中嵌套也不允许),

SAMEORIGIN(表示该页面可 以在相同域名页面的 frame 中展示),ALLOW-FROM uri(表示该页面可以在指定来源的 frame 中展示)。 

X-Content-Type-Options:如果服务器发送响应头 "X-Content-Type-Options: nosniff",则 script 和 styleSheet 元素会拒绝包含错误的 MIME 类型的响应。这是一 种安全功能,有助于防止基于 MIME 类型混淆的攻击。 

 

4.参考文献