Spring Security 6.3 中的新特性

1、简介

Spring Security 6.3 版在框架中引入了一系列安全增强功能。本文将带你了解其中一些最值得注意的特性,重点介绍它们的优点和用法。

2、被动式的 JDK 序列化支持

Spring Security 6.3 包含被动式的 JDK 序列化支持(Passive JDK Serialization Support),首先先来了解一下相关的问题和困扰。

2.1、Spring Security 的序列化设计

6.3 版本之前,Spring Security 对其类在不同版本之间通过 JDK Serialization 进行序列化和反序列化有严格的策略。这个限制是框架的一个有意设计决策,旨在确保安全性和稳定性。其理由是防止使用不同版本的Spring Security 反序列化在一个版本中序列化的对象时出现不兼容性和安全漏洞。

这一设计的一个关键方面是在整个 Spring Security 项目中使用全局 serialVersionUID。在 Java 中,序列化和反序列化过程使用唯一标识符 serialVersionUID 来验证加载的类是否与序列化对象完全一致。

通过为 Spring Security 的每个发布版本维护一个唯一的全局 serialVersionUID,该框架可确保一个版本的序列化对象无法使用另一个版本进行反序列化。这种方法有效地创建了一个版本屏障,防止了序列化版本 serialVersionUID 值不匹配的对象被反序列化。

例如,Spring Security 中的 SecurityContextImpl 类表示 Security Context 信息。该类的序列化版本包含该版本特有的 serialVersionUID。当尝试在不同版本的 Spring Security 中反序列化该对象时,serialVersionUID 不匹配会阻止该过程成功进行。

2.2、序列化设计带来的困扰

在优先增强安全性的同时,这种设计策略也带来了一些困扰。开发人员通常将 Spring SecuritySpring Session 等其他 Spring 库集成,以管理用户登录会话(Session)。这些会话包含重要的用户身份认证和 Security Context 信息,通常通过 Spring Security 类实现。此外,为了优化用户体验并提高应用的可扩展性,开发人员通常会在各种持久存储解决方案(包括数据库)中存储这些会话数据。

以下是序列化设计带来的一些麻烦。如果 Spring Security 版本发生变化,通过 Canary 发布流程升级应用可能会导致问题。在这种情况下,持久化会话(Session)信息无法反序列化,用户可能需要重新登录。

另一个问题出现在使用了 Spring Security 的 RMI(远程方法调用)架构中。例如,如果客户端应用在远程方法调用中使用了 Spring Security 类,则必须在客户端对其进行序列化,并在另一端对其进行反序列化。如果两个应用程序没有共享相同的 Spring Security 版本,调用就会失败,导致 InvalidClassException 异常。

2.3、解决方法

解决这一问题的典型方法如下。可以使用 JDK 序列化之外的其他序列化库,例如 Jackson (JSON)序列化。这样,就不用序列化 Spring Security 类,而是获取所需的详细信息的 JSON 表示,然后用 Jackson 将其序列化。

另一种方法是继承所需的 Spring Security 类(如 Authentication),并通过 readObjectwriteObject 方法明确实现自定义序列化支持。

2.4、Spring Security 6.3 中序列化的更改

从 6.3 版开始,类的序列化会进行与前一个次要版本的兼容性检查。这确保升级到新版本时可以无缝地反序列化 Spring Security 类。

3、授权

Spring Security 6.3 在 Spring Security 授权(Authorization)方面引入了一些值得注意的变化。

3.1、注解参数

Spring Security 的 “方法安全”(Method Security)支持元注解。我们可以根据应用的场景,使用自定义注解来提高其可读性。

例如,可以将 @PreAuthorize("hasRole('USER')") 简化为以下内容:

