Spring Security in Action 第五章 SpringSecurity实现认证

本专栏将从基础开始,循序渐进,以实战为线索,逐步深入SpringSecurity相关知识相关知识,打造完整的SpringSecurity学习步骤,提升工程化编码能力和思维能力,写出高质量代码。希望大家都能够从中有所收获,也请大家多多支持。
专栏地址:SpringSecurity专栏
本文涉及的代码都已放在gitee上:gitee地址
如果文章知识点有错误的地方,请指正!大家一起学习,一起进步。
专栏汇总:专栏汇总


本章包括

  • 使用自定义的AuthenticationProvider实现认证逻辑
  • 使用HTTP Basic和基于表单的登录认证方法
  • 了解并管理SecurityContext组件

在第3章和第4章中,我们介绍了在认证流程中发挥作用的一些组件。我们讨论了UserDetails以及如何定义原型来描述Spring Security中的用户。然后我们在例子中使用了UserDetails,在那里你了解了UserDetailsService和UserDetailsManager合约是如何工作的,以及你如何实现这些合约。我们还讨论了这些接口的主要实现,并在实例中使用。最后,你学到了PasswordEncoder如何管理密码和如何使用它,以及Spring Security加密模块(SSCM)及其加密器和密钥生成器。

然而,AuthenticationProvider层是负责认证逻辑的。AuthenticationProvider是你找到决定是否对请求进行认证的条件和指令的地方。将这个责任委托给AuthenticationProvider的组件是AuthenticationManager,它从HTTP过滤器层接收请求。我们将在第9章中详细讨论过滤器层。在本章中,我们来看看认证过程,它只有两种可能的结果:

  • 提出请求的实体没有得到认证。用户不被认可,应用程序拒绝请求,而不委托给授权过程。通常,在这种情况下,发回给客户端的响应状态是HTTP 401 Unauthorized。
  • 提出请求的实体是经过认证的。关于请求者的详细信息被存储起来,以便应用程序可以使用这些信息进行授权。正如你将在本章中发现的,SecurityContext接口是存储当前认证请求细节的实例。

为了提醒你演员和他们之间的联系,图5.1提供了你在第二章中也看到的图表。

image-20230203214253708

图5.1 Spring Security中的认证流程。这个流程定义了应用程序如何识别提出请求的人。在图中,本章所讨论的组件都有阴影。在这里,AuthenticationProvider实现了这个过程中的认证逻辑,而SecurityContext则存储了关于认证请求的细节。

本章涵盖了认证流程的其余部分(图5.1中的阴影框)。然后,在第7章和第8章中,你将学习授权是如何工作的,这是HTTP请求中认证之后的过程。首先,我们需要讨论如何实现AuthenticationProvider接口。你需要知道Spring Security在认证过程中是如何理解请求的。

为了让你清楚地了解如何表示一个请求,我们将从Authentication接口开始。一旦我们讨论了这一点,我们就可以进一步观察在成功认证之后,请求的细节会发生什么。认证成功后,我们就可以讨论SecurityContext接口以及Spring Security管理它的方式。在本章接近尾声时,你将学习如何定制HTTP Basic认证方法。我们还将讨论另一种我们可以在应用程序中使用的认证选项–基于表单的登录。

5.1 了解AuthenticationProvider

在企业应用中,你可能会发现自己处于这样一种情况:基于用户名和密码的认证的默认实现并不适用。此外,当涉及到认证时,你的应用可能需要实现几种情况(图5.2)。例如,你可能希望用户能够通过使用短信中收到的代码或特定应用程序显示的代码来证明他们是谁。或者,你可能需要实现认证场景,用户必须提供存储在文件中的某种密钥。你甚至可能需要使用用户的指纹表示来实现认证逻辑。一个框架的目的是要足够灵活,使你能够实现任何这些所需的场景。

image-20230204092557162

图5.2 对于一个应用程序,你可能需要以不同的方式实现认证。虽然在大多数情况下,一个用户名和一个密码就足够了,但在某些情况下,用户认证的情况可能更复杂。

一个框架通常会提供一套最常用的实现,但它当然不能涵盖所有可能的选项。就Spring Security而言,你可以使用AuthenticationProvider合约来定义任何自定义的认证逻辑。在本节中,你将学习通过实现Authentication接口来表示认证事件,然后用AuthenticationProvider创建你的自定义认证逻辑。为了实现我们的目标:

  • 在5.1.1节中,我们分析了Spring Security如何表示认证事件。
  • 在第5.1.2节中,我们讨论了负责认证逻辑的AuthenticationProvider合约。
  • 在第5.1.3节中,你通过在一个例子中实现AuthenticationProvider契约来编写自定义认证逻辑。

5.1.1 在认证过程中表示请求

在这一节中,我们将讨论Spring Security在认证过程中如何表示一个请求。在深入实现客户认证逻辑之前,了解这一点很重要。正如你将在第5.1.2节中了解到的,要实现一个自定义的AuthenticationProvider,你首先需要了解如何表示认证事件本身。在这一节中,我们看一下代表认证的契约,并讨论你需要知道的方法。

认证是同名过程中涉及的基本接口之一。认证接口表示认证请求事件,并持有请求访问应用程序的实体的详细信息。你可以在认证过程中和认证之后使用与认证请求事件相关的信息。请求访问应用程序的用户被称为委托人。如果你曾经在任何应用程序中使用过Java Security API,你会了解到在Java Security API中,一个名为Principal的接口代表了相同的概念。Spring Security的Authentication接口扩展了这个契约(图5.3)。

