Spring Security / Servlet Application / 鉴权

上一篇:Spring Security / Servlet Application / 大图景

样例代码:Spring Security Sample

参考:Spring Security Reference


Servlet 鉴权的架构组件

  • SecurityContextHolder
    Spring Security 用 SecurityContextHolder 来存储鉴权对象的详细信息
  • SecurityContext
    SecurityContextHolder 获得 SecurityContext
    含有当前鉴权用户的 Authentication
  • Authentication
    1. 作为 AuthenticationManager 的输入,为其提供用户输入的鉴权凭证(通常为密码)
    2. 存放已鉴权的用户信息,可以从 SecurityContext 中获取
  • GrantedAuthority
    当前 Authentication 中授予主体的权限(即:roles、scopes 等)
  • AuthenticationManager
    提供定义了 Spring Security 的 Filter 如何执行鉴权的 API
  • ProviderManager
    最常用的 AuthenticationManager 的实现
  • AuthenticationProvider
    ProviderManagerAuthenticationProvider 来执行特定类型的鉴权
  • Request Credentials with AuthenticationEntryPoint
    向客户端(client)请求凭证(credential)(即:重定向到登录页、发送 WWW-Authenticate 响应等)
  • AbstractAuthenticationProcessingFilter
    一个基础的用于鉴权的 Filter
    它给出了一个很好的高层鉴权流程的建议,说明了各个部分是如何在一起协同工作的
    是一个基于浏览器、基于 HTTP 的鉴权请求抽象处理器

鉴权机制

  • 用户名和密码
    通过用户名和密码鉴权
  • OAuth 2.0 Login
    依赖 OpenID Connect 的 OAuth 2.0 登录,以及非标准的 OAuth 2.0 Login(如 GitHub)
  • SAML 2.0 Login
    SAML 2.0 登录
  • Central Authentication Server (CAS)
    提供中央鉴权服务器
  • Remember Me
    记住用户过去设置的会话过期时间
  • JAAS Authentication
    通过 JAAS 鉴权
  • OpenID
    OpenID 鉴权,和 OpenID Connect 不是同一个东西
  • Pre-Authentication Scenarios
    通过像 SiteMinder 或 Java EE security 这样外部机制鉴权
    但仍然使用 Spring Security 来授权和防范常见的漏洞
  • X509 Authentication
    X509 鉴权

1. SecurityContextHolder

SecurityContextHolder 是 Spring Security 鉴权模型的核心
通过 SecurityContextHolder.getContext() 来获取 SecurityContext

Spring Security / Servlet Application / 鉴权_第1张图片

Spring Security 用 SecurityContextHolder 来存储鉴权主体的细节
Spring Security 不关心数据是如何存入 SecurityContextHolder
如果其中有值,那么就会被当做当前被鉴权的用户

指定一个被鉴权的用户的最简单的方式,就是直接设置 SecurityContextHolder

例 54. 设置 SecurityContextHolder

SecurityContext context = SecurityContextHolder.createEmptyContext(); (1)
Authentication authentication = new TestingAuthenticationToken("username", "password", "ROLE_USER"); (2)
context.setAuthentication(authentication);

SecurityContextHolder.setContext(context); (3)

(1)创建一个空的 SecurityContext
   这里创建了一个新的 SecurityContext 实例而不是 SecurityContextHolder.getContext().setAuthentication(authentication)
   这样可以避免多线程之间竞争
(2)创建一个新的 Authentication 对象
   Spring Security 不在乎 SecurityContext 中的 Authentication 是什么类型的实现
(3)将 SecurityContext 存入 SecurityContextHolder
   Spring Security 会使用这个信息来授权

如果你想获取授权主体的信息,同样可以访问 SecurityContextHolder

例 55. 获取当前授权用户的相关信息

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

默认情况下,SecurityContextHolder 使用 ThreadLocal 来存储这些细节信息
使用 ThreadLocal,可以在处理完当前主体请求后,安全地清理线程
FilterChainProxy 保证每次会清理 SecurityContext

有一些应用不适合使用 ThreadLocal

例如 Swing 客户端可能会希望所有的 JVM 线程都使用同一个安全上下文
SecurityContextHolder 可以在启动时配置一个 strategy 策略对象
由这个策略对象来决定使用何种方式对上下文进行存储
对于独立应用,可以使用 SecurityContextHolder.MODE_GLOBAL 策略

另一些应用,希望由安全线程创建的所有线程共享同一个安全身份
此时可以使用 SecurityContextHolder.MODE_INHERITABLETHREADLOCAL 策略

可以通过两种方式来改变默认的 SecurityContextHolder.MODE_THREADLOCAL 策略

  1. 设置系统属性(system property)
  2. 调用 SecurityContextHolder 的静态方法
package org.springframework.security.core.context;

public class SecurityContextHolder {
     
   ...
   private static SecurityContextHolderStrategy strategy; // 62
   ...
   public static SecurityContext getContext() {
      // 103
      return strategy.getContext();
   }
   ...
}

2. SecurityContext

SecurityContextHolder 获取 SecurityContext
一个 SecurityContext 对象包含一个 Authentication 对象

3. Authentication

Authentication 有两个主要目的

  1. 作为 AuthenticationManager 的输入,提供用户输入的鉴权凭证(credential)
    在这个场景下,Authentication.isAuthenticated() 返回 false
  2. 代表当前已鉴权的用户
    可以从 SecurityContext 获取当前的 Authentication 对象

