欢迎光临
我们一直在努力

Spring Security实战:6 实现身份验证

  • 使用自定义身份验证提供程序实现身份验证逻辑
  • 使用 HTTP 基本和基于表单的登录身份验证方法
  • 了解和管理安全上下文组件

在第 3 章和第 4 章中,我们介绍了在身份验证流中起作用的一些组件。我们讨论了UserDetails以及如何定义原型来描述Spring Security中的用户。然后,我们在示例中使用了UserDetails,在这些示例中,您了解了UserDetailsService和UserDetailsManager合约的工作原理以及如何实现这些合约。 我们还在示例中讨论并使用了这些接口的主要实现。最后,您学习了密码编码器如何管理密码以及如何使用密码,以及Spring Security加密模块(SSCM)及其加密器和密钥生成器。

但是,身份验证提供程序层是负责身份验证逻辑的层。在身份验证提供程序中,您可以找到决定是否对请求进行身份验证的条件和说明。将此责任委托给身份验证提供程序的组件是 AuthenticationManager ,它接收来自 HTTP 过滤器层的请求,我们在第 5 章中讨论过。在本章中,让我们看一下身份验证过程,它只有两个可能的结果:

  • “慈善”发出请求的实体未经过身份验证。无法识别用户,并且应用程序拒绝请求而不委派给授权过程。通常,在这种情况下,发送回客户端的响应状态为“HTTP 401 未经授权”。
  • “慈善”发出请求的实体已经过身份验证。存储有关请求者的详细信息,以便应用程序可以使用这些详细信息进行授权。正如您将在本章中发现的那样, SecurityContext 负责有关当前经过身份验证的请求的详细信息。

为了提醒您参与者以及它们之间的联系,图 6.1 提供了您在第 2 章中看到的图表。

本章介绍身份验证流程的其余部分(图 6.1 中的阴影框)。然后,在第 7 章和第 8 章中,您将了解授权的工作原理,即在 HTTP 请求中进行身份验证之后的过程。首先,我们需要讨论如何实现 AuthenticationProvider 接口。您需要知道 Spring 安全性如何理解身份验证过程中的请求。

为了清楚地说明如何表示身份验证请求,我们将从身份验证接口开始。讨论这个问题后,我们可以进一步观察成功身份验证后请求的详细信息会发生什么。身份验证成功后,我们可以讨论 SecurityContext 接口以及 Spring Security 管理它的方式。在本章即将结束时,您将学习如何自定义 HTTP 基本身份验证方法。我们还将讨论可以在应用程序中使用的另一个身份验证选项 — 基于表单的登录。

在企业应用程序中,您可能会发现自己处于基于用户名和密码的身份验证的默认实现不适用的情况。此外,在身份验证方面,您的应用程序可能需要实现多个方案(图 6.2)。例如,您可能希望用户能够通过使用 SMS 消息中接收或由特定应用程序显示的代码来证明他们是谁。或者,您可能需要实现身份验证方案,其中用户必须提供存储在文件中的某种密钥。您甚至可能需要使用用户指纹的表示形式来实现身份验证逻辑。框架的目的是足够灵活,以允许您实现这些必需方案中的任何一个。

框架通常提供一组最常用的实现,但它当然不能涵盖所有可能的选项。在 Spring 安全性方面,您可以使用 AuthenticationProvider 合约来定义任何自定义身份验证逻辑。在本节中,您将了解如何通过实现身份验证接口,然后使用 AuthenticationProvider 创建自定义身份验证逻辑来表示身份验证事件。实现我们的目标

  • 在第 6.1.1 节中,我们分析了 Spring 安全性如何表示身份验证事件。
  • 在第 6.1.2 节中,我们讨论了负责身份验证逻辑的 AuthenticationProvider 合约。
  • 在第 6.1.3 节中,您将通过实现示例中的 AuthenticationProvider 协定来编写自定义身份验证逻辑。

在本节中,我们将讨论 Spring 安全性如何在身份验证过程中理解请求。在深入实现自定义身份验证逻辑之前,请务必了解这一点。正如您将在第 6.1.2 节中了解到的那样,要实现自定义身份验证提供程序,您首先需要了解如何描述身份验证事件本身。在本节中,我们将查看表示身份验证的协定,并讨论您需要了解的方法。

身份验证是具有相同名称的进程中涉及的基本接口之一。身份验证接口表示身份验证请求事件,并保存请求访问应用程序的实体的详细信息。您可以在身份验证过程中和之后使用与身份验证请求事件相关的信息。请求访问应用程序的用户称为“慈善”主体。如果您曾经在任何应用程序中使用过 Java 安全性,您就会了解到,在 Java 安全性中,名为 Principal 的接口表示相同的概念。Spring 安全性的身份验证接口扩展了此合约(图 6.3)。

Spring 安全性中的身份验证协定不仅表示主体,还添加有关身份验证过程是否完成的信息,以及权限集合。事实上,这个契约旨在从Java安全扩展主契约,这在与其他框架和应用程序的实现的兼容性方面是一个加分项。这种灵活性允许从以另一种方式实现身份验证的应用程序更轻松地迁移到 Spring 安全性。

让我们在下面的列表中了解有关身份验证接口设计的更多信息。

public interface Authentication extends Principal, Serializable {

  Collection<? extends GrantedAuthority> getAuthorities();
  Object getCredentials();
  Object getDetails();
  Object getPrincipal();
  boolean isAuthenticated();
  void setAuthenticated(boolean isAuthenticated) 
     throws IllegalArgumentException;
}

目前,您需要学习的此合约的唯一方法是:

  • isAuthenticated() — 如果身份验证过程结束,则返回 true;如果身份验证过程仍在进行中,则返回 false。
  • getCredentials() — 返回身份验证过程中使用的密码或任何密钥。
  • getAuthority() — 返回已验证请求的已授予权限的集合。

我们将在后面的章节中讨论身份验证协定的其他方法,这些方法适用于我们随后看到的实现。

在本节中,我们将讨论实现自定义身份验证逻辑。我们分析与此责任相关的 Spring 安全合同以了解其定义。有了这些详细信息,您将使用第 6.1.3 节中的代码示例实现自定义身份验证逻辑。