image-20230204093235376

图5.3 Authentication继承于Principal。Authentication增加了一些要求,如需要密码或可以指定关于认证请求的更多细节。其中的一些细节,如授权列表,是Spring Security特有的。

Spring Security中的Authentication合约不仅代表了一个委托人,它还增加了关于认证过程是否完成的信息,以及一个授权集合。该契约被设计为扩展Java Security API中的Principal契约,这在与其他框架和应用程序的实现的兼容性方面是一个优势。这种灵活性允许从以其他方式实现认证的应用程序更容易地迁移到Spring Security。

让我们在下面的列表中了解更多关于认证界面的设计。

清单5.1 Spring Security中声明的认证接口

image-20230204093459106

目前,你需要学习的这个合同的唯一方法是这些:

  • isAuthenticated()-如果认证过程结束,返回true;如果认证过程仍在进行,返回false。

  • getCredentials()-返回密码或认证过程中使用的任何秘钥。

  • getAuthorities()-返回认证请求的授权集合。

我们将在后面的章节中讨论认证合同的其他方法,在适当的地方,我们会看一下当时的实现。

5.1.2 实现自定义认证逻辑

在这一节中,我们将讨论实现自定义认证逻辑。我们分析了与此责任相关的Spring Security合同,以了解其定义。有了这些细节,你就可以通过第5.1.3节中的一个代码例子来实现自定义认证逻辑。

Spring Security中的AuthenticationProvider负责处理认证逻辑。AuthenticationProvider接口的默认实现将寻找系统用户的责任交给了UserDetailsService。在认证过程中,它也使用PasswordEncoder进行密码管理。下面的列表给出了认证提供者的定义,你需要实现它来为你的应用程序定义一个自定义的认证提供者。

清单5.2 AuthenticationProvider接口

image-20230204093943467

AuthenticationProvider责任与Authen- tication契约紧密相连。authenticate()方法接收一个Authentication对象作为参数并返回一个Authentication对象。我们实现authenticate()方法来定义认证逻辑。我们可以用三条要点快速总结你应该实现 authenticate() 方法的方式:

  • 如果认证失败,该方法应该抛出一个AuthenticationException。

  • 如果该方法收到的认证对象不被你的AuthenticationProvider实现所支持,那么该方法应该返回null。这样一来,我们就有可能在HTTP-过滤器层面上使用多个分离的认证类型。我们将在第9章中进一步讨论这个问题。你还会在第11章中找到一个拥有多个AuthorizationProvider类的例子,这也是本书的第二个实战章节。

  • 该方法应该返回一个Authentication实例,代表一个完全认证的对象。对于这个实例,isAuthenticated()方法返回真,它包含了所有关于被认证实体的必要细节。通常情况,应用程序也会从这个实例中删除敏感数据,如密码。实施后,密码不再需要,保留这些细节有可能会暴露给不希望看到的人。

AuthenticationProvider接口的第二个方法是supports(Class<?> authentication)。你可以实现这个方法,如果当前的AuthenticationProvider支持作为Authentication对象提供的类型,则返回true。请注意,即使这个方法对一个对象返回true,仍有可能使authenticate()方法返回null而拒绝请求。Spring Security这样设计是为了更加灵活,允许你实现一个AuthenticationProvider,它可以根据请求的细节而不仅仅是其类型来拒绝认证请求。

关于认证管理器和认证提供者如何共同工作以验证或使认证请求无效的一个比喻是为你的门上了一把更复杂的锁。你可以通过使用卡片或老式的物理钥匙来打开这个锁(图5.4)。这把锁本身就是决定是否开门的认证管理器。为了做出这个决定,它委托给两个认证提供者:一个知道如何验证卡片,另一个知道如何验证物理钥匙。如果你出示卡片来开门,只使用物理钥匙的认证提供者会抱怨说它不知道这种认证方式。但另一个供应商支持这种认证,并验证该卡是否对门有效。这实际上是supports()方法的目的。

除了测试认证类型,Spring Security还增加了一层灵活性。门的锁可以识别多种卡。在这种情况下,当你出示一张卡时,其中一个认证提供者可以说,“我理解这是一张卡。但这不是我可以验证的卡的类型!” 当supports()返回真,但authenticate()返回空时,就会发生这种情况。

image-20230204094601314

图5.4 AuthenticationManager委托给一个可用的认证提供者。AuthenticationProvider可能不支持所提供的认证类型。另一方面,如果它确实支持该对象类型,它可能不知道如何验证该特定对象。认证被评估,一个能够说明请求是否正确的AuthenticationProvider会响应AuthenticationManager。

5.1.3 应用自定义认证逻辑

在本节中,我们实现了自定义的认证逻辑。你可以在项目 ssia-ch5-ex1 中找到这个例子。通过这个例子,你可以应用你在第5.1.1节和第5.1.2节学到的关于Authentication和AuthenticationProvider接口的内容。在列表5.3和5.4中,我们一步一步地建立一个如何实现自定义AuthenticationProvider的例子。这些步骤,也在图5.5中展示,如下:

  • 声明一个实现AuthenticationProvider契约的类。
  • 决定新的AuthenticationProvider支持哪些类型的认证对象:
    重写supports(Class<?> c)方法来指定我们定义的AuthenticationProvider支持哪种类型的认证。
    覆盖authenticate(Authentication a)方法来实现认证逻辑。
  • 在Spring Security中注册一个新的AuthenticationProvider实现的实例。

清单5.3 重写AuthenticationProvider的supports()方法

image-20230204095131780

