使用OPA实现Spring安全授权 | baeldung


在本教程中,我们将展示如何将 Spring Security 的授权决策外部化到 OPA——开放策略代理

跨应用程序的一个共同要求是能够根据策略做出某些决定。当这个策略足够简单并且不太可能改变时,我们可以直接在代码中实现这个策略,这是最常见的场景。
但是,在其他情况下,我们需要更大的灵活性。访问控制决策是典型的:随着应用程序变得越来越复杂,授予对给定功能的访问权限可能不仅取决于您是谁,还取决于请求的其他上下文方面。这些方面可能包括 IP 地址、时间和登录身份验证方法(例如:“记住我”、OTP)等。
此外,将上下文信息与用户身份相结合的规则应该易于更改,最好不会导致应用程序停机。这一要求自然会导致一个专用服务处理策略评估请求的架构。
这种灵活性的代价是在调用外部服务时增加了复杂性和性能损失。另一方面,我们可以在不影响应用程序的情况下发展甚至完全替换授权服务。此外,我们还可以与多个应用程序共享这个服务,从而在它们之间实现一致的授权模式。

什么是OPA
开放政策代理,简称OPA,是一个用Go开源实现的策略评估引擎。它最初是由Styra开发的,现在是CNCF的一个毕业项目。下面是这个工具的一些典型用途的清单。

  • Envoy授权过滤器
  • Kubernetes准入控制器
  • Terraform计划评估

安装OPA是非常简单的。只要下载适合我们平台的二进制文件,把它放在操作系统PATH的一个文件夹里,就可以了。我们可以用一个简单的命令来验证它是否正确安装。

$ opa version
Version: 0.39.0
Build Commit: cc965f6
Build Timestamp: 2022-03-31T12:34:56Z
Build Hostname: 5aba1d393f31
Go Version: go1.18
Platform: windows/amd64
WebAssembly: available

OPA评估用REGO编写的策略,REGO是一种经过优化的声明性语言,用于运行对复杂对象结构的查询。这些查询的结果然后由客户应用程序根据具体的使用情况来使用。在我们的案例中,对象结构是一个授权请求,我们将使用策略来查询结果,以授予对特定功能的访问。

需要注意的是,OPA的策略是通用的,不以任何方式与表达授权决策相联系。事实上,我们可以在其他传统上由Drools等规则引擎主导的场景中使用它。

编写策略
这是用REGO编写的一个简单的授权策略的样子:

package baeldung.auth.account

# Not authorized by default
default authorized = false

authorized = true {
    count(deny) == 0
    count(allow) > 0
}

# Allow access to /public
allow["public"] {
    regex.match(
"^/public/.*",input.uri)
}

# Account API requires authenticated user
deny[
"account_api_authenticated"] {
    regex.match(
"^/account/.*",input.uri)
    regex.match(
"ANONYMOUS",input.principal)
}

# Authorize access to account
allow[
"account_api_authorized"] {
    regex.match(
"^/account/.+",input.uri)
    parts := split(input.uri,
"/")
    account := parts[2]
    role := concat(
":",[ "ROLE_account", "read", account] )
    role == input.authorities[i]
}

首先要注意的是包的声明。OPA策略使用包来组织规则,它们在评估传入的请求时也起着关键作用,我们将在后面展示。我们可以在多个目录下组织策略文件。

接下来,我们定义实际的策略规则。

  • 一个默认的规则,以确保我们最终总会得到一个授权变量的值
  • 主聚合器规则,我们可以理解为 "当没有拒绝访问的规则和至少有一个允许访问的规则时,授权为真"
  • 允许和拒绝规则,每一条都表达了一个条件,如果匹配,将分别在允许或拒绝数组中增加一个条目。

对OPA策略语言的完整描述超出了本文的范围,但规则本身并不难读。在看这些规则时,有几件事要记住。
  • 形式为a :=b或a=b的语句是简单的赋值(不过它们不一样)。
  • a = b { ......conditions }或a { ......conditions }形式的语句意味着 "如果条件为真,则将b分配给a”
  • 在策略文件中的顺序出现是不相关的

除此之外,OPA还有一个丰富的内置函数库,为查询深度嵌套的数据结构进行了优化,同时还有更多熟悉的功能,如字符串操作、集合等等。

评估策略
让我们使用上一节中定义的策略来评估一个授权请求。在我们的例子中,我们将使用一个包含传入请求的一些片段的JSON结构来建立这个授权请求。

{
    "input": {
       
"principal": "user1",
       
"authorities": ["ROLE_account:read:0001"],
       
"uri": "/account/0001",
       
"headers": {
           
"WebTestClient-Request-Id": "1",
           
"Accept": "application/json"
        }
    }
}

请注意,我们已经将请求属性包装在一个单一的输入对象中。这个对象在策略评估过程中成为输入变量,我们可以用类似JavaScript的语法来访问它的属性。

为了测试我们的策略是否像预期的那样工作,让我们在本地以服务器模式运行OPA,并手动提交一些测试请求。