可以从 Authentication 获取:

  1. principal(主体)
    标识用户身份
    当使用用户名密码鉴权时,主体通常是 UserDetails 实例
  2. credentials(凭证)
    通常是密码
    大多数情况下,为了防止泄露,在用户完成鉴权后,会清理掉 credentials
  3. authorities(权限)
    GrantedAuthority 表示授予用户的高层权限,例如 roles、scopes
    一个高层权限对应多个低层权限

4. GrantedAuthority

可以通过 Authentication.getAuthorities() 获得 GrantedAuthority
该方法返回后一个 GrantedAuthorityCollection
GrantedAuthority 是高层的权限,通常为 roles 或 scopes
例如:ROLE_ADMINISTRATOR 或者 ROLE_HR_SUPERVISOR
这些角色在之后会被用于 web 授权
Spring Security 的其它部分可以解释这些权限

当使用基于用户名密码的鉴权时,GrantedAuthority 通常靠 UserDetailsService 载入

通常 GrantedAuthority 对象是应用范围的权限
它们不专属于某个特定的领域对象(domain object)
不应该用 GrantedAuthority 来代表专属于第 54 号员工的一个权限
因为如果有非常多这样的权限,很快就会内存溢出(或者在非常少的情况下,会导致应用花很长的时间给用户鉴权)
Spring Security 设计了其它方式来处理这种常见的需求
为此需要使用项目的领域对象安全(domain object security)能力

上边的意思是,当前讨论的权限是针对某一类人的设计,而不是针对每个具体的人
需要针对每个具体的人的情况,需要使用 domain object security 能力

5. AuthenticationManager

一个 SecurityFilterChain 对应一个 AuthenticationManager
一个 AuthenticationManager 对应多个 AuthenticationProvider

AuthenticationManager 是定义了 Spring Security 的 Filter 如何进行鉴权的 API
Spring Security 的 Filter 调用 AuthenticationManager 进行鉴权
并将其返回的 Authentication 设置到 SecurityContextHolder
如果你没有集成 Spring Security 的 Filter
你可以直接设置 SecurityContextHolder 并且不用调用 AuthenticationManager

ProviderManager

ProviderManager 是最常用的 AuthenticationManager 实现

ProviderManager 持有一个 AuthenticationProviderList
ProviderManager 会遍历这个 List
将任务委托给 List 中 AuthenticationProvider.supports(Class) 为 true 的 AuthenticationProvider
即:调用匹配上的 AuthenticationProvider 的 authenticate(Authentication) 方法

如果 AuthenticationProvider.authenticate(Authentication) 抛出 AuthenticationException
则继续循环调用下一个 supports(Class) 匹配上的 AuthenticationProvider 的 authenticate(Authentication)
直到某个 AuthenticationProvider.authenticate(Authentication) 鉴权成功

如果所有的 AuthenticationProvider 都没能鉴权成功
则调用 this.parent.authenticate(Authentication),即:委托给父 AuthenticationManager 进行鉴权
如果父 AuthenticationManager 也没鉴权成功,则抛出上边过程中最后一次鉴权抛出的 AuthenticationException

如果上述整个过程既没鉴权成功,也没抛出过 AuthenticationException
例如:所有的 AuthenticationProvider.supports(Class) 都为 false 且没有父 AuthenticationManager
则抛出 ProviderNotFoundException(一个特殊的 AuthenticationException,表示
ProviderManager 没有被配置支持这个特殊类型的 Authentication

Spring Security / Servlet Application / 鉴权_第2张图片

package org.springframework.security.authentication;

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
     
    // ...
    private List<AuthenticationProvider> providers = Collections.emptyList();
    // ...
    private AuthenticationManager parent; // 100
    // ...
    public List<AuthenticationProvider> getProviders() {
      // 265
        return this.providers;
    }
    // ...
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
      // 165
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        int currentPosition = 0;
        int size = this.providers.size();
        for (AuthenticationProvider provider : getProviders()) {
     
            if (!provider.supports(toTest)) {
     
                continue;
            }
            if (logger.isTraceEnabled()) {
     
                logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
                    provider.getClass().getSimpleName(), ++currentPosition, size));
            }
            try {
     
                result = provider.authenticate(authentication);
                if (result != null) {
     
                    copyDetails(authentication, result);
                    break;
                }
            }
            catch (AccountStatusException | InternalAuthenticationServiceException ex) {
     
                prepareException(ex, authentication);
                // SEC-546: Avoid polling additional providers if auth failure is due to
                // invalid account status
                throw ex;
            }
            catch (AuthenticationException ex) {
     
                lastException = ex;
            }
        }
        if (result == null && this.parent != null) {
     
            // Allow the parent to try.
            try {
     
                parentResult = this.parent.authenticate(authentication);
                result = parentResult;
            }
            catch (ProviderNotFoundException ex) {
     
                // ignore as we will throw below if no other exception occurred prior to
                // calling parent and the parent
                // may throw ProviderNotFound even though a provider in the child already
                // handled the request
            }
            catch (AuthenticationException ex) {
     
                parentException = ex;
                lastException = ex;
            }
        }
        if (result != null) {
     
            if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
     
                // Authentication is complete. Remove credentials and other secret data
                // from authentication
                ((CredentialsContainer) result).eraseCredentials();
            }
            // If the parent AuthenticationManager was attempted and successful then it
            // will publish an AuthenticationSuccessEvent
            // This check prevents a duplicate AuthenticationSuccessEvent if the parent
            // AuthenticationManager already published it
            if (parentResult == null) {
     
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        }

        // Parent was null, or didn't authenticate (or throw an exception).
        if (lastException == null) {
     
            lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
                new Object[] {
      toTest.getName() }, "No AuthenticationProvider found for {0}"));
        }
        // If the parent AuthenticationManager was attempted and failed then it will
        // publish an AbstractAuthenticationFailureEvent
        // This check prevents a duplicate AbstractAuthenticationFailureEvent if the
        // parent AuthenticationManager already published it
        if (parentException == null) {
     
            prepareException(lastException, authentication);
        }
        throw lastException;
    }
    // ...
}