在清单5.3中,我们定义了一个实现AuthenticationProvider接口的新类。我们用@Component来标记这个类,以便在Spring管理的上下文中拥有一个其类型的实例。然后,我们必须决定这个AuthenticationProvider支持什么样的认证接口实现。这取决于我们期望作为参数提供给authenticate()方法的什么类型。如果我们不在认证过滤器层面上定制任何东西(这就是我们的情况,但我们会在到达第9章时做到这一点),那么UsernamePasswordAuthenticationToken类定义了这个类型。这个类是认证接口的实现,代表了一个带有用户名和密码的标准认证请求。

通过这个定义,我们使AuthenticationProvider支持一种特定的密钥。一旦我们指定了我们的AuthenticationProvider的范围,我们就通过覆盖authenticate()方法来实现认证逻辑,如下面列表所示。

清单5.4 实现认证逻辑

package com.laurentiuspilca.ssia.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
    
    

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @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());
        } else {
    
    
            //如果密码不匹配,抛出一个AuthenticationException类型的异常。BadCredentialsException继承自AuthenticationException。
            throw new BadCredentialsException("Something went wrong!");
        }
    }

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

列表5.4中的逻辑很简单,图5.5直观地显示了这个逻辑。我们利用UserDetailsService的实现来获取UserDetails。如果用户不存在,loadUserByUsername()方法应该抛出一个AuthenticationException。在这种情况下,认证过程停止,HTTP过滤器将响应状态设置为HTTP 401 Unauthorized。如果用户名存在,我们可以通过上下文中的PasswordEncoder的matches()方法进一步检查用户的密码。如果密码不匹配,那么就应该再次抛出一个AuthenticationException。如果密码是正确的,AuthenticationProvider会返回一个标记为 "authenticated "的Authentication实例,其中包含关于请求的详细信息。

image-20230204095738173

图5.5 由AuthenticationProvider实现的自定义认证流程。为了验证认证请求,AuthenticationProvider用所提供的UserDetailsService的实现加载用户详细信息,如果密码匹配,则用PasswordEncoder验证密码。如果用户不存在或者密码不正确,AuthenticationProvider会抛出一个AuthenticationException。

要插入AuthenticationProvider的新实现,请在项目的配置类中覆盖WebSecurityConfigurerAdapter类的configure(AuthenticationManagerBuilder auth)方法。这在下面的列表中得到了证明。

package com.laurentiuspilca.ssia.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;

import javax.sql.DataSource;

@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
    
    

    @Autowired
    private AuthenticationProvider authenticationProvider;

    //omitted

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
    
    
        auth.authenticationProvider(authenticationProvider);
    }
}

注意 在清单5.5中,我在一个声明为AuthenticationProvider的字段上使用了@Autowired注解。Spring将Authentication- Provider识别为一个接口(这是一个抽象)。但Spring知道它需要在其上下文中找到该特定接口的一个实现实例。在我们的例子中,这个实现是Custo AuthenticationProvider的实例,它是我们使用@Component注解声明并添加到Spring上下文中的唯一一个该类型的实例。

这就是了! 你成功地定制了认证提供者的实现。现在你可以在你需要的地方为你的应用程序定制认证逻辑。

如何在应用程序设计中失败
不正确地应用一个框架会导致应用程序的可维护性降低。更糟的是有时那些使用框架失败的人认为是框架的错。让我告诉你一个故事。

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

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

开发人员观察到,随着时间的推移,定制变得越来越困难。但没有人采取行动来重新设计组件,以便按照Spring Security的意图使用契约。大部分的困难来自于不了解Spring的能力。一个主要的开发者说:“这都是Spring Security的错!这个框架很难应用,而且它也不适合我们。这个框架很难应用,也很难用上任何定制化的东西”。我对他的观察感到有些震惊。我知道Spring Security有时是难以理解的,而且这个框架以没有一个柔和的学习曲线而闻名。但是我从来没有遇到过这样的情况:我找不到办法用Spring Security来设计一个易于定制的类!"。

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

一切都以决定完全重写大量的自定义代码来正确依赖Spring Security而告终,从而使应用程序更容易扩展,以满足他们对安全实现的关注。我们还发现了一些与Spring Security无关的其他问题,但这是另一个故事。从这个故事中,你可以得到一些教训:

  • 一个框架,尤其是在应用程序中广泛使用的框架,是由许多聪明人参与编写的。即便如此,也很难相信它能被糟糕地实现。在断定任何问题都是框架的错之前,一定要分析你的应用程序。

  • 当决定使用一个框架时,要确保你至少很好地理解它的基础知识。

    • 要注意你用来学习框架的资源。有时,你在网上找到的文章会告诉你如何进行快速的变通,而不一定是如何正确地实现一个类的设计。

    • 在你的研究中使用多个来源。为了澄清你的误解,在不确定如何使用某些东西的时候,写一个概念证明。

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

​ 当我们依赖一个框架所实现的功能时,我们可以享受到一些好处。我们知道这些功能是经过测试的,包括漏洞在内的变化较少。另外,一个好的框架依赖于抽象,这有助于你创建可维护的应用程序。请记住,当你自己编写实现时,你更容易受到漏洞的影响。

5.2 使用SecurityContext

本节讨论了安全上下文。我们分析了它是如何工作的,如何从它那里访问数据,以及应用程序在不同的线程相关情况下如何管理它。一旦你完成这一节,你就会知道如何为各种情况配置安全上下文。这样,你就可以在第7章和第8章配置授权时使用安全上下文存储的关于认证用户的详细信息。