Spring Security 中的 AuthenticationProvider 负责身份验证逻辑。 AuthenticationProvider 接口的默认实现将查找系统用户的责任委托给 UserDetailsService。它还使用密码编码器在身份验证过程中进行密码管理。下面的清单给出了身份验证提供程序的定义,您需要实现该定义才能为应用程序定义自定义身份验证提供程序。

public interface AuthenticationProvider {

  Authentication authenticate(Authentication authentication) 
    throws AuthenticationException;

  boolean supports(Class<?> authentication);
}

身份验证提供程序职责与身份验证协定紧密耦合。 authenticate() 方法接收身份验证对象作为参数并返回身份验证对象。我们实现 authenticate() 方法来定义身份验证逻辑。我们可以用三个项目符号快速总结您应该实现 authenticate() 方法的方式:

  • 如果身份验证失败,该方法应引发身份验证异常。
  • 如果该方法收到的身份验证对象不受身份验证提供程序 的实现支持,则该方法应返回 null。这样,我们就可以使用在HTTP过滤器级别分离的多种身份验证类型。
  • 该方法应返回表示完全身份验证对象的身份验证实例。对于此实例,isAuthenticated() 方法返回 true,它包含有关经过身份验证的实体的所有必要详细信息。通常,应用程序还会从此实例中删除敏感数据,例如密码。身份验证成功后,不再需要密码,保留这些详细信息可能会将它们暴露给不必要的眼睛。

AuthenticationProvider 接口中的第二种方法是 supports(Class<?> 身份验证)。 如果当前身份验证提供程序支持作为身份验证对象提供的类型,则可以实现此方法以返回 true。请注意,即使此方法为对象返回 true,authenticate() 方法仍有可能通过返回 null 来拒绝请求。Spring 安全性的设计更加灵活,并允许您实现一个身份验证提供程序,该提供程序可以根据请求的详细信息拒绝身份验证请求,而不仅仅是通过其类型。

身份验证管理器和身份验证提供程序如何协同工作以验证或使身份验证请求无效的类比是为您的门提供更复杂的锁。您可以使用卡或老式物理钥匙打开此锁(图 6.4)。锁本身是决定是否打开门的身份验证管理器。为了做出该决定,它委托给两个身份验证提供程序:一个知道如何验证卡,另一个知道如何验证物理密钥。如果您出示一张卡来开门,则仅使用物理密钥的身份验证提供程序会抱怨它不知道这种身份验证。但其他提供商支持这种身份验证,并验证卡是否对门有效。这实际上是 – supports() 方法的目的。

除了测试身份验证类型外,Spring 安全性还增加了一层灵活性。门锁可以识别多种卡片。在这种情况下,当您出示卡时,其中一个身份验证提供商可能会说:“我将其理解为卡。但这不是我可以验证的卡类型!当 supports() 返回 true 但 authenticate() 返回 null 时,就会发生这种情况。

图 6.5 显示了其中一个 AuthenticationProvider 对象识别身份验证但确定其无效的替代方案。在这种情况下,结果将是一个身份验证异常,最终在 Web 应用的 HTTP 响应中显示为 401 未授权 HTTP 状态。

在本节中,我们将实现自定义身份验证逻辑。您可以在项目 ssia-ch6-ex1 中找到此示例。通过此示例,您将应用您在第 6.1.1 节和 6.1.2 节中学到的有关身份验证和身份验证提供程序接口的知识。在清单 6.3 和 6.4 中,我们逐步构建了一个如何实现自定义身份验证提供程序的示例。这些步骤也如图6.5所示,如下:

  1. 声明实现身份验证提供程序协定的类。
  2. 确定新的身份验证提供程序支持哪些类型的身份验证对象:
  3. 实现 supports(Class<?> c) 方法,以指定我们定义的 AuthenticationProvider 支持哪种类型的身份验证。
  4. 实现身份验证(身份验证 a) 方法来实现身份验证逻辑。
  5. 向 Spring Security 注册新的 AuthenticationProvider 实现的实例。
@Component
public class CustomAuthenticationProvider 
  implements AuthenticationProvider {

  // Omitted code

  @Override
  public boolean supports(Class<?> authenticationType) {
    return authenticationType
            .equals(UsernamePasswordAuthenticationToken.class);
  }
}

在清单 6.3 中,我们定义了一个实现 AuthenticationProvider 接口的新类。我们用@Component标记该类,以便在 Spring 管理的上下文中拥有一个其类型的实例。然后,我们必须决定此身份验证提供程序支持哪种身份验证 接口实现。这取决于我们希望作为 authenticate() 方法的参数提供的类型。如果我们没有在身份验证过滤器级别自定义任何内容(如第 5 章所述),则类
UsernamePasswordAuthenticationToken 定义类型。此类是身份验证接口的实现,表示具有用户名和密码的标准身份验证请求。

通过这个定义,我们使身份验证提供程序支持特定类型的密钥。指定 AuthenticationProvider 的范围后,我们通过重写 authenticate() 方法来实现身份验证逻辑,如以下列表所示。

@Component
public class CustomAuthenticationProvider 
  implements AuthenticationProvider {

  private final UserDetailsService userDetailsService;
  private final PasswordEncoder passwordEncoder;

  // Omitted constructor

  @Override
  public Authentication authenticate(Authentication authentication) {
    String username = authentication.getName();
    String password = authentication.getCredentials().toString();

    UserDetails u = userDetailsService.loadUserByUsername(username);

    if (passwordEncoder.matches(password, u.getPassword())) {
      return new UsernamePasswordAuthenticationToken(
            username, 
            password, 
            u.getAuthorities());     #A
    } else {
      throw new BadCredentialsException
                  ("Something went wrong!");    #B
    }
  }

  // Omitted code
}