$ opa run -w -s src/test/rego

选项-s可以在服务器模式下运行,而-w可以自动重载规则文件。src/test/rego是包含我们样本代码中策略文件的文件夹。一旦运行,OPA将在本地端口8181监听API请求。如果需要,我们可以使用-a选项改变默认端口。

现在,我们可以使用curl或其他工具来发送请求。

$ curl --location --request POST 'http://localhost:8181/v1/data/baeldung/auth/account' \
--header 'Content-Type: application/json' \
--data-raw '{
   
"input": {
       
"principal": "user1",
       
"authorities": [],
       
"uri": "/account/0001",
       
"headers": {
           
"WebTestClient-Request-Id": "1",
           
"Accept": "application/json"
        }
    }
}'

注意/v1/data前缀后面的路径部分。它与策略的包名相对应,点被正斜线取代。

响应将是一个JSON对象,包含针对输入数据评估策略所产生的所有结果。

{
  "result": {
   
"allow": [],
   
"authorized": false,
   
"deny": []
  }
}

结果属性是一个包含由策略引擎产生的结果的对象。我们可以看到,在这种情况下,授权属性是假的。我们还可以看到,allow 和 deny 是空数组。这意味着没有特定的规则与输入相匹配。因此,主要的授权规则也没有匹配。

Spring Authorization Manager集成
现在我们已经看到了OPA的工作方式,我们可以继续前进,把它集成到Spring授权框架中。在这里,我们将专注于它的反应式Web变体,但一般的想法也适用于基于MVC的常规应用。

首先,我们需要实现ReactiveAuthorizationManager Bean,它使用OPA作为其后台。

@Bean
public ReactiveAuthorizationManager<AuthorizationContext> opaAuthManager(WebClient opaWebClient) {
    
    return (auth, context) -> {
        return opaWebClient.post()
          .accept(MediaType.APPLICATION_JSON)
          .contentType(MediaType.APPLICATION_JSON)
          .body(toAuthorizationPayload(auth,context), Map.class)
          .exchangeToMono(this::toDecision);
    };
}

在这里,注入的WebClient来自另一个Bean,我们从@ConfigurationPropreties类中预先初始化其属性。

处理管道委托给toAuthorizationRequest方法的职责是从当前的Authentication和AuthorizationContext中收集信息,然后建立一个授权请求的有效载荷。同样地,toAuthorizationDecision也会获取授权响应,并将其映射为一个AuthorizationDecision。

现在,我们使用这个Bean来构建一个SecurityWebFilterChain:

@Bean
public SecurityWebFilterChain accountAuthorization(ServerHttpSecurity http, @Qualifier("opaWebClient") WebClient opaWebClient) {
    return http
      .httpBasic()
      .and()
      .authorizeExchange(exchanges -> {
          exchanges
            .pathMatchers(
"/account/*")
            .access(opaAuthManager(opaWebClient));
      })
      .build();
}

我们只将我们自定义的AuthorizationManager应用于/account API。这种方法背后的原因是,我们可以很容易地扩展这个逻辑以支持多个策略文件,从而使它们更容易维护。例如,我们可以有一个配置,使用请求URI来选择一个合适的规则包,并使用这些信息来建立授权请求。

在我们的案例中,/account API本身只是一个简单的控制器/服务对,它返回一个用假余额填充的账户对象。

测试
最后但并非最不重要的是,让我们建立一个集成测试,把所有东西放在一起。首先,让我们确保 "快乐路径 "的工作。这意味着,给定一个认证的用户,他们应该能够访问自己的账户。

@Test
@WithMockUser(username = "user1", roles = { "account:read:0001"} )
void testGivenValidUser_thenSuccess() {
    rest.get()
     .uri(
"/account/0001")
      .accept(MediaType.APPLICATION_JSON)
      .exchange()
      .expectStatus()
      .is2xxSuccessful();
}

其次,我们还必须验证,一个经过认证的用户应该只能访问他们自己的账户。

@Test
@WithMockUser(username = "user1", roles = { "account:read:0002"} )
void testGivenValidUser_thenUnauthorized() {
    rest.get()
     .uri(
"/account/0001")
      .accept(MediaType.APPLICATION_JSON)
      .exchange()
      .expectStatus()
      .isForbidden();
}

最后,让我们也测试一下认证用户没有权限的情况。

@Test
@WithMockUser(username = "user1", roles = {} )
void testGivenNoAuthorities_thenForbidden() {
    rest.get()
      .uri(
"/account/0001")
      .accept(MediaType.APPLICATION_JSON)
      .exchange()
      .expectStatus()
      .isForbidden();
}

我们可以从 IDE 或命令行运行这些测试。请注意,无论哪种情况,我们都必须首先启动指向包含我们的授权策略文件的文件夹的 OPA 服务器。

结论
在本文中,我们展示了如何使用 OPA 将基于 Spring Security 的应用程序的授权决策外部化。像往常一样,完整的代码可以在 GitHub 上找到