在授权过程结束后,你很可能需要关于被认证实体的细节。例如,你可能需要参考当前被认证用户的用户名或权限。在认证过程结束后,这些信息是否还能被访问?一旦AuthenticationManager成功地完成了认证过程,它就会为请求的其余部分存储Authentication实例。存储Authentication对象的实例被称为安全环境(security context)。

image-20230204100922018

图5.6 认证成功后,认证过滤器在安全上下文中存储了被认证实体的详细信息。在那里,实现与请求相对应的动作的控制器可以在需要时访问这些细节。

Spring Security的安全上下文是由SecurityContext接口描述的。下面的列表定义了这个接口。

清单5.6 SecurityContext接口

image-20230204101016070

从接口定义中可以看出,SecurityContext的主要职责是存储Authentication对象。但SecurityContext本身是如何管理的呢?Spring Security提供了三种策略来管理SecurityContext,其中一个对象扮演着管理者的角色。它被命名为SecurityContextHolder:

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

除了这三种管理Spring Security提供的安全上下文的策略外,在本节中,我们还讨论了当你定义自己的线程而不为Spring所知时的情况。正如你将了解到的,对于这些情况,你需要明确地将安全上下文中的细节复制到新线程中。Spring Security不能自动管理不在Spring上下文中的对象,但它为此提供了一些很棒的实用类。

5.2.1 将一种保持策略应用于安全上下文

管理安全上下文的第一个策略是MODE_THREADLOCAL策略。这个策略也是Spring Security使用的管理安全上下文的默认策略。在这种策略下,Spring Security使用ThreadLocal来管理上下文。ThreadLocal是JDK提供的一个实现。该实现作为一个数据集合工作,但确保应用程序的每个线程只能看到存储在该集合中的数据。这样一来,每个请求都能访问其安全上下文。没有线程可以访问另一个线程的ThreadLocal。这意味着,在一个Web应用程序中,每个请求只能看到自己的安全上下文。我们可以说,这也是你通常想要的后端Web应用程序的情况。

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

image-20230204101524330

图5.7 每个请求都有自己的线程,用一个箭头表示。每个线程只能访问自己的安全上下文细节。当一个新的线程被创建时(例如,通过一个@Async方法),父线程的细节不会被复制。

作为管理安全上下文的默认策略,这个过程不需要明确配置。只要在认证过程结束后,在任何需要安全上下文的地方使用静态的getContext()方法向持有人索取安全上下文。在列表5.7中,你可以找到一个在应用程序的一个端点中获取安全上下文的例子。从安全上下文中,你可以进一步获得Authentication对象,它存储了关于被认证实体的详细信息。你可以在本节中找到我们讨论的例子,作为项目sia-ch5-ex2的一部分。

清单 5.7 从 SecurityContextHolder 获取 SecurityContext

package com.laurentiuspilca.ssia.controllers;

import com.laurentiuspilca.ssia.services.HelloService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.security.concurrent.DelegatingSecurityContextCallable;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RestController
public class HelloController {
    
    

    @Autowired
    private HelloService helloService;

    @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) {
    
    
        return "Hello, " + a.getName() + "!";
    }

当用一个正确的用户调用端点时,响应体包含用户名称。比如说:

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

5.2.2 对异步调用使用保持策略

坚持使用默认策略来管理安全环境是很容易的。而且在很多情况下,这是你唯一需要的东西。MODE_THREADLOCAL为你提供了为每个线程隔离安全上下文的能力,它使安全上下文的理解和管理更加自然。但也有一些情况下,这并不适用。

如果我们必须处理每个请求的多个线程,情况会变得更加复杂。看看如果你把端点变成异步的会发生什么。执行方法的线程不再是提供请求的同一线程。想想像下一个列表中的端点。

image-20230204102101527

为了启用@Async注解的功能,我还创建了一个配置类,并用@EnableAsync注解了它,如图所示。

image-20230204102133458

注意 有时在文章或论坛中,你会发现配置注解被放在主类之上。例如,你可能会发现,某些例子直接在主类上使用@EnableAsync注解。这种方法在技术上是正确的,因为我们用@SpringBootApplication注解来注释Spring Boot应用程序的主类,其中包括@Configuration特性。但在现实世界的应用中,我们更愿意把责任分开,我们从不把主类作为配置类使用。为了使本书中的例子尽可能清晰,我更倾向于将这些注解保留在@Configuration类之上,与你在实际场景中的情况类似。

如果你尝试现在的代码,它会在从认证中获取名字的那一行抛出一个NullPointerException,这就是

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

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

image-20230204102411417

图5.8 当使用MODE_INHERITABLETHREADLOCAL时,框架会将安全上下文的细节从请求的原始线程复制到新线程的安全上下文。

下一个列表介绍了通过调用setStrategyName()方法来设置安全上下文管理策略的方法。

package com.laurentiuspilca.ssia.config;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.core.context.SecurityContextHolder;