清单 6.4 中的逻辑很简单,图 6.6 直观地显示了这个逻辑。我们利用UserDetailsService实现来获取UserDetails。如果用户不存在,则 loadUserByUsername() 方法应抛出 AuthenticationException 。在这种情况下,身份验证过程将停止,HTTP 筛选器将响应状态设置为 HTTP 401 未经授权。如果用户名存在,我们可以从上下文中使用 PasswordEncoder 的 matches() 方法进一步检查用户的密码。如果密码不匹配,则应再次抛出身份验证异常。如果密码正确,则身份验证提供程序将返回标记为“已验证”的身份验证实例,其中包含有关请求的详细信息。

为了插入 AuthenticationProvider 的新实现,我们定义了一个 SecurityFilterChain bean。下面的清单对此进行了演示。

@Configuration
public class ProjectConfig {

  private final AuthenticationProvider authenticationProvider;

  // Omitted constructor

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) 
    throws Exception {

    http.httpBasic(Customizer.withDefaults());

    http.authenticationProvider(authenticationProvider);

    http.authorizeHttpRequests(c -> c.anyRequest().authenticated());

    return http.build();  
  }

  // Omitted code
}

在清单 6.5 中,我将依赖注入与使用 AuthenticationProvider 接口声明的字段一起使用。Spring 将 AuthenticationProvider 识别为一个接口(这是一个抽象)。但是Spring知道它需要在其上下文中找到该特定接口的实现实例。在我们的例子中,实现是 自定义身份验证提供程序 ,这是我们使用 @Component 注释声明并添加到 Spring 上下文中的唯一此类实例。要复习依赖注入,我建议您阅读我写的另一本书《春天从这里开始》(Manning,2021 年)。

就是这样!您已成功自定义身份验证提供程序的实现。现在,您可以根据需要为应用程序自定义身份验证逻辑。

错误地应用框架会导致应用程序可维护性降低。更糟糕的是,有时那些未能使用框架的人认为这是框架的错。我给你讲个故事。

有一年冬天,一家公司的开发主管打电话给我,帮助他们实现一项新功能。他们需要在早期使用 Spring 开发的系统组件中应用自定义身份验证方法。不幸的是,在实现应用程序的类设计时,开发人员没有正确依赖Spring Security的主干架构。

他们只依赖于过滤器链,将Spring Security的整个功能作为自定义代码重新实现。

开发人员观察到,随着时间的推移,自定义变得越来越困难。但是没有人采取行动来重新设计组件,以便按照Spring Security中的预期使用合约。大部分困难来自于不了解Spring的能力。其中一位首席开发人员说:“这只是这个Spring Security的错!这个框架很难应用,也很难与任何定制一起使用。我对他的观察感到有些震惊。我知道 Spring Security 有时很难理解,而且该框架以没有软学习曲线而闻名。但是我从来没有遇到过找不到一种方法来设计一个带有 Spring Security 的易于自定义的类的情况!

我们一起调查,我意识到应用程序开发人员只使用了Spring Security提供的10%。然后,我介绍了一个为期两天的关于 Spring Security 的研讨会,重点介绍了我们可以为他们需要更改的特定系统组件做些什么(以及如何做)。

一切都以完全重写大量自定义代码以正确依赖 Spring 安全性而告终,从而使应用程序更容易扩展以满足他们对安全实现的关注。我们还发现了一些与Spring Security无关的其他问题,但那是另一回事了。

从这个故事中可以学到一些教训:

? 一个框架,尤其是一个广泛用于应用程序的框架,是由许多聪明的人参与编写的。即便如此,也很难相信它可以很好地实施。在得出任何问题都是框架的错误之前,请始终分析您的应用程序。

? 在决定使用框架时,请确保您至少很好地理解了它的基础知识。

? 注意您用来了解框架的资源。有时,您在 Web 上找到的文章会向您展示如何执行快速解决方法,而不一定介绍如何正确实现类设计。

? 在您的研究中使用多个来源。为了澄清您的误解,请在不确定如何使用某些内容时编写概念证明。

? 如果您决定使用框架,请尽可能多地将其用于预期目的。例如,假设您使用 Spring 安全性,并且您观察到对于安全性实现,您倾向于编写更多的自定义代码,而不是依赖框架提供的内容。你应该提出一个关于为什么会发生这种情况的问题。

当我们依赖框架实现的功能时,我们享受到几个好处。我们知道它们已经过测试,并且包含漏洞的更改较少。此外,一个好的框架依赖于抽象,它可以帮助您创建可维护的应用程序。请记住,当您编写自己的实现时,您更容易受到漏洞的影响。

本节讨论安全上下文。我们分析了它的工作原理、如何从中访问数据,以及应用程序如何在与线程相关的不同场景中管理它。完成本部分后,你将了解如何为各种情况配置安全上下文。这样,您可以在第 7 章和第 8 章中配置授权时使用安全上下文存储的有关经过身份验证的用户的详细信息。

身份验证过程结束后,您可能需要有关经过身份验证的实体的详细信息。例如,您可能需要引用当前经过身份验证的用户的用户名或权限。身份验证过程完成后,是否仍可访问此信息?一旦 AuthenticationManager 成功完成身份验证过程,它将存储 Authentication 实例用于请求的其余部分。存储身份验证对象的实例称为“慈善”安全上下文。

Spring Security 的安全上下文由 SecurityContext 接口描述。下面的清单定义了此接口。

public interface SecurityContext extends Serializable {

  Authentication getAuthentication();
  void setAuthentication(Authentication authentication);
}

从协定定义中可以看出,SecurityContext 的主要职责是存储身份验证对象。但是,如何管理SecurityContext本身呢?Spring 安全性提供了三种策略来管理具有管理器角色的对象的安全上下文。它被命名为 SecurityContextHolder :

  • MODE_THREADLOCAL —允许每个线程在安全上下文中存储其自己的详细信息。在每请求线程 Web 应用程序中,这是一种常见的方法,因为每个请求都有一个单独的线程。
  • MODE_INHERITABLETHREADLOCAL —与MODE_THREADLOCAL类似,但也指示 Spring 安全性在异步方法的情况下将安全上下文复制到下一个线程。这样,我们可以说运行 @Async 方法的新线程继承了安全上下文。 @Async注释与方法一起使用,以指示 Spring 在单独的线程上调用注释方法。
  • MODE_GLOBAL — 使应用程序的所有线程看到相同的安全上下文实例。