每个 AuthenticationProvider 负责处理某个特定类型的鉴权
例如,某个 AuthenticationProvider 专门用于验证用户名密码的有效性
另一个 AuthenticationProvider 则专门负责 SAML assertion 的认证
这样的设计,可以在只提供一个 AuthenticationManager Bean 的情况下,处理多种不同类型的鉴权
并且各种不同类型鉴权的关注点相互分离,放在各个不同的 AuthenticationProvider

可以为 ProviderManager 配置一个父 AuthenticationManager
没有 AuthenticationProvider 成功鉴权时,会调用父 AuthenticationManager 的鉴权

Spring Security / Servlet Application / 鉴权_第3张图片

多个 ProviderManager 实例可以共享一个相同的父 AuthenticationManager
在使用多个有共同兜底鉴权逻辑的 SecurityFilterChain 时,可以使用这种结构
各个 SecurityFilterChain 持有一个自己的 ProviderManager 为其提供特定的鉴权机制
同时,这些 ProviderManager 又持有同一个父 AuthenticationManager 共享同一套兜底的鉴权策略

Spring Security / Servlet Application / 鉴权_第4张图片

不同类型的 Authentication 由不同类型的 SecurityFilter 创建并放入 AuthenticationManager.authenticate(Authentication)
例如:OAuth2AuthorizationCodeGrantFilter 创建的 OAuth2AuthorizationCodeAuthenticationToken

默认情况下 ProviderManager 会将鉴权成功后返回的 Authentication 对象中的敏感的凭证信息(credentials)清理掉
类似密码这样的信息只是用来鉴权的,鉴权成功后就没有必要再保留,应该从 HttpSession 中清除掉