@Target({ ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('USER')")
public @interface IsUser {
    String[] value();
}

接下来,就可以在业务代码中使用 @IsUser 注解:

@Service
public class MessageService {
    @IsUser
    public Message readMessage() {
        return "Message";
    }
}

假设我们还有另一个角色,即 ADMIN。我们可以再为这个角色创建一个名为 @IsAdmin 的注解。然而,这样做是多余的。更合适的做法是将此元注解用作模板,并将角色作为注解参数。Spring Security 6.3 引入了定义此类元注解的功能。

要将元注解模板化,首先需要定义一个 PrePostTemplateDefaults Bean。

示例如下:

@Bean
PrePostTemplateDefaults prePostTemplateDefaults() {
    return new PrePostTemplateDefaults();
}

该 Bean 定义是模板解析所必需的。

接下来,为 @PreAuthorize 注解定义一个元注解 @CustomHasAnyRole,它可以接受 USERADMIN 角色:

@Target({ ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
// 使用 {value} 进行参数占位
@PreAuthorize("hasAnyRole({value})")
public @interface CustomHasAnyRole {
    String[] value();
}

我们可以通过提供角色来使用这个元注解:

@Service
public class MessageService {
    private final List<Message> messages;

    public MessageService() {
        messages = new ArrayList<>();
        messages.add(new Message(1, "Message 1"));
    }
    
    @CustomHasAnyRole({"'USER'", "'ADMIN'"})
    public Message readMessage(Integer id) {
        return messages.get(0);
    }

    @CustomHasAnyRole("'ADMIN'")
    public String writeMessage(Message message) {
        return "Message Written";
    }
    
    @CustomHasAnyRole({"'ADMIN'"})
    public String deleteMessage(Integer id) {
        return "Message Deleted";
    }
}

如上例,我们提供了 USERADMIN 这两个角色值作为注解参数。

3.2、返回值的安全性

Spring Security 6.3 的另一项强大新功能是使用 @AuthorizeReturnObject 注解确保 Domain 对象的安全。这一增强功能可对方法返回的对象进行授权检查,从而实现更细粒度的安全性,确保只有经过授权的用户才可以访问特定的 Domain 对象。

举个例子。假设我们有以下 Account 类,其中包含 ibanbalance 字段。要求只有具有 read 权限的用户才能检索账户 balance

public class Account {
    private String iban;
    private Double balance;

    // 构造器省略

    public String getIban() {
        return iban;
    }

    @PreAuthorize("hasAuthority('read')")
    public Double getBalance() {
        return balance;
    }
}

接下来,定义 AccountService 类,返回 Account 实例:

@Service
public class AccountService {
    @AuthorizeReturnObject
    public Optional<Account> getAccountByIban(String iban) {
        return Optional.of(new Account("XX1234567809", 2345.6));
    }
}

如上,我们使用了 @AuthorizeReturnObject 注解。Spring Security 确保 Account 实例只能被具有 read 权限的用户访问。

3.3、错误处理

上文介绍了如何使用 @AuthorizeReturnObject 注解来确保 Domain 对象的安全。一旦启用,未经授权的访问将导致 AccessDeniedException 异常。Spring Security 6.3 提供了 MethodAuthorizationDeniedHandler 接口来处理授权失败。

举个例子,我们可以扩展上节中的示例,使用 read 权限来保护 iban。然而,我们打算在任何未经授权的访问时提供一个屏蔽值,而不是返回 AccessDeniedException

定义一个 MethodAuthorizationDeniedHandler 接口的实现:

@Component
public class MaskMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler  {
    @Override
    public Object handleDeniedInvocation(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) {
        return "****";
    }
}

在上述代码段中,如果出现 AccessDeniedException,则返回我们提供的屏蔽值。

getIban() 方法上使用该 Handler 类,如下所示:

@PreAuthorize("hasAuthority('read')")
@HandleAuthorizationDenied(handlerClass=MaskMethodAuthorizationDeniedHandler.class)
public String getIban() {
    return iban;
}

4、检查密码是否被破解

Spring Security 6.3 提供了一个用于 检查密码是否被泄露 的实现。该实现会将提供的密码与一个已泄露密码数据库(pwnedpasswords.com)进行比对。因此,应用可以在注册时验证用户提供的密码。

首先,定义 HaveIBeenPwnedRestApiPasswordChecker Bean:

@Bean
public HaveIBeenPwnedRestApiPasswordChecker passwordChecker() {
    return new HaveIBeenPwnedRestApiPasswordChecker();
}

然后,使用此实现来检查用户提供的密码:

@RestController
@RequestMapping("/register")
public class RegistrationController {
    private final HaveIBeenPwnedRestApiPasswordChecker haveIBeenPwnedRestApiPasswordChecker;

    @Autowired
    public RegistrationController(HaveIBeenPwnedRestApiPasswordChecker haveIBeenPwnedRestApiPasswordChecker) {
        this.haveIBeenPwnedRestApiPasswordChecker = haveIBeenPwnedRestApiPasswordChecker;
    }

    @PostMapping
    public String register(@RequestParam String username, @RequestParam String password) {
        CompromisedPasswordDecision compromisedPasswordDecision = haveIBeenPwnedRestApiPasswordChecker.checkPassword(password);
        if (compromisedPasswordDecision.isCompromised()) {
        throw new IllegalArgumentException("Compromised Password.");
    }

        // TODO ...
        return "User registered successfully";
    }
}

5、OAuth 2.0 Token 交换授权

Spring Security 6.3 还引入了对 OAuth 2.0 Token Exchange(RFC 8693)授权方式的支持,允许客户端在保留用户身份的情况下交换 Token。该功能使得可以实现模拟用户身份的场景,其中资源服务器可以充当客户端以获取新的 Token。

让我们通过一个例子详细解释一下。

假设我们有一个名为 loan-service(贷款服务)的资源服务器,它为贷款账户提供各种 API。该服务是受保护的,客户端需要提供一个 Access Token,该 Token 必须具有贷款服务的受众(aud claim)。

现在让我们设想一下,loan-service 需要调用另一个资源服务 loan-product-service,该服务公开贷款产品的详细信息。loan-product-service 也是受保护的,需要的 Token 必须与 loan-product-service 的受众(aud )一致。由于这两个服务的受众不同,loan-service 的令牌不能用于 loan-product-service 服务。

在这种情况下,资源服务器 loan-service 应成为客户端,并为 loan-product-service 将现有 Token 交换为新 Token,同时保留原有 Token 身份。

Spring Security 6.3 为 Token 交换授权提供了名为 TokenExchangeOAuth2AuthorizedClientProviderOAuth2AuthorizedClientProvider 类的新实现。

6、总结

本文介绍了 Spring Security 6.3 中引入的各种新功能。其中值得注意的变化包括授权框架的增强、被动式 JDK 序列化支持和 OAuth 2.0 令牌交换支持。


Ref:https://www.baeldung.com/spring-security-6-3