除了管理Spring Security提供的安全上下文的这三种策略之外,在本节中,我们还讨论了当您定义自己的线程时会发生什么,而这些线程对于Spring来说是未知的。正如您将了解的那样,对于这些情况,您需要将详细信息从安全上下文显式复制到新线程。Spring 安全性不能自动管理不在 Spring 上下文中的对象,但它为此提供了一些很棒的实用程序类。

管理安全上下文的第一个策略是MODE_THREADLOCAL策略。此策略也是管理 Spring 安全性使用的安全上下文的默认设置。通过这种策略,Spring Security使用ThreadLocal来管理上下文。 ThreadLocal 是 JDK 提供的实现。此实现用作数据集合,但确保应用程序的每个线程只能看到存储在集合的专用部分中的数据。这样,每个请求都可以访问其安全上下文。没有线程可以访问另一个线程的 ThreadLocal 。这意味着在 Web 应用程序中,每个请求只能看到自己的安全上下文。我们可以说,这也是您通常希望后端Web应用程序拥有的。

图 6.8 提供了此功能的概述。每个请求(A、B 和 C)都有自己的分配线程(T1、T2 和 T3)。这样,每个请求只能看到存储在其自己的安全上下文中的详细信息。但这也意味着,如果创建了一个新线程(例如,当调用异步方法时),新线程也将具有自己的安全上下文。父线程(请求的原始线程)中的详细信息不会复制到新线程的安全上下文中。

在这里,我们讨论一个传统的 servlet 应用程序,其中每个请求都绑定到一个线程。此体系结构仅适用于传统的 Servlet 应用程序,其中每个请求都分配了自己的线程。它不适用于反应式应用程序。我们将在第 17 章中详细讨论响应式方法的安全性。

作为管理安全上下文的默认策略,此过程不需要显式配置。只需在身份验证过程结束后,使用静态 getContext() 方法向持有者请求安全上下文,只要您需要它。在清单 6.7 中,您可以在应用程序的一个端点中找到一个获取安全上下文的示例。从安全上下文中,可以进一步获取 Authentication 对象,该对象存储有关经过身份验证的实体的详细信息。您可以在本节中找到我们在本节中讨论的示例,作为项目 ssia-ch6-ex2 的一部分。

@GetMapping("/hello")
public String hello() {
  SecurityContext context = SecurityContextHolder.getContext();
  Authentication a = context.getAuthentication();

  return "Hello, " + a.getName() + "!";
}

从上下文中获取身份验证在端点级别更加舒适,因为 Spring 知道将其直接注入到方法参数中。您不需要每次都显式引用 SecurityContextHolder 类。如以下清单所示,此方法更好。

@GetMapping("/hello")
public String hello(Authentication a) {      #A
  return "Hello, " + a.getName() + "!";
}

使用正确的用户调用终结点时,响应正文包含用户名。例如

curl -u user:99ff79e3-8ca0-401c-a396-0a8625ab3bad http://localhost:8080/hello
Hello, user!

很容易坚持使用管理安全上下文的默认策略。在很多情况下,这是您唯一需要的东西。 MODE_THREADLOCAL使您能够隔离每个线程的安全上下文,并使安全上下文更自然地理解和管理。但也有一些情况不适用。

如果我们必须处理每个请求的多个线程,情况会变得更加复杂。看看如果使终结点异步发生什么情况。执行该方法的线程不再是为请求提供服务的同一线程。考虑一个终结点,类似于下一个列表中提供的终结点。

@GetMapping("/bye")
@Async      #A
public void goodbye() {
  SecurityContext context = SecurityContextHolder.getContext();
  String username = context.getAuthentication().getName();

  // do something with the username
}

为了启用@Async注解的功能,我还创建了一个配置类并用 @EnableAsync 对其进行了注释,如下所示:

@Configuration
@EnableAsync
public class ProjectConfig {

}

有时在文章或论坛中,您会发现配置注释放在主类上。例如,您可能会发现某些示例直接在主类上使用@EnableAsync批注。这种方法在技术上是正确的,因为我们使用 @SpringBootApplication 注释(包括@Configuration特征)注释 Spring Boot 应用程序的主类。但在实际应用程序中,我们更愿意将职责分开,并且我们从不使用主类作为配置类。为了使本书中的示例尽可能清晰,我更喜欢将这些注释保留在@Configuration类中,类似于在实际场景中找到它们的方式。

如果您按现在的方式尝试代码,它会在从身份验证中获取名称的行上抛出一个 NullPointerException,即

String username = context.getAuthentication().getName()

这是因为该方法现在在不继承安全上下文的另一个线程上执行。因此,授权对象为空,并且在所呈现代码的上下文中会导致空指针异常。在这种情况下,您可以使用
MODE_INHERITABLETHREADLOCAL策略解决问题。这可以通过调用
SecurityContextHolder.setStrategyName()方法或使用系统属性spring.security.strategy来设置。通过设置此策略,框架知道将请求的原始线程的详细信息复制到异步方法的新创建线程(图 6.9)。

下一个清单提供了一种通过调用 setStrategyName() 方法来设置安全上下文管理策略的方法。

@Configuration
@EnableAsync
public class ProjectConfig {