@Configuration
@EnableAsync
public class ProjectConfig {
    
    

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

调用端点后,你会发现安全上下文被Spring正确地传播给了下一个线程。此外,Authentication不再是空的。

注意 这只有在框架自己创建线程时才有效(例如,在@Async方法的情况下)。如果你的代码创建了线程,即使使用MODE _INHERITABLETHREADLOCAL策略,你也会遇到同样的问题。发生这种情况是因为,在这种情况下,框架并不知道你的代码所创建的线程。我们将在5.2.4和5.2.5节中讨论如何解决这些情况的问题。

5.2.3 对独立的应用程序使用保持策略

如果你需要的是一个由应用程序的所有线程共享的安全上下文,你可以将策略改为MODE_GLOBAL(图5.9)。

image-20230204102818648

图5.9 在使用MODE_GLOBAL作为安全上下文管理策略时,所有线程都访问相同的安全上下文。这意味着这些线程都可以访问相同的数据,并且可以改变这些信息。正因为如此,可能会出现竞争条件,你必须注意同步问题。

你不会对Web服务器使用这种策略,因为它不符合应用程序的总体情况。一个后端网络应用程序独立地管理它所收到的请求,所以让每个请求的安全上下文分开,而不是一个上下文用于所有的请求,确实更有意义。但这对于一个独立的应用程序来说可以是一个很好的用途。

正如下面的代码片段所示,你可以用我们对MODE_INHERITABLETHREADLOCAL的同样方法来改变策略。你可以使用方法SecurityContextHolder.setStrategyName()或系统属性spring.security .strategy()。

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

另外,请注意,SecurityContext不是线程安全的。因此,在这种应用程序的所有线程都可以访问SecurityContext对象的策略下,你需要照顾到并发访问。

5.2.4 使用DelegatingSecurityContextRunnable转发安全上下文

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

SecurityContextHolder的任何特定策略都不能为你提供自管理线程的解决方案。在这种情况下,你需要注意安全上下文的传播。这方面的一个解决方案是使用DelegatingSecurityContextRunnable来装饰你想在独立线程上执行的任务。DelegatingSecurityContextRunnable扩展了Runnable。当没有预期的值时,你可以在任务的执行之后使用它。如果你有一个返回值,那么你可以使用Callable替代,也就是DelegatingSecurityContextCallable。这两个类都代表了异步执行的任务,就像任何其他Runnable或Callable一样。此外,这些类确保为执行任务的线程复制当前的安全上下文。如图5.10所示,这些对象装饰了原始任务,并将安全上下文复制到新的线程。

image-20230204103608443

图5.10 DelegatingSecurityContextCallable被设计成Callable对象的一个装饰器。在构建这样一个对象时,你提供了应用程序异步执行的可调用任务。DelegatingSecurityContextCallable将细节从安全上下文复制到新线程,然后执行任务。

清单5.11介绍了DelegatingSecurityContextCallable的使用。让我们先定义一个简单的端点方法,声明一个Callable对象。Callable任务从当前安全上下文中返回用户名。

清单5.11 定义一个可调用对象,并在一个单独的线程上将其作为一个任务执行

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

   ...
}

我们继续这个例子,将任务提交给一个ExecutorService。执行的响应被检索并作为响应体由端点返回。

清单5.12 定义一个ExecutorService并提交任务

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

    ...
}

如果你按原样运行该应用程序,你只会得到一个NullPointerException。在新创建的运行可调用任务的线程中,认证不存在了,安全上下文是空的。为了解决这个问题,我们用DelegatingSecurityContextCallable来装饰这个任务,它为新的线程提供了当前的上下文,正如这个清单所提供的。

清单5.13 运行由DelegatingSecurityContextCallable装饰的任务

@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 http://localhost:8080/ciao

响应为:

Ciao, user!

5.2.5 用DelegatingSecurityContextExecutorService转发安全上下文

当处理我们的代码在没有让框架知道它们的情况下启动的线程时,我们必须管理从安全上下文到下一个线程的细节传播。在第5.2.4节中,你应用了一种技术,通过利用任务本身来复制安全上下文中的细节。Spring Security提供了一些很好的实用类,如DelegatingSecurityContextRunnable和DelegatingSecurityContextCallable。这些类装饰了你异步执行的任务,也负责从安全上下文中复制细节,这样你的实现就可以从新创建的线程中访问这些细节。但我们还有第二个选择来处理安全上下文传播到新线程的问题,这就是管理从线程池而不是从任务本身的传播。在本节中,你将学习如何通过使用Spring Security提供的更多优秀的实用类来应用这种技术。

装饰任务的另一种方法是使用一种特殊类型的Executor。在下一个例子中,你可以看到任务仍然是一个简单的Callable,但线程仍然管理着安全上下文。安全上下文的传播之所以发生,是因为一个叫做DelegatingSecurityContextExecutorService的实现装饰了ExecutorService。DelegatingSecurityContextExecutorService还负责安全上下文的传播,如图5.11所示。

image-20230204104724881

图5.11 DelegatingSecurityContextExecutorService装饰了一个ExecutorService,并在提交任务前将安全上下文细节传播给下一个线程。

清单5.14中的代码显示了如何使用DelegatingSecurityContextExecutorService来装饰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();
    }
}

调用端点以测试DelegatingSecurityContextExecutorService是否正确委托了安全上下文。

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

注意 在与安全上下文的并发支持有关的类中,我建议你注意表5.1中介绍的那些。

Spring提供了各种实用类的实现,你可以在应用程序中使用这些实用类来管理创建自己的线程时的安全上下文。在5.2.4节中,你实现了DelegatingSecurityContextCallable。在本节中,我们使用DelegatingSecurityContextExecutorService。如果你需要为预定任务实现安全上下文的传播,那么你会很高兴听到Spring Security也为你提供了一个名为DelegatingSecurityContextScheduledExecutorService的装饰器。这种机制与我们在本节中介绍的DelegatingSecurityContextExecutorService类似,不同的是它装饰了ScheduledExecutorService,允许你与预定任务一起工作。