在一个无状态的应用中,为了提高性能,可能会用到缓存
如果使用了缓存技术,这个默认的清理工作可能会导致问题
如果 Authentication 持有的引用指向的对象被缓存(例如 UserDetails
鉴权成功后 ProviderManager 将它的凭证清理掉了
则无法再次使用这个缓存的对象进行鉴权,因为其中的凭证被删除了
如果用户关闭浏览器后再次登录,或者在别的设备再次登录
在鉴权时,通过用户名获取 UserDetails 时如果使用的是上次的没有凭证的 UserDetails 对象,则会导致鉴权 Bug

当使用缓存时,需要考虑这个问题,解决方案:

  1. 在存入缓存时或者 AuthenticationProvider 返回时,对对象做一个复制(即不要让 ProviderManager 清理到缓存对象)
  2. ProviderManager 的 eraseCredentialsAfterAuthentication 属性设置为 false

第一种方案,在缓存失效前,用户凭证(敏感信息)被保留,增加了安全风险
第二种方案,则是不再清理凭证(敏感信息),更不安全
可以考虑自定义 UserDetails,重写获取凭证 getter 的逻辑,在凭证为 null 时去数据库通过 id 或用户名再查一次
即:只缓存凭证以外的信息,允许 ProviderManager 的清理


既然一个特定类型的 SecurityFilter 对应着一个特定类型的 Authentication
而特定类型的鉴权逻辑 AuthenticationProvider 又由特定类型的 Authentication 决定
为何不直接把不同类型的鉴权逻辑放到对应类型的 SecurityFilter 中呢?
这是为了关注点分离

  • Filter 负责判断请求是否是鉴权请求(POST 且是鉴权 URI)、从请求中获取凭证、鉴权异常处理
  • AuthenticationManager 负责验证凭证的有效性

关注点分离后,除了方便测试外,还有下面这些好处:

  • SecurityFilterAuthenticationProvider 可以脱离一一映射的关系,增加灵活性、重用性
    用不同方式获取用户凭证的 Filter 可以共享同一个验证凭证的 AuthenticationProvider
  • 将不同类型的 AuthenticationProvider 统一放到 AuthenticationManager
    可以设置统一的父 AuthenticationManager
    并且能方便地在多组 AuthenticationProvider 之间共享父 AuthenticationManager
  • ProviderManager 可以抽象出一些统一的工作
    例如:ProviderManager 会在鉴权成功后清理凭证(credentials)
    类似 AuthenticationProvider 的切面,可以让 AuthenticationProvider 更专注入于验证凭证的工作

默认的
ProviderManager 中包含一个 AnonymousAuthenticationProvider
ProviderManager 的父 AuthenticationManager 也是一个 ProviderManager
这个父 ProviderManager 包含一个 DaoAuthenticationProvider

7. AuthenticationProvider

可以向 ProviderManager 注入多个 AuthenticationProvider
每个 AuthenticationProvider 提供特定类型的鉴权,例如:

  • DaoAuthenticationProvider 提供基于用户名密码的鉴权
  • JwtAuthenticationProvider 提供基于 JWT token 的鉴权

8. 通过 AuthenticationEntryPoint 请求凭证

AuthenticationEntryPoint 用于发送向客户端请求凭证的 HTTP 响应

有的时候,客户端在访问资源的请求中就已经直接包含了凭证(例如用户名密码)
这种情况下,Spring Security 不需要发送一个向客户端请求凭证的 HTTP 响应

如果客户端发起一个访问资源的请求,而客户端还没有进行过鉴权
那么就会使用 AuthenticationEntryPoint 的实现,来发送向客户端请求凭证的 HTTP 响应
AuthenticationEntryPoint 的实现可以重定向到登录页,或者返回一个带 WWW-Authenticate 响应头的响应等等

9. AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter 用来作为对用户的凭证进行鉴权的基础的 Filter
Spring Security 先通过 AuthenticationEntryPoint 请求凭证
然后 AbstractAuthenticationProcessingFilter 对凭证进行鉴权

OAuth2LoginAuthenticationFilterUsernamePasswordAuthenticationFilter 继承了该抽象类

Spring Security / Servlet Application / 鉴权_第5张图片

  1. 当用户提交了凭证,AbstractAuthenticationProcessingFilter 会创建一个 Authentication
    Authentication 的具体类型取决于 AbstractAuthenticationProcessingFilter 的实现类
    例如,UsernamePasswordAuthenticationFilter 会用用户名密码创建一个 UsernamePasswordAuthenticationToken
  2. 接着,将这个 Authentication 作为参数,调用 AuthenticationManager.authenticate(Authentication) 进行鉴权
  3. 如果鉴权失败了,则进入失败流程
    • 清理 SecurityContextHolder
    • 调用 RememberMeServices.loginFail(HttpServletRequest, HttpServletResponse)
      如果没有开启 remember me 功能,则什么都不会做
    • 调用 AuthenticationFailureHandler
  4. 如果鉴权成功了,则进入成功流程
    • 通知 SessionAuthenticationStrategy 有一个新的登录
    • 将返回的 Authentication 放入 SecurityContextHolder
      之后 SecurityContextPersistenceFilter 会将 SecurityContext 存入 HttpSession
    • 调用 RememberMeServices.loginSuccess(HttpServletRequest, HttpServletResponse, Authentication)
      如果没有开启 remember me 功能,则什么都不会做
    • ApplicationEventPublisher 发布一个 InteractiveAuthenticationSuccessEvent
    • 调用 AuthenticationSuccessHandler

10. 基于用户名密码的鉴权

最常见的鉴权方式,就是验证用户名密码的有效性
Spring Security 对基于用户名密码的鉴权提供了全面的支持

读取用户名密码

Spring Security 提供了下面这些内置的机制来从 HttpServletRequest 中读取用户名密码:

  • Form Login(表单登录)
  • Basic Authentication(Basic HTTP 鉴权,即:浏览器弹框输入账号密码的形式)
  • Digest Authentication(摘要鉴权,不安全,不推荐使用)

存储机制

每一种读取用户名密码的机制,都能使用下边这些存储机制中的任意一种:

  • 内存存储(In-Memory Authentication)
  • 通过 JDBC 访问关系型数据库(JDBC Authentication)
  • 通过 UserDetailsService 实现自定义存储(UserDetailsService)
  • LDAP 存储(LDAP Authentication)

10.1. Form Login

Spring Security 支持通过 HTML 表单提交用户名密码的形式
下边的图展示了 Spring Security 是如何重定向到登录页的

Spring Security / Servlet Application / 鉴权_第6张图片

  1. 用户访问资源 /private,此时用户还没有登录
  2. Spring Security 的 FilterSecurityInterceptor 抛出 AccessDeniedException 异常
  3. ExceptionTranslationFilter 捕获异常,开始鉴权
    通过配置的 AuthenticationEntryPoint 重定向到登录页
    AuthenticationEntryPoint 常见的实现是 LoginUrlAuthenticationEntryPoint
  4. 浏览器重定向到登录页
  5. 应用渲染并返回登录页

用户提交用户名密码之后,UsernamePasswordAuthenticationFilter 对用户名和密码进行鉴权
UsernamePasswordAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter

Spring Security / Servlet Application / 鉴权_第7张图片

  1. 当用户提交了用户名和密码
    UsernamePasswordAuthenticationFilter 会从 HttpServletRequest 中获取用户名和密码
    然后用获取到的用户名密码,创建一个 UsernamePasswordAuthenticationToken 对象
    这是一个 Authentication 的实现
  2. UsernamePasswordAuthenticationToken 会被传入 AuthenticationManager 进行鉴权
    AuthenticationManager 的工作细节取决于如何存储用户信息
  3. 如果鉴权失败了,则进入失败流程
    • 清理 SecurityContextHolder
    • 调用 RememberMeServices.loginFail(HttpServletRequest, HttpServletResponse)
      如果没有开启 remember me 功能,则什么都不会做
    • 调用 AuthenticationFailureHandler
  4. 如果鉴权成功了,则进入成功流程
    • 通知 SessionAuthenticationStrategy 有一个新的登录
    • 将返回的 Authentication 放入 SecurityContextHolder
    • 调用 RememberMeServices.loginSuccess(HttpServletRequest, HttpServletResponse, Authentication)
      如果没有开启 remember me 功能,则什么都不会做
    • ApplicationEventPublisher 发布一个 InteractiveAuthenticationSuccessEvent
    • 调用 AuthenticationSuccessHandler
      通常是一个 SimpleUrlAuthenticationSuccessHandler
      会重定向到 ExceptionTranslationFilter 在重定向到登录页时缓存的原请求地址

默认情况下,表单登录是开启的
但只要客户端程序员重写了 configure(HttpSecurity http) 方法,默认的表单登录配置就会被覆盖
此时要开启表单登录,需要显式地配置

@Component
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
     
        http.formLogin();
    }
    // ...
}

上边的配置会使用 Spring 提供的登录页,具体的,由 DefaultLoginPageGeneratingFilter 提供
要使用自定义的登录页,需要如下配置

@Component
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
     
        http
            // ...
            .formLogin(formLoginConfigurer -> formLoginConfigurer
            .loginPage("/login")
            .permitAll()
        );
    }
    // ...
}