  @Bean
  public InitializingBean initializingBean() {
    return () -> SecurityContextHolder.setStrategyName(
      SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
  }
}

调用端点,你现在将观察到安全上下文被 Spring 正确地传播到下一个线程。此外, 身份验证不再为空。

但是,这仅在框架本身创建线程时有效(例如,在 @Async 方法的情况下)。如果您的代码创建了线程,即使使用
MODE_INHERITABLETHREADLOCAL 策略,也会遇到相同的问题。发生这种情况是因为,在这种情况下,框架不知道代码创建的线程。我们将在第 6.2.4 和 6.2.5 节中讨论如何解决这些情况的问题。

如果您需要的是应用程序的所有线程共享的安全上下文,则将策略更改为MODE_GLOBAL (图 6.10)。您不会将此策略用于 Web 服务器,因为它不符合应用程序的总体情况。后端 Web 应用程序独立管理它收到的请求,因此将每个请求的安全上下文分开而不是对所有请求使用一个上下文确实更有意义。但这对于独立应用程序来说是一个很好的用途。

如以下代码片段所示,您可以像我们对
MODE_INHERITABLETHREADLOCAL 所做的那样更改策略。您可以使用
SecurityContextHolder.setStrategyName() 方法或系统属性 spring.security.strategy:

@Bean
public InitializingBean initializingBean() {
  return () -> SecurityContextHolder.setStrategyName(
    SecurityContextHolder.MODE_GLOBAL);
}

另外,请注意,SecurityContext 不是线程安全的。因此,使用此策略,应用程序的所有线程都可以访问 SecurityContext 对象,您需要注意并发访问。

您已经了解到,您可以使用 Spring 安全性提供的三种模式来管理安全上下文: MODE_THREADLOCAL、 MODE_INHERITEDTHREADLOCAL 和 MODE_GLOBAL 。默认情况下,框架仅确保为请求的线程提供安全上下文,并且此安全上下文仅可由该线程访问。但是框架不处理新创建的线程(例如,在异步方法的情况下)。您了解到,对于这种情况,您必须显式设置不同的安全上下文管理模式。但是我们仍然有一个奇点:当你的代码在框架不知道的情况下启动新线程时会发生什么?有时我们将这些“慈善”命名为自我管理的线程,因为管理它们的是我们,而不是框架。在本节中,我们将应用 Spring 安全性提供的一些实用工具,这些工具可帮助您将安全上下文传播到新创建的线程。

SecurityContextHolder 没有特定的策略为您提供自我管理线程的解决方案。在这种情况下,您需要注意安全上下文传播。一种解决方案是使用
DeputatingSecurityContextRunnable 来修饰要在单独的线程上执行的任务。
DedelegateatingSecurityContextRunnable 扩展了 Runnable 。您可以在任务执行后在没有预期值时使用它。如果您有返回值,则可以使用 Callable<T> 替代方法,即
DeputatingSecurityContextCallable<T> 。 这两个类都表示异步执行的任务,就像任何其他 Runnable 或 Callable 一样。此外,这些确保复制执行任务的线程的当前安全上下文。如图 6.11 所示,这些对象装饰原始任务并将安全上下文复制到新线程。

示例 6.11 介绍了
DeputatingSecurityContextCallable 的用法。让我们首先定义一个简单的终结点方法来声明 Callable 对象。 可调用任务从当前安全上下文返回用户名。

@GetMapping("/ciao")
public String ciao() throws Exception {
  Callable<String> task = () -> {
     SecurityContext context = SecurityContextHolder.getContext();
     return context.getAuthentication().getName();
  };

  // Omitted code
}

我们通过将任务提交给执行器服务来继续该示例。终结点检索执行的响应并将其作为响应正文返回。

@GetMapping("/ciao")
public String ciao() throws Exception {
  Callable<String> task = () -> {
      SecurityContext context = SecurityContextHolder.getContext();
      return context.getAuthentication().getName();
  };

  ExecutorService e = Executors.newCachedThreadPool();
  try {
     return "Ciao, " + e.submit(task).get() + "!";
  } finally {
     e.shutdown();
  }
}

如果按原样运行应用程序,则只会得到一个 空指针异常 .在新创建的线程中运行可调用任务,身份验证不再存在,并且安全上下文为空。为了解决这个问题,我们使用 委派安全上下文可调用 来装饰任务,它为新线程提供当前上下文,如此列表所示。

@GetMapping("/ciao")
public String ciao() throws Exception {
  Callable<String> task = () -> {
    SecurityContext context = SecurityContextHolder.getContext();
    return context.getAuthentication().getName();
  };

  ExecutorService e = Executors.newCachedThreadPool();
  try {
    var contextTask = new DelegatingSecurityContextCallable<>(task);
    return "Ciao, " + e.submit(contextTask).get() + "!";
  } finally {
    e.shutdown();
  }
}

现在调用端点,您可以观察到 Spring 将安全上下文传播到执行任务的线程:

curl -u user:2eb3f2e8-debd-420c-9680-48159b2ff905
[CA]http://localhost:8080/ciao

此调用的响应正文为

Ciao, user!

在处理我们的代码在没有让框架知道的情况下启动的线程时,我们必须管理从安全上下文到下一个线程的详细信息的传播。在第 6.2.4 节中,您应用了一种技术,通过使用任务本身从安全上下文中复制详细信息。Spring Security 提供了一些很棒的实用程序类,如
DeputatingSecurityContextRunnable 和
DeputatingSecurityContextCallable 。 这些类装饰异步执行的任务,并负责从安全上下文复制详细信息,以便您的实现可以从新创建的线程访问这些详细信息。但是我们还有第二个选项来处理安全上下文传播到新线程,这是管理从线程池而不是任务本身的传播。在本节中,您将学习如何通过使用 Spring 安全性提供的更多出色的实用程序类来应用此技术。

装饰任务的替代方法是使用特定类型的执行器。在下一个示例中,您可以观察到任务仍然是一个简单的 Callable<T> ,但线程仍管理安全上下文。安全上下文的传播之所以发生,是因为一个名为
DeputatingSecurityContextExecutorService 的实现修饰了 ExecutorService。
DeputatingSecurityContextExecutorService还负责安全上下文传播,如图6.12所示。

示例 6.14 中的代码显示了如何使用
DedelegateatingSecurityContextExecutorService 来修饰 ExecutorService,以便在提交任务时,它需要注意传播安全上下文的详细信息。

@GetMapping("/hola")
public String hola() throws Exception {
  Callable<String> task = () -> {
    SecurityContext context = SecurityContextHolder.getContext();
    return context.getAuthentication().getName();
  };

  ExecutorService e = Executors.newCachedThreadPool();
  e = new DelegatingSecurityContextExecutorService(e);
  try {
    return "Hola, " + e.submit(task).get() + "!";
  } finally {
    e.shutdown();
  }
}

调用终结点以测试委派安全上下文执行器服务是否正确委派了安全上下文:

curl -u user:5a5124cc-060d-40b1-8aad-753d3da28dca http://localhost:8080/hola

此调用的响应正文为

Hola, user!

在与安全上下文的并发支持相关的类中,我建议您注意表 6.1 中提供的类。

Spring 提供了实用程序类的各种实现,您可以在应用程序中使用这些实现在创建自己的线程时管理安全上下文。在第 6.2.4 节中,您实现了 委派安全上下文可调用 。在本节中,我们使用 委派安全上下文执行器服务 。如果您需要为计划任务实现安全上下文传播,那么您会很高兴听到Spring Security还为您提供了一个名为
DeputatingSecurityContextScheduledExecutorService的装饰器。这种机制类似于我们在本节中介绍的委派安全上下文执行器服务,不同之处在于它装饰了一个 计划执行器服务 ,允许您使用计划任务。

此外,为了获得更大的灵活性,Spring Security 为您提供了一个名为
DeputatingSecurityContextExecutor 的装饰器的更抽象版本。这个类直接装饰一个执行器,这是这个线程池层次结构中最抽象的契约。当您希望能够用语言为您提供的任何选项替换线程池的实现时,可以选择它来设计应用程序。

描述

委派安全上下文执行器

实现执行程序接口,旨在装饰执行程序对象,使其能够将安全上下文转发到其池创建的线程。

De委派安全上下文执行器服务

实现 ExecutorService 接口,旨在修饰 ExecutorService 对象,使其能够将安全上下文转发到由其池创建的线程。

De委派安全上下文计划执行器服务

实现 ScheduledExecutorService 接口,旨在修饰 ScheduledExecutorService 对象,使其能够将安全上下文转发到由其池创建的线程。

委派安全上下文可运行

实现 Runnable 接口,并表示在不同线程上执行而不返回响应的任务。在普通的 Runnable 之上,它还能够传播要在新线程上使用的安全上下文。

委派安全上下文可调用

实现 Callable 接口,并表示在不同线程上执行并最终将返回响应的任务。在普通的 Callable 之上,它还能够传播要在新线程上使用的安全上下文。

到目前为止,我们只使用HTTP Basic作为身份验证方法,但在本书中,您将了解到还有其他可能性。HTTP 基本身份验证方法很简单,这使其成为示例和演示目的或概念证明的绝佳选择。但出于同样的原因,它可能不适合您需要实现的所有实际方案。

在本节中,您将了解与 HTTP 基本相关的更多配置。同样,我们还发现了一种新的身份验证方法,称为 表单登录 .在本书的其余部分,我们将讨论其他身份验证方法,这些方法与不同类型的体系结构非常匹配。我们将比较这些内容,以便您了解最佳实践以及身份验证的反模式。

您知道HTTP Basic是默认的身份验证方法,我们已经在第3章的各种示例中观察到了它的工作方式。在本节中,我们将添加有关此身份验证方法配置的更多详细信息。

对于理论方案,HTTP 基本身份验证附带的默认值非常好。但在更复杂的应用程序中,您可能会发现需要自定义其中一些设置。例如,您可能希望为身份验证过程失败的情况实现特定逻辑。在这种情况下,您甚至可能需要在发送回客户端的响应上设置一些值。因此,让我们通过实际示例来考虑这些情况,以了解如何实现这一点。我想再次指出如何显式设置此方法,如以下清单所示。您可以在项目 ssia-ch6-ex3 中找到此示例。

@Configuration
public class ProjectConfig {