此外,为了提高灵活性,Spring Security为你提供了一个更抽象的装饰器版本,叫做DelegatingSecurityContextExecutor。这个类直接装饰了一个Executor,它是这个层次的线程池中最抽象的契约。当你希望能够用语言提供的任何选择来替换线程池的实现时,你可以选择它来设计你的应用程序。

表5.1 负责将安全上下文委托给独立线程的对象

描述
DelegatingSecurityContextExecutor 实现Executor接口,旨在对Executor对象进行装饰,使其具有将安全上下文转发给由其池子创建的线程的能力。
DelegatingSecurityContextExecutorService 实现ExecutorService接口,旨在装饰ExecutorService对象,使其具有将安全上下文转发到由其池创建的线程的能力。
DelegatingSecurityContextScheduledExecutorService 实现ScheduledExecutorService接口,旨在装饰ScheduledExecutorService对象,使其具有将安全上下文转发给由其池创建的线程的能力。
DelegatingSecurityContextRunnable 实现Runnable接口,代表一个在不同线程上执行的任务,不返回响应。与普通的Runnable相比,它还能够传播一个安全上下文,以便在新的线程上使用。
DelegatingSecurityContextCallable 执行Callable接口,代表一个在不同线程上执行的任务,最终会返回一个响应。与普通的Callable相比,它还能够传播一个安全上下文,以便在新的线程上使用。

5.3 了解HTTP Basic和基于表单的登录认证

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

在本节中,你会学到更多与HTTP Basic有关的配置。同时,我们还发现了一种新的认证方法,叫做formLogin。在本书的其余部分,我们将讨论其他认证方法,这些方法与不同类型的架构相匹配。我们将对这些方法进行比较,以便你了解最佳实践和反模式的认证方法。

5.3.1 使用和配置HTTP Basic

你知道HTTP Basic是默认的认证方法,我们已经在第三章的各种例子中观察到了它的工作方式。在这一节中,我们将增加有关这种认证方法配置的更多细节。

对于理论上的场景,HTTP Basic认证所带来的默认值是很好的。但在一个更复杂的应用中,你可能会发现需要定制其中的一些设置。例如,你可能想为认证过程失败的情况实现一个特定的逻辑。在这种情况下,你甚至可能需要在发回给客户端的响应上设置一些值。因此,让我们用实际的例子来考虑这些情况,以了解你如何实现这些。我想再次指出,你可以明确地设置这个方法,如下面的列表所示。你可以在项目sia-ch5-ex3中找到这个例子。
清单5.15 设置HTTP Basic认证方法

@Configuration
public class ProjectConfig
        extends WebSecurityConfigurerAdapter {
    
    
    @Override
    protected void configure(HttpSecurity http)
            throws Exception {
    
    
        http.httpBasic();
    }
}

你也可以用一个Customizer类型的参数来调用HttpSecurity实例的httpBasic()方法。这个参数允许你设置一些与认证方法有关的配置,例如,领域名称,如列表5.16所示。你可以把领域看作是一个使用特定认证方法的保护空间。关于完整的描述,请参考RFC 2617,网址是https:// tools.ietf.org/html/rfc2617。
清单 5.16 为验证失败的响应配置领域名称

@Override
protected void configure(HttpSecurity http) throws Exception {
    
    
    http.httpBasic(c -> {
    
    
    	c.realmName("OTHER");
    });
    http.authorizeRequests().anyRequest().authenticated();
}

清单5.16介绍了一个改变领域(Realm)名称的例子。使用的lambda表达式实际上是一个Customizer<HttpBasicConfigurer>类型的对象。HttpBasicConfigurer类型的参数允许我们调用realmName()方法来重命名领域。你可以使用带有-v标志的cURL来获得一个冗长的HTTP响应,其中领域名称确实被改变了。然而,请注意,只有当HTTP响应状态为401未授权时,你才能在响应中找到WWW-Authenticate头,而不是当HTTP响应状态为200 OK时。下面是对cURL的调用: curl -v http://localhost:8080/hello 调用的响应是

/

< WWW-Authenticate: Basic realm=“OTHER”

Spring Security中的Realm指的是一个安全特定域,或一个安全重点区域,认证和授权活动就在其中进行。它为系统提供了一个统一的方法来管理安全相关的信息,如用户证书和角色,并作为应用程序和底层安全系统(如LDAP、数据库或其他)之间的桥梁。换句话说,Realm作为认证和授权数据的来源,帮助应用程序和安全系统之间建立信任。

另外,通过使用自定义器,我们可以自定义认证失败的响应。如果你的系统的客户端在认证失败的情况下希望在响应中得到一些特定的东西,你就需要这样做。你可能需要添加或删除一个或多个头文件。或者你可以有一些逻辑来过滤正文,以确保应用程序不向客户端暴露任何敏感数据。

注意 对你暴露在系统外的数据要始终保持谨慎。最常见的错误之一(这也是OWASP十大漏洞的一部分)是暴露敏感数据。处理应用程序在认证失败时发送给客户端的细节,总是暴露机密信息的风险点。

为了定制认证失败的响应,我们可以实现一个AuthenticationEntryPoint。它的commence()方法接收HttpServletRequest、HttpServletResponse,以及导致认证失败的AuthenticationException。清单5.17演示了一种实现AuthenticationEntryPoint的方法,它为响应添加了一个头,并将HTTP状态设置为401未授权。