自定义的登录页,可以使用 Thymeleaf 模板来渲染

DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
    <head>
        <title>Please Log Intitle>
    head>
    <body>
        <h1>Please Log Inh1>
        <div th:if="${param.error}">Invalid username and password.div>
        <div th:if="${param.logout}">You have been logged out.div>
        <form th:action="@{/login}" method="post">
            <div>
                <input type="text" name="username" placeholder="Username"/>
            div>
            <div>
                <input type="password" name="password" placeholder="Password"/>
            div>
            <input type="submit" value="Log in" />
        form>
    body>
html>

默认的 HTML 表单需要满足:

  1. form 的 action 为 /login,method 为 post
  2. 表单需要包含 CSRF Token,Thymeleaf 会自动包含
  3. 用户名字段名称为:username
  4. 密码字段名称为:password
  5. 如果 HTTP 的 parameter 中含有 error 参数,说明之前是登录失败后,重定向到登录页的
  6. 如果 HTTP 的 parameter 中含有 logout 参数,说明之前是成功登出后,重定向到登录页的

如果使用 Spring MVC,可以用 Controller 来作为登录页的入口

@Controller
class LoginController {
     
    
    @GetMapping("/login")
    String login() {
     
        return "login";
    }
    
}

10.2. Basic Authentication

RFC 7617:Basic HTTP Authentication

Spring Security 为基于 Servlet 的应用提供了对 Basic HTTP Authentication 的支持

向客户请求凭证时
返回 WWW-Authenticate 响应头
实现了 Basic HTTP Authentication 协议的浏览器会弹出用户名密码表单

Spring Security / Servlet Application / 鉴权_第8张图片

  1. 用户发起未鉴权授权的请求 /private
  2. FilterSecurityInterceptor 抛出 AccessDeniedException
  3. ExceptionTranslationFilter 捕获异常,发现是未鉴权用户,开始请求用户凭证
    根据配置,调用 BasicAuthenticationEntryPoint 的鉴权入口方法
    该方法会返回带有 WWW-Authenticate 响应头的响应
    RequestCache 一般会缓存一个 NullRequestCache,即鉴权成功后不会重放请求
    因为实现了 Basic HTTP Authentication 协议的浏览器会缓存请求信息
    浏览器以弹框形式让用户填写用户名密码
    用户点击确认提交时,浏览器会直接重放鉴权之前的请求,并带上用户名和密码

用户代理(如浏览器)收到带有 WWW-Authenticate 响应头的响应后弹出弹框让用户登录
用户提交用户名密码后,Spring Security 处理逻辑如下图

Spring Security / Servlet Application / 鉴权_第9张图片

  1. 当用户提交了用户名和密码
    BasicAuthenticationFilter 会从 HttpServletRequest 中获取用户名和密码
    然后用获取到的用户名密码,创建一个 UsernamePasswordAuthenticationToken 对象
    这是一个 Authentication 的实现
  2. UsernamePasswordAuthenticationToken 会被传入 AuthenticationManager 进行鉴权
    AuthenticationManager 的工作细节取决于如何存储用户信息
  3. 如果鉴权失败了,则进入失败流程
    • 清理 SecurityContextHolder
    • 调用 RememberMeServices.loginFail(HttpServletRequest, HttpServletResponse)
      如果没有开启 remember me 功能,则什么都不会做
    • 调用 AuthenticationEntryPoint 再次返回带有 WWW-Authenticate 响应头的响应
  4. 如果鉴权成功了,则进入成功流程
    • 将返回的 Authentication 放入 SecurityContextHolder
    • 调用 RememberMeServices.loginSuccess(HttpServletRequest, HttpServletResponse, Authentication)
      如果没有开启 remember me 功能,则什么都不会做
    • BasicAuthenticationFilter 调用 FilterChain.doFilter(request,response)
      继续执行剩下的应用逻辑

Basic HTTP Authentication
第一次未鉴权访返回 WWW-Authenticate 响应头时,就已经设置了 Cookie:JSESSIONID=…
也就是说鉴权以前就已经创建了 Session
提交用户名密码会直接重放之前的请求并携带用户名密码(用户名密码加密后放在 Authorization 请求头中)
后端鉴权成功后把用户信息放入之前创建的 Session 即可

默认情况下,Basic HTTP Authentication 是开启的
但只要客户端程序员重写了 configure(HttpSecurity http) 方法,默认配置就会被覆盖
此时要开启 Basic HTTP Authentication,需要显式地配置

@Component
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
     
        http
            // ...
            .httpBasic(withDefaults());
    }
    // ...
}

10.3. Digest Authentication

RFC 2617:Digest Access Authentication

DigestAuthenticationFilter 支持 Digest Access Authentication

Digest Access Authentication 不安全,不推荐使用

10.4. In-Memory Authentication

通过用户名密码鉴权的逻辑:
通过用户名查询数据库中的用户记录,然后比较用户提交的密码与数据库保存的密码是否相同

通过用户名密码进行鉴权的 AuthenticationProvider 默认是 DaoAuthenticationProvider
DaoAuthenticationProvider 通过用户名查询用户记录是通过调用 UserDetailsService.loadUserByUsername(String username)

要想使用注册在内存中的用户信息,只需要创建一个 InMemoryUserDetailsManager 类型的 Bean

InMemoryUserDetailsManager 实现了 UserDetailsService
InMemoryUserDetailsManager 会从内存中查询用户记录
InMemoryUserDetailsManager 还实现了 UserDetailsManager 接口,用于管理 UserDetails
当配置为用户名密码鉴权时,Spring Security 会基于 UserDetails 进行鉴权