  @Bean
  public SecurityFilterChain configure(HttpSecurity http) 
    throws Exception {

     http.httpBasic(Customizer.withDefaults());

     return http.build();
  }
}

您可以使用定制器类型的参数调用 HttpSecurity 实例的 httpBasic() 方法。此参数允许您设置一些与身份验证方法相关的配置,例如领域名称,如清单 6.16 所示。您可以将领域视为使用特定身份验证方法的保护空间。有关完整说明,请参阅 RFC 2617
https://tools.ietf.org/html/rfc2617。

@Bean
public SecurityFilterChain configure(HttpSecurity http) 
  throws Exception {

  http.httpBasic(c -> {
    c.realmName("OTHER");
    c.authenticationEntryPoint(new CustomEntryPoint());
  });

  http.authorizeHttpRequests(c -> c.anyRequest().authenticated());

  return http.build();
}

示例 6.16 给出了一个更改领域名称的示例。实际上,使用的 lambda 表达式是 Customizer<HttpBasicConfigurer<HttpSecurity>> 类型的对象。 类型为 HttpBasicConfigurer<HttpSecurity> 的参数允许我们调用 realmName() 方法来重命名领域。您可以将 cURL 与 -v 标志一起使用,以获取详细的 HTTP 响应,其中领域名称确实已更改。但是,请注意,仅当 HTTP 响应状态为 401 未授权时,才会在响应中找到 WWW-Authenticate 标头,而不是在 HTTP 响应状态为 200 OK 时找到。以下是对 cURL 的调用:

curl -v http://localhost:8080/hello

呼叫的响应是

/
...
< WWW-Authenticate: Basic realm="OTHER"
...

此外,通过使用 定制器 ,我们可以自定义失败身份验证的响应。如果系统的客户端希望在身份验证失败的情况下响应中出现特定内容,则需要执行此操作。您可能需要添加或删除一个或多个标头。或者,可以使用一些逻辑来筛选正文,以确保应用程序不会向客户端公开任何敏感数据。

始终谨慎对待在系统外部公开的数据。最常见的错误之一(也是OWASP十大漏洞的一部分 –
https://owasp.org/www-project-top-ten/)是暴露敏感数据。使用应用程序发送给客户端以进行失败的身份验证的详细信息始终是泄露机密信息的风险点。

要自定义对失败身份验证的响应,我们可以实现一个 身份验证入口点 .它的 commence() 方法接收导致身份验证失败的 HttpServletRequest 、HttpServletResponse 和 AuthenticationException。 清单 6.17 演示了一种实现 AuthenticationEntryPoint 的方法,该方法向响应添加一个标头,并将 HTTP 状态设置为 401 Unauthorized。

AuthenticationEntryPoint 接口的名称不能反映其在身份验证失败时的用法,这有点模棱两可。在 Spring 安全架构中,它由一个名为
ExceptionTranslationManager 的组件直接使用,该组件处理过滤器链中抛出的任何 AccessDeniedException 和 AuthenticationException。您可以将
ExceptionTranslationManager 视为 Java 异常和 HTTP 响应之间的桥梁。

public class CustomEntryPoint 
  implements AuthenticationEntryPoint {

  @Override
  public void commence(
    HttpServletRequest httpServletRequest, 
    HttpServletResponse httpServletResponse, 
    AuthenticationException e) 
      throws IOException, ServletException {

      httpServletResponse
        .addHeader("message", "Luke, I am your father!");
      httpServletResponse
        .sendError(HttpStatus.UNAUTHORIZED.value());

    }
}

然后,可以在配置类中使用 HTTP Basic 方法注册自定义入口点。下面的清单提供了自定义入口点的配置类。

@Bean
public SecurityFilterChain configure(HttpSecurity http) 
  throws Exception {

  http.httpBasic(c -> {
    c.realmName("OTHER");
    c.authenticationEntryPoint(new CustomEntryPoint());
  });

  http.authorizeHttpRequests().anyRequest().authenticated();

  return http.build();
}

如果现在调用终结点导致身份验证失败,则应在响应中找到新添加的标头:

curl -v http://localhost:8080/hello

呼叫的响应是

...
< HTTP/1.1 401
< Set-Cookie: JSESSIONID=459BAFA7E0E6246A463AD19B07569C7B; Path=/; HttpOnly
< message: Luke, I am your father!
...

在开发 Web 应用程序时,您可能希望提供一个用户友好的登录表单,用户可以在其中输入其凭据。此外,您可能希望经过身份验证的用户能够在登录后浏览网页并能够注销。对于小型 Web 应用程序,您可以利用基于表单的登录方法。在本部分中,您将了解如何为应用程序应用和配置此身份验证方法。为了实现这一点,我们编写了一个使用基于表单的登录的小型 Web 应用程序。图 6.13 描述了我们将实现的流程。本节中的示例是项目 ssia-ch6-ex4 的一部分。

我将此方法链接到一个小型 Web 应用程序,因为这样,我们使用服务器端会话来管理安全上下文。对于需要水平可伸缩性的大型应用程序,不希望使用服务器端会话来管理安全上下文。我们将在第 12 章到第 15 章处理 OAuth 2 时更详细地讨论这些方面。

若要将身份验证方法更改为基于表单的登录,请使用 SecurityFilterChain Bean 的 HttpSecurity 对象而不是 httpBasic() ,调用 HttpSecurity 参数的 formLogin() 方法。 以下清单显示了此更改。

@Configuration
public class ProjectConfig {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) 
    throws Exception {

    http.formLogin(Customizer.withDefaults());

    http.authorizeHttpRequests(c -> c.anyRequest().authenticated());

    return http.build();
  }
}

即使使用这种最小的配置,Spring 安全性也已经为您的项目配置了一个登录表单和一个注销页面。启动应用程序并使用浏览器访问它应该会将您重定向到登录页面(图 6.14)。

您可以使用默认提供的凭据登录,只要您不注册您的用户详细信息服务。正如我们在第 2 章中了解到的,这些是用户名“user”和应用程序启动时在控制台中打印的 UUID 密码。成功登录后,由于未定义其他页面,因此将重定向到默认错误页面。该应用程序依赖于我们在前面示例中遇到的相同体系结构进行身份验证。因此,如图 6.14 所示,您需要为应用程序的主页实现一个控制器。不同之处在于,我们希望端点返回可以被浏览器解释为我们的网页的 HTML,而不是一个简单的 JSON 格式的响应。因此,我们选择坚持使用 Spring MVC 流程,并在执行控制器中定义的操作后从文件呈现视图。图 6.15 显示了用于呈现应用程序主页的 Spring MVC 流程。

若要向应用程序添加简单页面,首先必须在项目的资源/静态文件夹中创建一个 HTML 文件。我把这个文件称为家.html。在其中,键入一些文本,以便您以后可以在浏览器中找到这些文本。您可以只添加标题(例如, <h1>欢迎</h1> )。创建 HTML 页后,控制器需要定义从路径到视图的映射。下面的清单提供了控制器类中主页.html页的操作方法的定义。

@Controller
public class HelloController {