清单5.17 实现一个AuthenticationEntryPoint

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());
    }
}
/**
这段代码定义了一个名为CustomEntryPoint的类,它实现了AuthenticationEntryPoint接口。它重写了AuthenticationEntryPoint中的 commence() 方法。这个方法在用户试图访问一个需要认证的资源时,如果认证失败,将会被调用。

该方法中的代码为 HTTP 响应添加了一个名为 "message" 的 header,并发送 401 Unauthorized 错误状态。这意味着,如果用户试图访问该资源,但未经认证,则将会收到带有 "message" header 和 401 Unauthorized 错误状态码的 HTTP 响应。
**/

注意 AuthenticationEntryPoint接口的名称并没有反映出它在认证失败时的用途,这一点有点含糊。在Spring Security架构中,它直接被一个叫做ExceptionTranslationManager的组件使用,该组件处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。
你可以把ExceptionTranslationManager看作是Java异常和HTTP响应之间的一个桥梁。

清单5.18 设置自定义的AuthenticationEntryPoint

@Override
protected void configure(HttpSecurity http)
        throws Exception {
    
    
    http.httpBasic(c -> {
    
    
        c.realmName("OTHER");
        c.authenticationEntryPoint(new CustomEntryPoint());
    });
    http.authorizeRequests()
            .anyRequest()
            .authenticated();
}

如果你现在调用一个端点,使认证失败,你应该在响应中发现新添加的头:

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

调用的响应是

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

5.3.2 用基于表单的登录实现认证

当开发一个网络应用程序时,你可能希望呈现一个用户友好的登录表,用户可以在其中输入他们的证书。同样,你可能希望你的认证用户在登录后能够浏览网页,并且能够注销。对于一个小型的Web应用程序,你可以利用基于表单的登录方法。在本节中,你将学习如何为你的应用程序应用和配置这种认证方法。为了实现这一目标,我们编写了一个使用基于表单的登录的小型Web应用程序。图5.12描述了我们要实现的流程。本节中的例子是项目sia-ch5-ex4的一部分。

注意 我把这个方法和一个小型的Web应用程序联系起来,因为,这样的话,我们使用服务器端的会话来管理安全上下文。对于需要横向扩展的大型应用,使用服务器端会话来管理安全上下文是不可取的。我们将在第12章到第15章处理OAuth 2时更详细地讨论这些方面。

image-20230204231427330

图5.12 使用基于表单的登录。一个未经认证的用户被重定向到一个表单,在那里他们可以使用他们的凭证进行认证。一旦应用程序验证了他们的身份,他们就会被重定向到应用程序的主页上。

要把认证方法改为基于表单的登录,在配置类的configure(HttpSecurity http)方法中,不要使用httpBasic(),而是调用HttpSecurity参数的formLogin()方法。下面的列表介绍了这一变化。

清单5.19 将认证方式改为基于表单的登录方式

@Configuration
public class ProjectConfig
        extends WebSecurityConfigurerAdapter {
    
    
    @Override
    protected void configure(HttpSecurity http)
            throws Exception {
    
    
        http.formLogin();
        http.authorizeRequests().anyRequest().authenticated();
    }
}

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

image-20230205181112273

图5.14 Spring MVC流程的一个简单表示。调度器找到与给定路径相关的控制器动作,本例中是/home。在执行控制器动作后,视图被渲染,响应被送回客户端。

只要你不注册你的UserDetailsService,你就可以使用默认提供的凭证来登录。正如我们在第2章中所学到的,这就是用户名 "user "和一个UUID密码,当应用程序启动时,这个密码会被打印在控制台中。登录成功后,由于没有定义其他页面,你会被重定向到一个默认的错误页面。该应用程序依赖于我们在以前的例子中遇到的相同的认证架构。所以,像图5.14所示,你需要为应用程序的主页实现一个控制器。不同的是,我们不希望有一个简单的JSON格式的响应,而是希望端点返回HTML,可以被浏览器解释为我们的网页。正因为如此,我们选择坚持使用Spring MVC流程,在执行控制器中定义的动作后,从文件中渲染视图。图5.14展示了Spring MVC的流程,用于渲染应用程序的主页。

image-20230205181946333

图5.14 Spring MVC流程的一个简单表示。调度器找到与给定路径相关的控制器动作,本例中是/home。在执行控制器动作后,视图被渲染,响应被送回客户端。

为了给应用程序添加一个简单的页面,你首先要在项目的resources/static文件夹下创建一个HTML文件。我把这个文件称为home.html。在里面输入一些文字,之后你就可以在浏览器中找到。你可以只添加一个标题(例如,Welcome)。创建HTML页面后,控制器需要定义从路径到视图的映射。下面列出了控制器类中home.html页面的动作方法的定义。

清单5.20 为home.html页面定义控制器的动作方法

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

注意,这不是一个@RestController,而是一个简单的@Controller。正因为如此,Spring不会在HTTP响应中发送该方法返回的值。相反,它找到并渲染了名称为home.html的视图。
现在尝试访问/home路径,首先会问你是否要登录。登录成功后,你会被重定向到主页,那里会出现欢迎信息。现在你可以访问/logout路径,这应该会将你重定向到一个注销页面(图5.15)。

image-20230205183414556

图5.15 Spring Security为基于表单的登录认证方法配置的注销页面。

在尝试访问一个没有登录的路径后,用户会被自动重定向到登录页面。登录成功后,应用程序会将用户重定向到他们最初试图访问的路径。

如果该路径不存在,应用程序会显示一个默认的错误页面。formLogin()方法返回一个FormLoginConfigurer类型的对象,它允许我们进行定制工作。例如,你可以通过调用defaultSuccessUrl()方法来实现,如下面列表所示。

清单5.21为登录表格设置一个默认的成功URL