如果没有配置自己的 UserDetailsService Bean,默认使用 InMemoryUserDetailsManager

下边的例子使用 User.builder() 来构建 UserDetails
使用 Spring Boot CLI 加密密码
具体的:
默认使用 DelegatingPasswordEncoder 来解密密码
{bcrypt} 大括号中的 bcrypt 用于指定真正用于解码的解码器类型,除去 {bcrypt} 剩下的部分就是密文
bcrypt 对应的解码器是:BCryptPasswordEncoder
下边密码的密文用 BCryptPasswordEncoder 解码后的明文就是:“password”

@Configuration
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    @Bean
    public UserDetailsService users() {
     
        UserDetails user = User.builder()
            .username("user")
            .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
            .roles("USER")
            .build();
        UserDetails admin = User.builder()
            .username("admin")
            .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
            .roles("USER", "ADMIN")
            .build();
        return new InMemoryUserDetailsManager(user, admin);
    }
    // ...
}

下边的例子使用 User.withDefaultPasswordEncoder() 来构建 UserDetails,使用默认的方式加密密码
因为密码的明文就在代码的字面量中,因此可以很容易地通过反编译看到密码,不安全,不应该在生产中使用

@Configuration
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    @Bean
    public UserDetailsService users() {
     
        // The builder will ensure the passwords are encoded before saving in memory
        UserBuilder users = User.withDefaultPasswordEncoder();
        UserDetails user = users
            .username("user")
            .password("password")
            .roles("USER")
            .build();
        UserDetails admin = users
            .username("admin")
            .password("password")
            .roles("USER", "ADMIN")
            .build();
        return new InMemoryUserDetailsManager(user, admin);
    }
    // ...
}

10.5. JDBC Authentication

Spring Security 的 JdbcDaoImpl 实现了 UserDetailsService 接口
支持基于用户名密码的鉴权,通过 JDBC 注册和查询用户记录
JdbcUserDetailsManager 继承了 JdbcDaoImpl
JdbcUserDetailsManager 还实现了 UserDetailsManager 接口,用于管理 UserDetails
当配置为用户名密码鉴权时,Spring Security 会基于 UserDetails 进行鉴权

默认的 Schema(数据库表结构)

Spring Security 为基于 JDBC 的鉴权提供了默认的请求模式
只要数据库按照要求的方式建表,就能正确 JdbcUserDetailsManager 就能正确的工作
示例为 H2 数据库的 SQL,可以根据不同的数据库类型调整为对应的方言

Spring Security 的类路径下提供了建表语句:org/springframework/security/core/userdetails/jdbc/users.ddl

create table users(
    username varchar_ignorecase(50) not null primary key,
    password varchar_ignorecase(500) not null,
    enabled boolean not null
);

create table authorities (
    username varchar_ignorecase(50) not null,
    authority varchar_ignorecase(50) not null,
    constraint fk_authorities_users foreign key(username) references users(username)
);

create unique index ix_auth_username on authorities (username,authority);

如果应用中有分组

create table groups (
    id bigint generated by default as identity(start with 0) primary key,
    group_name varchar_ignorecase(50) not null
);

create table group_authorities (
    group_id bigint not null,
    authority varchar(50) not null,
    constraint fk_group_authorities_group foreign key(group_id) references groups(id)
);

create table group_members (
    id bigint generated by default as identity(start with 0) primary key,
    username varchar(50) not null,
    group_id bigint not null,
    constraint fk_group_members_group foreign key(group_id) references groups(id)
);

配置数据源,JDBC 会使用数据源来访问具体的数据库

@Configuration
public class DataSourceConfig {
     

    /**
     * 配置数据源 DataSource Bean
     * EmbeddedDatabaseBuilder 是 Spring 内置的数据库,所以不需要配置连接信息
     * addScript() 中的 .ddl 文件是 Spring Security 提供的,用来创建鉴权用到的最基本的表
     *
     * @return DataSource
     */
    @Bean
    DataSource dataSource() {
     
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .addScript("classpath:org/springframework/security/core/userdetails/jdbc/users.ddl")
            .build();
    }

}

配置 JdbcUserDetailsManager

/**
 * 通过 JDBC 注册用户
 */
@Configuration
public class A05_JDBCConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    /**
     * JdbcUserDetailsManager 继承了 JdbcDaoImpl
     * JdbcDaoImpl 实现了 UserDetailsService
     * JdbcDaoImpl 会通过 JDBC 查询用户记录,JDBC 使用 DataSource Bean 来连接数据源
     *
     * JdbcUserDetailsManager 还实现了 UserDetailsManager 接口,用于管理 UserDetails
     * 当配置为用户名密码鉴权时,Spring Security 会基于 UserDetails 进行鉴权
     *
     * @return UserDetailsService
     */
    @Bean
    UserDetailsManager users(DataSource dataSource) {
     
        UserDetails user = User.builder()
            .username("user")
            .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
            .roles("USER")
            .build();
        UserDetails admin = User.builder()
            .username("admin")
            .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
            .roles("USER", "ADMIN")
            .build();
        JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
        users.createUser(user);
        users.createUser(admin);
        return users;
    }
    // ...
}

10.6. UserDetails

UserDetailsService 通过用户名查询用户记录,返回 UserDetails
DaoAuthenticationProvider 会验证 UserDetails 的有效性,然后返回一个 Authentication
Authentication 的 principal(主体)就是之前的 UserDetails

10.7. UserDetailsService

DaoAuthenticationProvider 使用 UserDetailsService 来检索用户名密码以及其它鉴权需要的属性
Spring Security 提供了 UserDetailsService 的 in-memory 和 JDBC 实现