  @GetMapping("/home")
  public String home() {
    return "home.html";
  }
}

请注意,这不是@RestController而是一个简单的@Controller。因此,Spring 不会在 HTTP 响应中发送该方法返回的值。相反,它会查找并呈现名为 home.html 的视图。

现在尝试访问 /home 路径时,首先询问您是否要登录。成功登录后,您将被重定向到主页,其中显示欢迎消息。您现在可以访问 /logout 路径,这应该会将您重定向到注销页面(图 6.16)。

尝试在不登录的情况下访问路径后,用户会自动重定向到登录页面。成功登录后,应用程序会将用户重定向回他们最初尝试访问的路径。如果该路径不存在,应用程序将显示默认错误页。 formLogin() 方法返回一个类型的对象 FormLoginConfigurer<HttpSecurity> ,这允许我们进行自定义。例如,可以通过调用 defaultSuccessUrl() 方法来执行此操作,如下面的清单所示。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) 
  throws Exception {

  http.formLogin(c -> c.defaultSuccessUrl("/home", true));

  http.authorizeHttpRequests(c -> c.anyRequest().authenticated());

  return http.build();
}

如果需要更深入地了解这一点,使用
AuthenticationSuccessHandler 和
AuthenticationFailureHandler 对象可以提供更详细的自定义方法。这些接口允许您实现一个对象,通过该对象可以应用为身份验证执行的逻辑。如果要自定义成功身份验证的逻辑,可以定义
AuthenticationSuccessHandler 。 onAuthenticationSuccess() 方法接收 servlet 请求、servlet 响应和 Authentication 对象作为参数。在清单 6.22 中,您将找到一个实现 onAuthenticationSuccess() 方法的示例,该方法根据登录用户的授权权限进行不同的重定向。