@Override
protected void configure(HttpSecurity http)
throws Exception {
    
    
    http.formLogin().defaultSuccessUrl("/home", true);
    http.authorizeRequests().anyRequest().authenticated();
}

如果你需要对此进行更深入的研究,使用AuthenticationSuccessHandler和AuthenticationFailureHandler对象可以提供更详细的定制方法。这些接口让你实现一个对象,通过这个对象你可以应用为认证而执行的逻辑。如果你想定制成功认证的逻辑,你可以定义一个AuthenticationSuccessHandler。onAuthenticationSuccess()方法接收Servlet请求、Servlet响应和Authentication对象作为参数。在清单5.22中,你会发现一个实现onAuthenticationSuccess()方法的例子,该方法可以根据登录用户的授予权限进行不同的重定向。

清单5.22 实现一个AuthenticationSuccessHandler

@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"))
            //如果 "读取 "权限不存在,返回一个空的Optional对象。
                        .findFirst();
        //如果 "读取 "权限存在,则重定向到/home
        if (auth.isPresent()) {
    
    
            httpServletResponse
                    .sendRedirect("/home");
        } else {
    
    
            httpServletResponse
                    .sendRedirect("/error");
        }
    }
}

在实际场景中,有些情况下,客户期望在认证失败的情况下得到某种格式的响应。他们可能会期望一个不同于401 Unauthorized的HTTP状态代码,或者在响应的正文中提供额外的信息。我在应用中发现的最典型的情况是发送一个请求标识符。这个请求标识符有一个唯一的值,用来在多个系统中追踪请求,在认证失败的情况下,应用程序可以在响应的主体中发送它。另一种情况是当你想对响应进行消毒,以确保应用程序不会将敏感数据暴露在系统之外。你可能想为失败的认证定义自定义逻辑,只需记录该事件,以便进一步调查。

如果你想定制应用程序在认证失败时执行的逻辑,你可以用一个AuthenticationFailureHandler实现来实现。例如,如果你想为任何失败的认证添加一个特定的头,你可以像清单5.23中所示那样做。当然,你也可以在这里实现任何逻辑。对于AuthenticationFailureHandler,onAuthenticationFailure()接收请求、响应和认证对象。

清单 5.23 实现一个 AuthenticationFailureHandler

@Component
public class CustomAuthenticationFailureHandler
        implements AuthenticationFailureHandler {
    
    
    @Override
    public void onAuthenticationFailure(
            HttpServletRequest httpServletRequest,
            HttpServletResponse httpServletResponse,
            AuthenticationException e) {
    
    
        httpServletResponse
                .setHeader("failed", LocalDateTime.now().toString());
    }
}

为了使用这两个对象,你需要在formLogin()方法返回的FormLoginConfigurer对象的configure()方法中注册它们。下面的列表显示了如何做到这一点。

清单5.24 在配置类中注册处理程序对象

@Configuration
public class ProjectConfig
        extends WebSecurityConfigurerAdapter {
    
    
    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler;
    @Override
    protected void configure(HttpSecurity http)
            throws Exception {
    
    
        http.formLogin()
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);
        http.authorizeRequests()
                .anyRequest().authenticated();
    }
}

现在,如果你试图用适当的用户名和密码使用HTTP Basic访问/home路径,你会得到一个状态为HTTP 302 Found的响应。

这个响应状态代码是应用程序告诉你它正试图进行重定向的方式。即使你提供了正确的用户名和密码,它也不会考虑这些,而会按照formLogin方法的要求,试图把你送到登录表单。然而,你可以改变配置,使其同时支持HTTP Basic和基于表单的登录方法,如下面的列表。

清单5.25 将基于表单的登录和HTTP Basic一起使用

@Override
    protected void configure(HttpSecurity http)
            throws Exception {
    
    
        http.formLogin()
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler)
                .and()
                .httpBasic();
        http.authorizeRequests()
                .anyRequest().authenticated();
    }

访问/home路径现在可以使用基于表单的登录和HTTP Basic认证方法。

curl -u user:cdd430f6-8ebc-49a6-9769-b0f3ce571d19 http://localhost:8080/home
响应为:
<h1>Welcome</h1>

总结

  • AuthenticationProvider是一个允许你实现自定义认证逻辑的组件。
  • 当你实现自定义认证逻辑时,保持责任的解耦是一个好的做法。对于用户管理,认证提供者委托给UserDetailsService,而对于密码验证的责任,认证提供者委托给PasswordEncoder。
  • SecurityContext在成功认证后会保留关于被认证实体的详细信息。
  • 你可以使用三种策略来管理安全上下文:MODE_THREADLOCAL、MODE_INHERITABLETHREADLOCAL和MODE_GLOBAL。
    根据你选择的模式,从不同线程访问安全上下文细节的工作方式不同。
  • 记住,当使用共享线程的本地模式时,它只适用于由Spring管理的线程。框架不会为不受其管理的线程复制安全上下文。
  • Spring Security为你提供了很好的实用类来管理你的代码所创建的线程,现在框架已经意识到这一点。为了管理你创建的线程的安全上下文,你可以使用 - DelegatingSecurityContextRunnable DelegatingSecurityContextCallable DelegatingSecurityContextExecutor
  • Spring Security通过基于表单的登录验证方法formLogin(),自动配置了一个用于登录的表单和一个用于注销的选项。在开发小型Web应用程序时,它是直接使用的。
  • formLogin认证方法是高度可定制的。此外,你可以将这种认证方式与HTTP Basic方法一起使用。

猜你喜欢

转载自blog.csdn.net/Learning_xzj/article/details/128998563