可以自定义 UserDetailsService Bean

由于上述鉴权逻辑是 DaoAuthenticationProvider 提供的
如果配置了 AuthenticationManagerBuilder 或者 AuthenticationProviderBean
自定义的 AuthenticationManager 或者 AuthenticationProvider 就会替代原来的组件
自定义的 UserDetailsService 是否被使用,就取决于自定义的 AuthenticationManager 是否用到它了

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
     
    // ...
    @Bean
    UserDetailsService userDetailsService() {
     
        return new CustomUserDetailsService();
    }
    // ...
}

10.8. PasswordEncoder

Spring Security 的 Servlet 支持通过集成 PasswordEncoder 安全地存储密码
可以自定义 PasswordEncoder Bean
DaoAuthenticationProvider 在验证用户名密码有效性时,会使用
PasswordEncoder.matches(CharSequence rawPassword, String encodedPassword)

package org.springframework.security.authentication.dao;

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
     
    // ...
    private PasswordEncoder passwordEncoder; // 48
    // ...
    @Override
    @SuppressWarnings("deprecation")
    protected void additionalAuthenticationChecks(UserDetails userDetails,
        UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
      // 69
        if (authentication.getCredentials() == null) {
     
            this.logger.debug("Failed to authenticate since no credentials provided");
            throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
        String presentedPassword = authentication.getCredentials().toString();
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
     
            this.logger.debug("Failed to authenticate since password does not match stored value");
            throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
    // ...
}

10.9. DaoAuthenticationProvider

DaoAuthenticationProvider 是一个 AuthenticationProvider 的实现
它利用 UserDetailsServicePasswordEncoder 来进行用户名密码鉴权

Spring Security / Servlet Application / 鉴权_第10张图片

  1. 读取用户名和密码的鉴权 Filter 把用户名密码放到 UsernamePasswordAuthenticationToken 中传递给 AuthenticationManager
    AuthenticationManager 的实现是 ProviderManager
  2. ProviderManager 被配置为使用 DaoAuthenticationProvider 来鉴权
  3. DaoAuthenticationProvider 找到 UserDetailsService Bean
    UserDetailsService.loadUserByUsername() 获取 UserDetails
  4. DaoAuthenticationProvider 使用 PasswordEncoder Bean 来验证上一步返回的 UserDetails 中密码的有效性
  5. 如果鉴权成功,将返回一个 UsernamePasswordAuthenticationToken 类型的 Authentication
    其中持有的 principal 就是之前得到的 UserDetails
    这个 UsernamePasswordAuthenticationToken 将被鉴权 Filter 放入 SecurityContextHolder

10.10. LDAP Authentication

LDAP(Lightweight Directory Access Protocol)
轻型目录访问协议
是一项基于目录树结构的数据存储技术,类似 Zookeeper 的树结构存储
只是 Zookeeper 的强项是保证分布式环境下的有序性,存储的只是配置信息,并不建议被当做数据库使用
而 LDAP 是可以当数据库存储大量数据的
只是 LDAP 并不支持事务等数据库特性,适合用于查询频率远远大于增删改频率的场景
如果要存储的数据结构适合用树结构来表示,那么使用 LDAP 的查询效率是很高的

LDAP 服务自己来管理数据的存取,要使用 LDAP 的应用只需要和 LDAP 服务通信就行

一个企业的组织架构、人员职位特别适合树形结构
而这些又是用户体系的一部分,因此必然又是和权限控制绑定在一起的
因此 LDAP 常常被组织用作用户信息、角色信息的中心仓库和鉴权服务(即:用户权限数据库 + 鉴权授权服务)

Spring Security 在被配置为通过用户名密码鉴权时,支持基于 LDAP 的鉴权
尽管 LDAP 的鉴权是基于用户名密码的,但并没有使用 UserDetailsService
因为在使用 LDAP Bind Operation 时,并不会返回密码,因此应用无法自己校验密码的有效性

由于 LDAP 服务可以根据许多不同的场景做许多不同的配置
Spring Security 的 LDAP provider 被设计为完全可配置的
Spring Security 为鉴权和角色获取设计了各自独立的策略接口,并且提供了默认实现
通过配置默认实现可以满足大多数的应用场景

11. 会话管理

SessionManagementFilterSessionAuthenticationStrategy 来完成和 HTTP 会话相关的功能
SessionManagementFilter 会将会话操作委托给自己持有的 SessionAuthenticationStrategy

常见的功能包括:会话超时检测、会话固定 攻击防护、限制一个已鉴权用户可以同时拥有多少个并发会话

前置知识:

SecurityContextRepository 负责在 Web 请求之间持久化 SecurityContext(通常对应会话域)
SecurityContextRepository 的默认实现是 HttpSessionSecurityContextRepository
Servlet 容器把 HttpServletRequest 对象通过 DelegatingFilterProxy 传递给 Spring Security Bean
HttpSessionSecurityContextRepositoryHttpServletRequest.getSession() 返回的 HttpSession 来持久化 SecurityContext

Spring Security 流程中,一般用 SecurityContextHolder 存取 SecurityContext
SecurityContextHolder 会根据配置使用 SecurityContextHolderStrategy 来存取 SecurityContext
多数时候 SecurityContextHolderStrategy 的实现是 ThreadLocalSecurityContextHolderStrategy
ThreadLocalSecurityContextHolderStrategy 是用 ThreadLocal 来存取 SecurityContext
注意:这里存取的 SecurityContextSecurityContextRepository 持久化的是同一个对象

综上所述,SecurityContextHolderThreadLocal 线程范围内存取 SecurityContext
而 Servlet 容器会为每个请求分配一个线程,因此 SecurityContextHolder 本质上是在请求域维护 SecurityContext
SecurityContextRepository 则是在会话域维护 SecurityContext

请求首先会经过 SecurityContextPersistenceFilter
在其 doFilter() 中,就会通过 SecurityContextRepositoryHttpSession 中载入会话域的 SecurityContext
如果会话域中没有,会新建 SecurityContext
然后把得到的 SecurityContext 通过 SecurityContextHolder.setContext() 放到请求域中
之后就通过 SecurityContextHolder 来获取 SecurityContext

SecurityContextPersistenceFilter 的 finally 语句块中,最终会清理 SecurityContextHolder
然后将本次请求域的 HttpSession 通过 SecurityContextRepository 存入会话域

请求会按顺序经过:

  1. SecurityContextPersistenceFilter
  2. LogoutFilter
  3. UsernamePasswordAuthenticationFilter
  4. SessionManagementFilter
  5. ExceptionTranslationFilter
  6. FilterSecurityInterceptor

SessionManagementFilter 的工作:

  1. 判断 SecurityContextRepository 是否能通过 HttpServletRequest 找到 SecurityContext
    如果能找到则放过,否则继续下面的步骤
  2. 如果当前是提交用户凭证的鉴权请求,并且鉴权成功了
    则将 SecurityContext 通过 SecurityContextRepository 立即持久化
    虽然最终 SecurityContextPersistenceFilter 也会做这个持久化工作
    但是在 SessionManagementFilter 中确认鉴权成功后第一时间持久化
    可以保证在当前请求还没结束前,又来了新的同一会话的请求时,可以正确地从会话中拿到 SecurityContext
  3. 通过 HttpServletRequest 的 API 检查会话是否已超时
    如果已超时则调用 InvalidSessionStrategy.onInvalidSessionDetected() 处理
    例如,在请求刚进入时 Session 还在,请求到 SessionManagementFilter 时 Session 已超时
    但是由于请求未结束,所以 HttpSession 还未被删除,里边的内容也还在,但是已过期
  4. 在第 2 步中,鉴权成功后,提供一个插件位置
    具体的,鉴权成功后会调用:SessionAuthenticationStrategy.onAuthentication()
    可以通过实现 SessionAuthenticationStrategy 来插入一些与 HTTP Session 相关的额外的逻辑
    例如:确认会话对象是否存在且可用、通过修改 Session ID 来防止 会话固定 攻击

准确的说,应该把 SecurityContext 的存储分为【当前域】和【持久化域】
【当前域】用 SecurityContextHolder 存取
【持久化域】用 SecurityContextRepository 存取
只是,一般的,【当前域】的 SecurityContextHolder 的策略一般为存入 ThreadLocal 对应 Servlet 的请求域
【持久化域】的 SecurityContextRepository 一般存入 HttpSession 对应 Servlet 的会话域

注意:
SessionManagementFilter 是一个保险,但不一定会用上
例如 AbstractAuthenticationProcessingFilter 中,如果是鉴权请求,无论是否鉴权成功,都是不会调用 FilterChain.doFilter()
因此位于过滤链后边的 SessionManagementFilter 不会被调用,会直接回到 SecurityContextPersistenceFilter 的 finally 语句块中
SecurityContextPersistenceFilter 的 finally 语句块中就会把 SecurityContext 放入 SecurityContextRepository
之后用户再次请求时,由于 SecurityContextRepository 中能取到 SecurityContextSessionManagementFilter 会直接放过

OAuth2LoginAuthenticationFilterUsernamePasswordAuthenticationFilter 都继承了 AbstractAuthenticationProcessingFilter
在使用这些 Filter 后,大多数时候 SessionManagementFilter 什么都没做

由于 AbstractAuthenticationProcessingFilter 中也调用了 SessionAuthenticationStrategy.onAuthentication()
因此此时配置的 SessionAuthenticationStrategy 仍然会起作用
默认的 SessionAuthenticationStrategyCompositeSessionAuthenticationStrategy
它会遍历一个 List 并挨个调用这些元素的 onAuthentication() 方法
默认的,列表中有一个 AbstractSessionFixationProtectionStrategy 的匿名对象
它的作用是防止 “ 会话固定 ” 攻击,如果在鉴权之前就存在 Session,会刷新 Session ID,让之前的 Session ID 失效

11.1 超时检测

参考:Security Namespace Configuration

默认情况下

  • SessionManagementFilter 持有的 SessionAuthenticationStrategyCompositeSessionAuthenticationStrategy
  • SessionManagementFilter 持有的 InvalidSessionStrategy 为 null

11.2 并发会话控制

11.3 “ 会话固定 ” 攻击防护

会话固定

11.4 SessionManagementFilter

11.5 SessionAuthenticationStrategy

11.6 并发控制

Spring Boot

Spring Boot 自动配置

  • 创建一个 Servlet Filter 对象作为 Bean 放入 Spring 容器中
    Bean 名称为:springSecurityFilterChain
    这个 Bean 负责为应用提供所有的安全能力
    如:保护应用的 URL、验证提交的用户名密码的有效性、重定向到登录表单等等
  • 创建一个 UserDetailsService Bean
    内置一个用户,用户名为 user,并为其生成一个随机密码,会打印在启动日志中
  • 将容器中名为 springSecurityFilterChain 的 Bean 注册为 Servlet 容器的 Filter
    将这个 Filter 配置为匹配所有的请求 URL

上一篇:Spring Security / Servlet Application / 大图景

样例代码:Spring Security Sample

你可能感兴趣的:(Java,Spring,java,spring,安全)