@Component
public class CustomAuthenticationSuccessHandler 
  implements AuthenticationSuccessHandler {

  @Override
  public void onAuthenticationSuccess(
    HttpServletRequest httpServletRequest, 
    HttpServletResponse httpServletResponse, 
    Authentication authentication) 
      throws IOException {

      var authorities = authentication.getAuthorities();

      var auth = 
              authorities.stream()
                .filter(a -> a.getAuthority().equals("read"))
                .findFirst();      #A

      if (auth.isPresent()) {      #B
        httpServletResponse
          .sendRedirect("/home");
      } else {
        httpServletResponse
          .sendRedirect("/error");
      }
   }
}

在实际方案中,客户端在身份验证失败的情况下需要某种格式的响应。他们可能需要与响应正文中的 401 未授权或其他信息不同的 HTTP 状态代码。我在应用程序中发现的最典型的情况是发送“慈善”请求标识符。此请求标识符具有唯一值,用于在多个系统之间回溯请求,如果身份验证失败,应用程序可以在响应正文中发送该值。另一种情况是,当您想要清理响应以确保应用程序不会在系统外部公开敏感数据时。您可能希望通过记录事件以供进一步调查来为失败的身份验证定义自定义逻辑。

如果要自定义应用程序在身份验证失败时执行的逻辑,可以使用
AuthenticationFailureHandler 实现类似地执行此操作。例如,如果要为任何失败的身份验证添加特定的标头,可以执行类似清单 6.23 所示的操作。当然,您也可以在此处实现任何逻辑。对于
AuthenticationFailureHandler , onAuthenticationFailure() 接收请求、响应和 Authentication 对象。

@Component
public class CustomAuthenticationFailureHandler 
  implements AuthenticationFailureHandler {

  @Override
  public void onAuthenticationFailure(
    HttpServletRequest httpServletRequest, 
    HttpServletResponse httpServletResponse, 
    AuthenticationException e)  {

    try {

      httpServletResponse.setHeader("failed", 
         LocalDateTime.now().toString());
      httpServletResponse.sendRedirect("/error");

    } catch (IOException ex) {
      throw new RuntimeException(ex);
    }    
  }
}

若要使用这两个对象,需要在 formLogin() 方法返回的 FormLoginConfigurer 对象的 configure() 方法中注册它们。下面的清单显示了如何执行此操作。

@Configuration
public class ProjectConfig {

  private final CustomAuthenticationSuccessHandler 
[CA]authenticationSuccessHandler;
  private final CustomAuthenticationFailureHandler 
[CA]authenticationFailureHandler;

// Omitted constructor

@Bean
public UserDetailsService uds() {
  var uds = new InMemoryUserDetailsManager();

  uds.createUser(
     User.withDefaultPasswordEncoder()
          .username("john")
          .password("12345")
          .authorities("read")
          .build()
  );

  uds.createUser(
     User.withDefaultPasswordEncoder()
           .username("bill")
           .password("12345")
           .authorities("write")
           .build()
  );

  return uds;
}

@Bean
public SecurityFilterChain configure(HttpSecurity http) 
  throws Exception {

   http.formLogin(c ->
     c.successHandler(authenticationSuccessHandler)
      .failureHandler(authenticationFailureHandler)
   );

   http.authorizeHttpRequests(c -> c.anyRequest().authenticated());

   return http.build();    
  }
}

现在,如果您尝试使用具有正确用户名和密码的 HTTP Basic 访问 /home 路径,则会返回状态为 HTTP 302 已找到的响应。此响应状态代码是应用程序告诉您它正在尝试执行重定向的方式。即使您提供了正确的用户名和密码,它也不会考虑这些用户名和密码,而是会尝试按照 formLogin 方法的要求将您发送到登录表单。但是,您可以更改配置以支持 HTTP 基本登录方法和基于表单的登录方法,如以下清单所示。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) 
  throws Exception {

  http.formLogin(c ->
     c.successHandler(authenticationSuccessHandler)
      .failureHandler(authenticationFailureHandler)
  );

  http.httpBasic(Customizer.withDefaults());

  http.authorizeHttpRequests(c -> c.anyRequest().authenticated());

  return http.build();
}

访问 /home 路径现在适用于基于表单的登录和 HTTP 基本身份验证方法:

curl -u user:cdd430f6-8ebc-49a6-9769-b0f3ce571d19 
[CA]http://localhost:8080/home

呼叫的响应是

<h1>Welcome</h1>
  • 身份验证提供程序是允许您实现自定义身份验证逻辑的组件。
  • 实现自定义身份验证逻辑时,最好将职责分离。对于用户管理,身份验证提供程序委托给 UserDetailsService ,对于密码验证的责任,身份验证提供程序委托给密码编码器。
  • 安全上下文在成功进行身份验证后保留有关经过身份验证的实体的详细信息。
  • 您可以使用三种策略来管理安全上下文: MODE_THREADLOCAL、 MODE_INHERITABLETHREADLOCAL 和 MODE_GLOBAL 。从不同线程访问安全上下文详细信息的工作方式因所选模式而异。
  • 请记住,当使用共享线程本地模式时,它仅适用于由 Spring 管理的线程。框架不会复制不受其控制的线程的安全上下文。
  • Spring 安全性为您提供了很好的实用程序类来管理由代码创建的线程,框架现在已知道这些线程。若要管理您创建的线程的安全上下文,可以使用委派安全上下文可运行委派安全上下文可调用委派安全上下文执行器
  • Spring 安全性自动配置一个用于登录的表单和一个使用基于表单的登录身份验证方法 formLogin() 注销的选项。在开发小型 Web 应用程序时,它很容易使用。
  • 表单登录身份验证方法是高度可自定义的。此外,您可以将这种类型的身份验证与 HTTP Basic 方法一起使用。
赞(0) 打赏
未经允许不得转载:划界MBA » Spring Security实战:6 实现身份验证

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续提供更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫

登录

找回密码

注册