第15章 授权:保护您的应用程序(ASP.NET Core in Action, 2nd Edition)

2023-03-07,,

本章包括

使用授权控制谁可以使用你的应用
对策略使用基于声明的授权
创建自定义策略以处理复杂的需求
根据所访问的资源授权请求
隐藏用户未经授权访问的Razor模板中的元素

在第14章中,我向您展示了如何通过添加身份验证向ASP.NET Core应用程序添加用户。通过身份验证,用户可以使用电子邮件地址和密码注册并登录到您的应用程序。每当你向应用程序添加身份验证时,你不可避免地会发现你希望能够限制某些用户可以做什么。确定用户是否可以在你的应用程序上执行给定操作的过程称为授权。

例如,在电子商务网站上,您可能有允许添加新产品和更改价格的管理员用户,允许查看已完成订单的销售用户,以及仅允许下单和购买产品的客户用户。

在本章中,我将向您展示如何在应用程序中使用授权来控制用户的行为。在第15.1节中,我会介绍授权,并将其放在您可能经历过的真实场景中:机场。我将描述事件的顺序,从办理登机手续到通过安检,再到进入机场休息室,您将在本章中了解这些事件与授权概念的关系。

在第15.2节中,我将展示授权如何适合ASPNETCore web应用程序,以及它如何与您在上一章中看到的ClaimsPrincipal类相关。您将看到如何在ASPNETCore应用程序中实施最简单的授权级别,确保只有经过身份验证的用户才能执行Razor Page或MVC操作。

我们将在第15.3节中通过增加政策的概念来扩展这种方法。通过这些,您可以为给定的经过身份验证的用户设置特定的要求,要求他们具有特定的信息,以便执行操作或Razor Page。

您将在ASPNETCore授权系统中广泛使用策略,因此在第15.4节中,我们将探讨如何处理更复杂的场景。您将了解授权要求和处理程序,以及如何将它们组合起来创建可应用于Razor Pages和操作的特定策略。

有时,用户是否被授权取决于他们试图访问的资源或文档。资源是您试图保护的任何东西,因此它可以是社交媒体应用程序中的文档或帖子。例如,您可以允许用户创建文档,或从其他用户读取文档,但只能编辑他们自己创建的文档。这种类型的授权被称为基于资源的授权,它是第15.5节的重点,您需要文档的详细信息来确定用户是否被授权。

在本章的最后一节中,我将展示如何将基于资源的授权方法扩展到Razor视图模板。这允许您修改UI以隐藏用户无权与之交互的元素。特别是,您将看到当用户无权编辑实体时,如何隐藏“编辑”按钮。

首先,我们将更仔细地研究授权的概念,它与身份验证的区别,以及它与您在机场中可能看到的现实概念的关系。

15.1 授权简介

在本节中,我将介绍授权,并讨论它与身份验证的比较。我用一个机场的真实案例来说明基于索赔的授权是如何运作的。

对于刚接触网络应用程序和安全的人来说,身份验证和授权有时会有点令人望而生畏。这两个词看起来如此相似,这当然没有帮助!这两个概念经常一起使用,但它们绝对不同:

Authentication (身份验证)——确定提出请求的人的过程
Authorization (授权)——确定是否允许请求的操作的过程

通常,首先进行身份验证,以便您知道是谁向您的应用程序发出请求。对于传统的web应用程序,您的应用程序通过检查用户登录时设置的加密cookie来验证请求(如前一章所示)。Web API通常使用头而不是cookie进行身份验证,但过程是相同的。

一旦请求经过身份验证,并且您知道是谁提出了请求,就可以确定是否允许他们在您的服务器上执行操作。这一过程称为授权,是本章的重点。

在我们深入代码并开始研究ASPNETCore中的授权之前,我将把这些概念放在您希望熟悉的真实场景中:在机场办理登机手续。要进入机场并登机,你必须经过几个步骤:第一步是证明你是谁(身份验证);以及检查是否允许继续(授权)的后续步骤。在简化形式中,这些可能如下所示:

    在值机柜台出示护照。领取登机牌。
    出示登机牌进入安检。通行安全。
    出示您的飞行常客卡进入航空公司休息室。进入休息室。
    出示登机牌登机。进入飞机。

显然,这些步骤(如图15.1所示)在现实生活中会有所不同(我没有飞行常客卡)。让我们进一步探索每一步。

图15.1 在机场登机时,您需要经过几个授权步骤。在每个授权步骤中,您必须以登机牌或飞行常客卡的形式提出索赔。如果您未经授权,访问将被拒绝。

当你到达机场时,你要做的第一件事就是去值机柜台。在这里,你可以购买机票,但要做到这一点,你需要提供护照来证明你是谁;您可以验证自己。如果你忘记了护照,你就无法进行身份验证,也无法继续。

一旦您购买了机票,就会收到一张登机牌,上面写着您乘坐的航班。我们假设它还包括一个登机牌号码。您可以将此数字视为与您的身份相关的附加声明。

定义:声明是关于用户的一条信息,由类型和可选值组成。

下一步是安全。保安会要求你出示登机牌以供检查,他们会用它来检查你是否有航班,从而允许你深入机场。这是一个授权过程:您必须拥有所需的申请(登机牌号码)才能继续。

如果您没有有效的BoardingPassNumber,有两种可能发生以下情况:

如果您尚未购买机票-您将被引导回值机柜台,在那里您可以验证并购买机票。此时,您可以再次尝试进入安全状态。
如果你有一张无效的机票,你将无法通过安检,你也无能为力。例如,如果你带着登机牌在航班上迟到一周,他们可能不会让你通过。(问我怎么知道!)

一旦你通过安检,你需要等待航班开始登机,但不幸的是没有空位。典型的幸运的是,你是一名常客,而且你已经累积了足够的里程数,可以获得金牌常客身份,因此你可以使用航空公司休息室。

你前往休息室,在那里你被要求向服务员出示你的金卡飞行常客卡,他们让你进去。这是另一个授权的例子。您必须持有价值为Gold的FrequentFlyerClass索赔才能继续。

注意:在本场景中,您已经使用了两次授权。每一次,您都提出了继续进行的要求。在第一种情况下,任何登机牌号码的存在都是足够的,而对于FrequentFlyerClass索赔,您需要黄金的具体价值。

当您登机时,您有一个最后的授权步骤,在该步骤中,您必须再次出示BoardingPassNumber声明。你早些时候提出了这一要求,但登机与进入安检截然不同,因此你必须再次提出。

整个场景与web应用程序的请求有很多相似之处:

这两个过程都以身份验证开始。
你必须证明你是谁,才能获得授权所需的索赔。
您使用授权来保护敏感行为,如进入安检和航空公司休息室。

我将在本章中重复使用这个机场场景,以构建一个简单的web应用程序,模拟您在机场中采取的步骤。我们已经概括介绍了授权的概念,因此在下一节中,我们将讨论授权在ASPNETCore中的工作原理。我们将从最基本的授权级别开始,确保只有经过身份验证的用户才能执行操作,并看看当您尝试执行这样的操作时会发生什么。

15.2 ASPNETCore中的授权

在本节中,您将看到上一节中描述的授权原则如何应用于ASPNETCore应用程序。您将了解[Authorize]属性和AuthorizationMiddleware在授权RazorPages和MVC操作请求中的作用。最后,您将了解防止未经身份验证的用户执行端点的过程,以及当用户未经授权时会发生什么。

ASPNETCore框架内置了授权,因此您可以在应用程序中的任何位置使用它,但在ASPNETCore 5.0中最常见的是通过AuthorizationMiddleware应用授权。AuthorizationMiddleware应该放在路由中间件和身份验证中间件之后,但要放在端点中间件之前,如图15.2所示。

注意:在ASPNETCore中,端点是指路由中间件选择的处理程序,在执行时将生成响应。它通常是RazorPage或WebAPI操作方法。

图15.2 授权发生在选择端点之后,请求经过身份验证之后,但在执行操作方法或Razor Page端点之前。

使用此配置,RoutingMiddleware根据请求的URL选择要执行的端点,例如RazorPage,如第5章所示。有关所选端点的元数据可用于路由中间件之后出现的所有中间件。此元数据包括有关端点的任何授权要求的详细信息,通常通过用[Authorize]属性修饰操作或Razor Page来附加。

AuthenticationMiddleware反序列化与请求相关联的加密cookie(或API的承载令牌),以创建ClaimsPrincipal。该对象被设置为请求的HttpContext.User,因此所有后续中间件都可以访问该值。它包含用户身份验证时添加到cookie中的所有声明。

现在我们来谈谈AuthorizationMiddleware。该中间件根据RoutingMiddleware提供的元数据检查所选端点是否有任何授权要求。如果端点有授权要求,AuthorizationMiddleware将使用HttpContext.User来确定当前请求是否被授权执行端点。

如果请求被授权,管道中的下一个中间件将正常执行。如果请求未被授权,AuthorizationMiddleware会使中间件管道短路,并且永远不会执行端点中间件。

注意:中间件在管道中的顺序非常重要。对UseAuthorization()的调用必须在UseRouting()和UseAuthentication()之后,但必须在UseEndpoints()之前。

ASPNETCore 3.0中的授权更改
ASPNETCore 3.0中的授权系统发生了重大变化。在此版本之前,AuthorizationMiddleware不存在。相反,[Authorize]属性作为MVC过滤器管道的一部分执行授权逻辑。
实际上,从在您的操作和RazorPages中使用授权的角度来看,从开发人员的角度来看并没有真正的区别。那为什么要改变呢?
新设计将AuthorizationMiddleware与端点路由(同时引入)结合使用,实现了更多场景。这些更改使将授权应用于非MVC/Razor Page端点变得更加容易。您将在第19章中了解如何创建这些类型的端点。您还可以在Microsoft的“从ASPNETCore 2.2迁移到3.0”文档的“授权”部分中阅读有关授权更改的更多信息:http://mng.bz/1rvj.

AuthorizationMiddleware负责应用授权要求,并确保只有授权用户才能执行受保护的端点。在15.2.1节中,您将了解如何应用最简单的授权要求,在15.2.2节中,将了解当用户未被授权执行端点时,框架如何响应。

15.2.1 防止匿名用户访问您的应用程序

当您考虑授权时,通常会考虑检查特定用户是否具有执行端点的权限。在ASPNETCore中,通常通过检查用户是否具有给定的声明来实现这一点。

还有一个我们还没有考虑过的更基本的授权级别——只允许经过身份验证的用户执行端点。这比索赔场景(我们稍后将讨论)更简单,因为只有两种可能性:

用户已通过身份验证——操作正常执行。
用户未经身份验证——用户无法执行端点。

您可以通过使用[Authorize]属性来实现这一基本级别的授权,我们在第13章讨论授权过滤器时看到了这一点。您可以将此属性应用于您的操作和RazorPages,如以下列表所示,以将其限制为仅限经过身份验证(登录)的用户。如果未经身份验证的用户尝试执行受[Authorize]属性保护的操作或Razor Page,他们将被重定向到登录页面。

清单15.1 对动作应用[Authorize]

public class RecipeApiController : ControllerBase
{
public IActionResult List() //任何人都可以执行此操作,即使未登录。
{
return Ok();
} [Authorize] //将[Authorize]应用于单个操作、整个控制器或Razor页面
public IActionResult View() //此操作只能由经过身份验证的用户执行。
{
return Ok();
}
}

将[Authorize]属性应用于端点会将元数据附加到该端点,表明只有经过身份验证的用户才能访问该端点。如图15.2所示,当RoutingMiddleware选择端点时,AuthorizationMiddleware可以使用该元数据。

您可以在操作范围、控制器范围、Razor Page范围或全局范围应用[Authorize]属性,如第13章所示。以这种方式应用了[Authorize]属性的任何操作或Razor Page只能由经过身份验证的用户执行。未经身份验证的用户将被重定向到登录页面。

提示:全局应用[Authorize]属性有几种不同的方法。您可以在我的博客上阅读不同的选项,以及何时选择哪个选项:http://mng.bz/opQp.

有时,特别是当您全局应用[Authorize]属性时,您可能需要在这个授权需求中戳出漏洞。如果您全局应用[授权]属性,则任何未经验证的请求都将重定向到应用程序的登录页面。但是,如果[Authorize]属性是全局的,那么当登录页面尝试加载时,您将未经身份验证并再次重定向到登录页面。现在你陷入了一个无限的重定向循环。

为了解决这个问题,可以通过将[AllowAnonymous]属性应用于操作或RazorPage来指定特定端点以忽略[Authorize]属性,如下所示。这允许未经身份验证的用户执行操作,因此可以避免否则会导致的重定向循环。

清单15.2 应用[AllowAnonymous]以允许未经身份验证的访问

[Authorize]    //应用于控制器范围,因此必须对控制器上的所有操作验证用户。
public class AccountController : ControllerBase
{
public IActionResult ManageAccount() //只有经过身份验证的用户才能执行ManageAccount。
{
return Ok();
} [AllowAnonymous] //[AllowAnonymous]覆盖[Authorize]以允许未经身份验证的用户。
public IActionResult Login() //匿名用户可以执行登录。
{
return Ok();
}
}

警告:如果全局应用[Authorize]属性,请确保将[AllowAnonymous]属性添加到登录操作、错误操作、密码重置操作以及需要未经身份验证的用户执行的任何其他操作中。如果您使用的是第14章中描述的默认Identity UI,则已经为您配置了该UI。

如果未经身份验证的用户试图执行受[Authorize]属性保护的操作,传统的web应用程序会将其重定向到登录页面。但Web API呢?对于更复杂的场景,即用户已登录但没有执行操作所需的声明,该如何处理?在15.2.2节中,我们将了解ASPNETCore身份验证服务如何为您处理所有这些问题。

15.2.2 处理未经授权的请求

在上一节中,您了解了如何将[Authorize]属性应用于一个操作,以确保只有经过身份验证的用户才能执行该操作。在第15.3节中,我们将查看更复杂的示例,这些示例要求您也具有特定的声明。在这两种情况下,您必须满足一个或多个授权要求(例如,您必须经过身份验证)才能执行操作。

如果用户满足授权要求,那么请求将不受阻碍地通过AuthorizationMiddleware,端点将在EndpointMiddleware中执行。如果它们不满足所选端点的要求,AuthorizationMiddleware将缩短请求。根据请求授权失败的原因,AuthorizationMiddleware会生成两种不同类型的响应之一,如图15.3所示:

图15.3 授权尝试的三种响应类型。在左边的示例中,请求包含一个身份验证cookie,因此用户在AuthenticationMiddleware中进行身份验证。AuthorizationMiddleware确认经过身份验证的用户可以访问所选端点,因此执行该端点。在中心示例中,请求没有经过身份验证,因此AuthorizationMiddleware生成一个质询响应。在正确的示例中,请求经过了身份验证,但用户没有执行端点的权限,因此生成了禁止响应。

Challenge——此响应表示用户未被授权执行操作,因为他们尚未登录。
Forbid——此响应表示用户已登录,但不符合执行操作的要求。例如,他们没有要求的索赔。

注意:如果您以基本形式应用[授权]属性,如第15.2.1节所述,您将只生成质询响应。在这种情况下,将为未经身份验证的用户生成质询响应,但始终会授权经过身份验证的使用者。

Challenge 或Forbid 响应生成的确切HTTP响应通常取决于您正在构建的应用程序的类型,因此您的应用程序使用的身份验证类型:带有Razor Pages的传统web应用程序或API应用程序。

对于使用cookie身份验证的传统web应用程序,例如当您使用ASPNETCore Identity时,如第14章所述,挑战和禁止响应会生成对应用程序中页面的HTTP重定向。

挑战响应表示用户尚未通过身份验证,因此他们被重定向到应用程序的登录页面。登录后,他们可以再次尝试执行受保护的资源。

禁止响应表示请求来自已登录的用户,但仍然不允许他们执行该操作。因此,用户被重定向到一个“禁止”或“拒绝访问”的网页,如图15.4所示,这会通知他们无法执行该操作或Razor page。

图15.4 使用cookie身份验证的传统web应用程序中的禁止响应。如果您没有执行Razor页面的权限,并且您已经登录,则会被重定向到“拒绝访问”页面。

前面的行为是传统web应用程序的标准行为,但web API通常使用不同的身份验证方法,如第14章所示。您通常会登录到第三方应用程序,该应用程序为客户端SPA或移动应用程序提供令牌,而不是直接登录并使用API。客户端应用程序在向Web API发出请求时发送此令牌。

使用令牌对Web API的请求进行身份验证基本上与使用cookie的传统Web应用程序相同;AuthenticationMiddleware反序列化cookie或令牌以创建ClaimsPrincipal。区别在于Web API如何处理授权失败。

当Web API应用程序生成质询响应时,它会向调用者返回401未授权错误响应。类似地,当应用程序生成禁止响应时,它会返回403禁止响应。传统的web应用程序基本上通过自动将未经授权的用户重定向到登录或“拒绝访问”页面来处理这些错误,但web API不这样做。由客户端SPA或移动应用程序检测这些错误并酌情处理。

提示:授权行为的差异是我通常建议为您的API和Razor页面应用程序创建单独的应用程序的原因之一-可以在同一个应用程序中同时使用这两个应用程序,但配置更复杂。

传统web应用程序和SPA之间的不同行为最初可能会令人困惑,但在实践中,您通常不必过于担心这一点。无论您是在构建WebAPI还是传统的MVC Web应用程序,在这两种情况下,应用程序中的授权代码看起来都是一样的。将[授权]属性应用于您的端点,并让框架为您处理这些差异。

注意:在第14章中,您看到了如何在Razor Pages应用程序中配置ASPNETCore标识。本章假设您也在构建Razor Pages应用程序,但如果您正在构建Web API,本章同样适用。授权策略以相同的方式应用,无论您构建的是哪种类型的应用。不同的只是未经授权请求的最终响应。

您已经了解了如何应用最基本的授权要求——仅将端点限制为经过身份验证的用户——但大多数应用程序需要比这种“要么全有要么全无”的方法更微妙的东西。

考虑第15.1节中的机场场景。通过认证(拥有护照)不足以让你通过安检。相反,您还需要一个特定的声明:BoardingPassNumber。在下一节中,我们将讨论如何在ASPNETCore中实现类似的需求。

15.3 使用基于claims-based的授权政策

在上一节中,您了解了如何要求用户登录以访问端点。在本节中,您将了解如何应用其他要求。您将学习使用授权策略执行基于声明的授权,以要求登录用户具有执行给定端点所需的声明。

在第14章中,您看到ASPNETCore中的身份验证以ClaimsPrincipal对象为中心,该对象表示用户。此对象包含一组声明,其中包含有关用户的信息,例如用户的姓名、电子邮件和出生日期。

例如,您可以使用这些按钮为每个用户自定义应用程序,方法是显示一条欢迎消息,通过名称称呼用户,但也可以使用声明进行授权。例如,您可能仅在用户具有特定声明(如BoardingPassNumber)或声明具有特定值(FrequentFlyerClass声明值为Gold)时才授权用户。
在ASPNETCore中,定义用户是否被授权的规则封装在策略中。

定义:策略定义了您必须满足的请求才能获得授权的要求。

可以使用[授权]属性将策略应用于操作,类似于您在15.2.1节中看到的方式。此列表显示了Razor Page PageModel,它代表了机场场景中的第一个授权步骤。AirportSecurity.cshtml Razor Page受[Authorize]属性保护,但您还提供了一个策略名称:“CanEnterSecurity”。

清单15.3 将授权策略应用于Razor页面

[Authorize("CanEnterSecurity")]    //使用[Authorize]应用“CanEnterSecurity”策略
public class AirportSecurityModel : PageModel
{
public void OnGet() //只有满足“CanEnterSecurity”策略的用户才能执行Razor Page。
{
}
}

如果用户尝试执行AirportSecurity.cshtml Razor Page,授权中间件将验证用户是否满足策略的要求(稍后我们将查看策略本身)。这给出了三种可能的结果之一:

用户满足策略中间件管道继续,Endpoint-middleware正常执行Razor Page。
用户未经身份验证用户将重定向到登录页面。
用户已通过身份验证,但不符合策略用户被重定向到“禁止”或“拒绝访问”页面。

这三种结果与您在机场通过安检时可能预期的现实结果相关:

你有有效的登机牌-你可以正常进入安检。
您没有登机牌-您将被重定向以购买机票。
您的登机牌无效(例如,您迟到了一天)-您被阻止进入。

清单15.3显示了如何使用[Authorize]属性将策略应用于RazorPage,但仍需要定义CanEnterSecurity策略。

您可以在Startup.cs的ConfigureServices方法中向ASPNETCore应用程序添加策略,如清单15.4所示。首先使用AddAuthorization()添加授权服务,然后可以通过对AuthorizationOptions对象调用AddPolicy()来添加策略。您可以通过调用提供的AuthorizationPolicyBuilder(此处称为policyBuilder)上的方法来定义策略本身。

清单15.4 使用AuthorizationPolicyBuilder添加授权策略

public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorization(options => //调用AddAuthorization以配置AuthorizationOptions
{
options.AddPolicy( //添加新策略
"CanEnterSecurity", //提供策略的名称
policyBuilder => policyBuilder //使用Authorization PolicyBuilder定义策略要求
.RequireClaim("BoardingPassNumber"));
});
//附加服务配置
}

当您调用AddPolicy时,您将为策略提供一个名称,该名称应与您在[Authorize]属性中使用的值相匹配,并定义策略的要求。在本例中,您有一个简单的要求:用户必须具有BoardingPassNumber类型的声明。如果用户有此声明,无论其价值如何,政策都将得到满足,用户将获得授权。

记住:声明是关于用户的信息,作为键值对。策略定义了成功授权的要求。策略可以要求用户拥有给定的声明,也可以指定更复杂的要求,如您稍后所见。

AuthorizationPolicyBuilder包含几种创建类似这样的简单策略的方法,如表15.1所示。例如,RequireClaim()方法的重载允许您指定声明必须具有的特定值。通过以下操作,您可以创建“BoardingPassNumber”声明值必须为“A1234”的保单:

policyBuilder => policyBuilder.RequireClaim("BoardingPassNumber", "A1234");

表15.1 AuthorizationPolicyBuilder上的简单策略生成器方法

方法

策略行为

RequireAuthenticatedUser()

所需用户必须经过身份验证。创建一个类似于默认[Authorize]属性的策略,其中您没有设置策略。

RequireClaim(claim, values)

用户必须具有指定的声明。如果提供,则声明必须是指定值之一。

RequireUsername(username)

用户必须具有指定的用户名。

RequireAssertion(function)

Executes the provided lambda function, which returns a bool, indicating whether the policy was satisfied.

基于角色的授权与基于声明的授权
如果您使用IntelliSense查看AuthorizationPolicyBuilder类型上可用的所有方法,您可能会注意到表15.1中没有提到的一个方法,RequireRole()。这是ASPNET早期版本中使用的基于角色的授权方法的残余,我不建议使用它。
在微软采用ASPNETCore和ASPNET最新版本使用的基于声明的授权之前,基于角色的授权是一种规范。用户被分配到一个或多个角色,如管理员或经理,授权涉及检查当前用户是否处于所需角色。
这种基于角色的授权方法在ASPNETCore中是可行的,但它主要用于遗留兼容性原因。建议采用基于声明的授权方式。除非您正在移植使用角色的遗留应用程序,否则我建议您接受基于声明的授权,并将这些角色抛在脑后。

您可以使用这些方法构建可以处理基本情况的简单策略,但通常需要更复杂的策略。如果您想创建一个强制只有18岁以上的用户才能执行端点的策略,该怎么办?

DateOfBirth声明提供了所需的信息,但没有一个正确的值,因此无法使用RequireClaim()方法。您可以使用RequireAssertion()方法,并提供一个根据出生日期声明计算年龄的函数,但这可能会很快变得混乱。

对于无法使用RequireClaim()方法轻松定义的更复杂的策略,我建议您采用不同的方法并创建自定义策略,如您将在下一节中看到的。

15.4 为授权创建自定义策略

您已经了解了如何通过要求特定声明或要求具有特定值的特定声明来创建策略,但通常情况下,要求会比这更复杂。在本节中,您将学习如何创建自定义授权要求和处理程序。您还将看到如何配置授权需求,其中有多种方法可以满足策略,其中任何一种都是有效的。

让我们回到机场的例子。您已经配置了通过安检的策略,现在您将配置控制您是否被授权进入航空公司休息室的策略。

如图15.1所示,如果您有价值为Gold的FrequentFlyerClass索赔,您可以进入休息室。如果这是唯一的要求,则可以使用AuthorizationPolicyBuilder创建如下策略:

options.AddPolicy("CanAccessLounge", policyBuilder => policyBuilder.RequireClaim("FrequentFlyerClass", "Gold");

但如果要求比这更复杂呢?例如,假设您年满18岁(根据出生日期声明计算),并且您是以下人员之一,则可以进入休息室:

您是一名黄金级常旅客(拥有价值为“黄金”的FrequentFlyerClass索赔)
您是航空公司的员工(拥有EmployeeNumber索赔)

如果您曾经被禁止进入休息室(您有IsBannedFromLounge声明),即使您满足其他要求,也不会被允许进入。

迄今为止,使用AuthorizationPolicyBuilder的基本用法无法实现这组复杂的需求。幸运的是,这些方法是一组构建块的包装,您可以组合这些构建块来实现所需的策略。

15.4.1 要求和处理程序:策略的构建块

ASPNETCore中的每个策略都包含一个或多个需求,每个需求都可以有一个或更多个处理程序。对于机场休息室示例,您有一个策略(“CanAccessLounge”)、两个要求(MinimumAgeRequirement和AllowedInLoungeRequirement)和几个处理程序,如图15.5所示。
要满足策略,用户必须满足所有要求。如果用户未能满足任何要求,授权中间件将不允许执行受保护的端点。在此示例中,必须允许用户进入休息室,并且必须年满18岁。

图15.5 一个策略可以有许多需求,每个需求都可以有许多处理程序。通过在策略中组合多个需求,并提供多个处理程序实现,您可以创建满足任何业务需求的复杂授权策略。

每个需求可以有一个或多个处理程序,这将确认满足了需求。例如,如图15.5所示,AllowedInLoungeRequirement有两个处理程序可以满足要求:

FrequentFlyerHandler
IsAirlineEmployeeHandler

如果用户满足其中一个处理程序,则满足AllowedInLoungeRequirement。您不需要满足某个需求的所有处理程序,您只需要一个。

注意:图15.5显示了第三个处理程序,BannedFromLoungeHandler,我将在第15.4.2节中介绍。它稍有不同,因为它只能满足要求,而不能满足要求。

您可以使用需求和处理程序来实现策略所需的大多数行为组合。通过组合需求的处理程序,可以使用逻辑OR验证条件:如果满足任何处理程序,则满足需求。通过组合需求,您创建了一个逻辑AND:必须满足所有需求才能满足策略,如图15.6所示。

提示:您还可以通过多次应用[Authorize]属性,将多个策略添加到Razor Page或action方法中;例如,[Authorize(“Policy1”),Authorize(“Policy 2”)]。必须满足所有策略才能授权请求。

图15.6 为了满足政策,必须满足所有要求。如果满足任何处理程序,则满足要求。

我强调了构成“CanAccessLounge”策略的要求和处理程序,因此在下一节中,您将构建每个组件并将其应用于机场示例应用程序。

15.4.2 创建具有自定义需求和处理程序的策略

您已经看到了构成自定义授权策略的所有部分,因此在本节中,我们将探讨“CanAccessLounge”策略的实现。

创建表示需求的授权需求

正如您所看到的,自定义策略可以有多个需求,但代码术语中的需求是什么?ASPNETCore中的授权需求是实现IAuthorizationRequirement接口的任何类。这是一个空白的标记界面,您可以将其应用于任何类,以表明它表示一个需求。

如果接口没有任何成员,您可能会想知道需求类需要什么样子。通常,它们是简单的POCO类。下面的列表显示了AllowedInLoungeRequirement,这是一个简单的需求。它没有属性或方法;它实现了所需的IAuthorizationRequirement接口。

清单15.5 允许的休息室要求

//接口将类标识为授权需求。
public class AllowedInLoungeRequirement
: IAuthorizationRequirement { }

这是最简单的需求形式,但对于他们来说,有一个或两个属性使需求更加通用也是很常见的。例如,您可以创建一个参数化的MinimumAgeRequirement,而不是创建高度特定的MustBe18YearsOldRequirement,如下表所示。通过将最低年龄作为需求的参数,您可以将该需求用于具有不同最低年龄要求的其他策略。

清单15.6 参数化MinimumAgeRequirement

public class MinimumAgeRequirement : IAuthorizationRequirement    //接口将类标识为授权需求。
{
public MinimumAgeRequirement(int minimumAge) //创建需求时提供最低年龄。
{
MinimumAge = minimumAge;
} public int MinimumAge { get; } //处理者可以使用暴露的最低年龄来确定是否满足要求。
}

要求是容易的部分。它们代表了策略的每个组成部分,必须满足这些组成部分才能使策略得到整体满足。

创建具有多个需求的策略

您已经创建了这两个需求,因此现在可以配置“CanAccessLounge”策略来使用它们。您可以像以前一样在Startup.cs的ConfigureServices方法中配置策略。清单15.7显示了如何通过创建每个需求的实例并将它们传递给AuthorizationPolicyBuilder来实现这一点。授权处理程序将在尝试授权策略时使用这些需求对象。

清单15.7 创建具有多个需求的授权策略

public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorization(options =>
{
//添加上一个用于传递安全性的简单策略
options.AddPolicy( "CanEnterSecurity", policyBuilder => policyBuilder
.RequireClaim(Claims.BoardingPassNumber));
//为机场休息室添加了一项新政策,称为CanAccessLounge
options.AddPolicy( "CanAccessLounge",
////添加每个IAuthorizationRequirement对象的实例
policyBuilder => policyBuilder.AddRequirements( new MinimumAgeRequirement(18),
new AllowedInLoungeRequirement()
));
});
// 附加服务配置
}

现在,您有一个名为“CanAccessLounge”的策略,它有两个要求,因此您可以使用[Authorize]属性将其应用于Razor Page或action方法,方法与您对“CanEnterSecurity”策略的做法完全相同:

[Authorize("CanAccessLounge")]
public class AirportLoungeModel : PageModel
{
   public void OnGet() { }
}

当请求被路由到AirportLounge.cshtml Razor页面时,授权中间件执行授权策略,并检查每个需求。但您在前面看到,需求纯粹是数据;它们指出了需要实现的内容,但没有描述必须如何实现。为此,您需要编写一些处理程序。

创建授权处理程序以满足您的需求

授权处理程序包含如何满足特定IAuthorizationRequirement的逻辑。执行时,处理程序可以执行以下三项之一:

将需求处理标记为成功
什么都不做
明确不符合要求

处理者应该实现AuthorizationHandler<T>,其中T是他们处理的需求类型。例如,下面的列表显示了AllowedInLoungeRequirement的处理程序,该处理程序检查用户是否具有名为FrequentFlyerClass的值为Gold的声明。

清单15.8 AllowedInLoungeRequirement的FrequencyFlyerHandler

//处理程序实现AuthorizationHandler<T>。
public class FrequentFlyerHandler : AuthorizationHandler<AllowedInLoungeRequirement>
{
//必须重写抽象HandleRequirementAsync方法。
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, //上下文包含诸如ClaimsPrincipal用户对象之类的详细信息。
AllowedInLoungeRequirement requirement //要处理的需求实例
)
{
if(context.User.HasClaim("FrequentFlyerClass", "Gold")) //检查用户是否具有FrequentFlyerClass声明
{
context.Succeed(requirement); //如果用户有必要的声明,则通过调用Succeede将需求标记为已满足。
}
return Task.CompletedTask; //如果不满足要求,则什么也不做。
}
}

该处理程序在功能上与您在第15.4节开头看到的简单RequireClaim()处理程序等效,但使用的是需求和处理程序方法。

当请求被路由到AirportLounge.cshtml Razor Page时,授权中间件会看到端点上带有“CanAccessLounge”策略的[Authorize]属性。它循环遍历策略中的所有需求,以及每个需求的所有处理程序,并为每个调用HandleRequirementAsync方法。

授权中间件将当前AuthorizationHandlerContext和要检查的需求传递给每个处理程序。当前正在授权的ClaimsPrincipal作为User属性在上下文中公开。在清单15.8中,FrequentFlyerHandler使用上下文检查一个名为FrequentFlyer-Class的声明,该声明具有Gold值,如果存在,则表示用户可以通过调用Successed()进入航空公司休息室。

注意:处理程序通过调用context.Sceeded()并将需求作为参数传递,将需求标记为成功满足。

当用户没有声明时,注意行为很重要。在这种情况下,FrequentFlyerHandler不会执行任何操作(它返回一个完成的Task以满足方法签名)。

注意:请记住,如果任何与需求相关联的处理程序都通过了,那么需求就是成功的。只有一个处理程序成功才能满足要求。

这种行为是授权处理程序的典型行为,您可以调用context.Sceeded()或不执行任何操作。下面的列表显示了IsAirlineEmployeeHandler的实现,它使用类似的声明检查来确定是否满足需求。

清单15.9 IsAirlineEmployeeHandler

//处理程序实现AuthorizationHandler<T>。
public class IsAirlineEmployeeHandler : AuthorizationHandler<AllowedInLoungeRequirement>
{
//必须重写抽象HandleRequirementAsync方法。
protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, AllowedInLoungeRequirement requirement)
{
if(context.User.HasClaim(c => c.Type == "EmployeeNumber")) //检查用户是否具有EmployeeNumber声明
{
context.Succeed(requirement); //如果用户有必要的声明,则通过调用Succeede将需求标记为已满足。
}
}
return Task.CompletedTask; //如果不满足要求,则什么也不做。
}

提示:可以编写可用于多个需求的非常通用的处理程序,但我建议只处理一个需求。如果需要提取一些通用功能,请将其移动到外部服务并从两个处理程序中调用。

授权处理程序的这种模式很常见,但在某些情况下,您可能需要检查失败条件,而不是检查成功条件。在机场的例子中,你不想授权之前被禁止进入休息室的人,即使他们可以进入。

您可以通过使用上下文中公开的context.Fail()方法来处理这个场景,如下表所示。在处理程序中调用Fail()将始终导致需求失败,从而导致整个策略失败。只有当您希望保证失败时,才应该使用它,即使其他处理程序表示成功。

清单15.10 在处理程序中调用context.Fail()以使需求失败

//处理程序实现AuthorizationHandler<T>。
public class BannedFromLoungeHandler : AuthorizationHandler<AllowedInLoungeRequirement>
{
//必须重写抽象HandleRequirementAsync方法。
protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, AllowedInLoungeRequirement requirement)
{
if(context.User.HasClaim(c => c.Type == "IsBanned")) //检查用户是否具有IsBanned声明
{
context.Fail(); //如果用户有索赔,则通过调用fail使要求失败。整个政策将失败。
} return Task.CompletedTask; //如果找不到索赔,什么也不做。
}
}

在大多数情况下,处理程序要么调用Succeede(),要么什么都不做,但当您需要kill开关来保证不满足需求时,Fail()方法非常有用。

注意:无论处理程序是否调用Success()、Fail(),授权系统都将始终执行某个需求的所有处理程序和策略的所有需求,因此您可以确保始终调用处理程序。

完成应用程序授权实现的最后一步是向DI容器注册授权处理程序,如以下列表所示。

清单15.11 在DI容器中注册授权处理程序

public void ConfigureServices(IServiceCollection services)
{
services.AddAuthorization(options =>
{
options.AddPolicy( "CanEnterSecurity", policyBuilder => policyBuilder
.RequireClaim(Claims.BoardingPassNumber)); options.AddPolicy(
"CanAccessLounge",
policyBuilder => policyBuilder.AddRequirements( new MinimumAgeRequirement(18),
new AllowedInLoungeRequirement()
));
});
services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
services.AddSingleton<IAuthorizationHandler, FrequentFlyerHandler>(); services
.AddSingleton<IAuthorizationHandler, BannedFromLoungeHandler>(); services
.AddSingleton<IAuthorizationHandler, IsAirlineEmployeeHandler>();
// 附加服务配置
}

对于这个应用程序,处理程序没有任何构造函数依赖关系,所以我已经将它们注册为容器的单例。如果处理程序具有作用域或暂时依赖关系(例如EF Core DbContext),则可能需要将它们注册为作用域。

注意:如第10章所述,服务注册的生存期为瞬时、作用域或单例。

您可以以多种方式组合策略、需求和处理程序的概念,以实现应用程序中的授权目标。本节中的示例虽然经过精心设计,但通过创建策略并酌情应用[Authorize]属性,演示了需要在操作方法或Razor Page级别以声明方式应用授权的每个组件。

除了将[Authorize]属性显式应用于操作和Razor Pages之外,您还可以全局配置它,以便将策略应用于应用程序中的每个Razor Page或控制器。此外,对于Razor Pages,您可以将不同的授权策略应用于不同的文件夹。您可以阅读有关使用Microsoft“ASPNETCore中的Razor Pages授权约定”文档中的约定应用授权策略的更多信息:http://mng.bz/nMm2。

然而,有一个领域[Authorize]属性不足:基于资源的授权。[Authorize]属性将元数据附加到端点,因此授权中间件可以在执行端点之前授权用户,但是如果您需要在操作方法或Razor Page处理程序期间授权该操作呢?

在文档或资源级别应用授权时,这很常见。如果只允许用户编辑他们创建的文档,那么您需要先加载文档,然后才能确定是否允许用户编辑文档!这对于声明性[Authorize]属性方法来说并不容易,因此必须使用另一种命令式方法。在下一节中,您将看到如何在RazorPage处理程序中应用这种基于资源的授权。

15.5 利用资源授权控制访问

在本节中,您将了解基于资源的授权。当您需要了解受保护资源的详细信息以确定用户是否获得授权时,使用此选项。您将学习如何使用IAuthorizationService手动应用授权策略,以及如何创建基于资源的AuthorizationHandlers。

基于资源的授权是应用程序的一个常见问题,尤其是当用户可以创建或编辑某种文档时。考虑您在前三章中构建的配方应用程序。该应用程序允许用户创建、查看和编辑食谱。

到目前为止,每个人都可以创建新的食谱,任何人都可以编辑任何食谱,即使他们还没有登录。现在你想添加一些额外的行为:

只有经过身份验证的用户才能创建新配方。
您只能编辑创建的配方。

您已经看到了如何实现这些要求中的第一个:用[Authorize]属性修饰Create.cshtmlRazorPage,而不指定策略,如清单所示。这将迫使用户在创建新配方之前进行身份验证。

清单15.12 向Create.cshtml Razor页面添加AuthorizeAttribute

[Authorize]  //用户必须经过身份验证才能执行Create.cshtml Razor页面。
public class CreateModel : PageModel
{
[BindProperty]
public CreateRecipeCommand Input { get; set; } //所有页面处理程序都受到保护。您只能将[Authorize]应用于PageModel,而不能应用于处理程序。
public void OnGet()
{
Input = new CreateRecipeCommand();
} public async Task<IActionResult> OnPost()
{
// 为简洁起见,未显示方法体
}
}

提示:与所有过滤器一样,您只能将[Authorize]属性应用于Razor Page,而不能应用于单个页面处理程序。该属性适用于Razor page中的所有页面处理程序。

添加[Authorize]属性可以满足第一个需求,但不幸的是,使用您目前所看到的技术,您无法满足第二个需求。您可以应用允许或拒绝用户编辑所有食谱的策略,但目前没有简单的方法来限制这一点,以便用户只能编辑自己的食谱。

为了找出谁创建了配方,您必须首先从数据库中加载它。只有这样,您才能尝试授权用户,同时考虑到特定的配方(资源)。下面的列表显示了一个部分实现的页面处理程序,显示了它的外观,其中在加载Recipe对象后,授权在方法的中途发生。

清单15.13 在授权请求之前,Edit.cshtml页面必须加载Recipe

public IActionResult OnGet(int id)  //要编辑的配方的id由模型绑定提供。
{
//You must load the Recipe from the database before you know who created it.
var recipe = _service.GetRecipe(id);
var createdById = recipe.CreatedById; //您必须授权当前用户验证是否允许他们编辑此特定配方。
//根据createdById授权用户
if(isAuthorized)
{
return View(recipe); //只有当用户获得授权时,操作方法才能继续。
}
}

您需要访问资源(在本例中是Recipe实体)来执行授权,因此声明性的[Authorize]属性无法帮助您。在第15.5.1节中,您将看到处理这些情况以及在操作方法或Razor Page中应用授权所需的方法。

警告:在URL中暴露实体的整数ID时要小心,如清单15.13所示。用户可以通过修改URL中的ID来访问不同的实体,从而编辑每个实体。确保应用授权检查,否则可能会暴露一个称为不安全直接对象引用(IDOR)的安全漏洞。

15.5.1 使用IAuthorizationService手动授权请求

迄今为止,所有的授权方法都是声明性的。应用[Authorize]属性,无论是否使用策略名称,并让框架自行执行授权。

对于这个配方编辑示例,您需要使用命令式授权,因此您可以在从数据库加载配方后授权用户。您需要自己编写一些授权代码,而不是使用标记“授权此方法”。

定义:声明式和命令式是两种不同的编程风格。声明式编程描述了您试图实现的目标,并让框架知道如何实现。命令式编程描述如何通过提供所需的每个步骤来实现目标。

ASPNETCore公开IAuthorizationService,您可以将其注入Razor Pages和控制器中进行强制授权。下面的列表显示了如何更新Edit.cshtml Razor页面(部分如清单15.13所示)以使用IAuthorizationService并验证是否允许继续执行该操作。

清单15.14 使用IAuthorizationService进行基于资源的授权

[Authorize]  //只有经过身份验证的用户才能编辑配方。
public class EditModel : PageModel
{
[BindProperty]
public Recipe Recipe { get; set; } private readonly RecipeService _service;
private readonly IAuthorizationService _authService; //IAuthorizationService使用DI注入到类构造函数中。 public EditModel( RecipeService service, IAuthorizationService authService)
{
_service = service;
_authService = authService;
} public async Task<IActionResult> OnGet(int id)
{
Recipe = _service.GetRecipe(id); //从数据库加载配方。
//调用IAuthorization-Service,提供ClaimsPrincipal、资源和策略名称
var authResult = await _authService.AuthorizeAsync(User, Recipe, "CanManageRecipe");
if (!authResult.Succeeded)
{
return new ForbidResult(); //如果授权失败,则返回“禁止”结果
}
return Page(); //如果授权成功,则继续显示Razor页面
}
}

IAuthorizationService公开了一个AuthorizeAsync方法,它需要三件事来授权请求:

ClaimsPrincipal用户对象,作为用户在PageModel上公开
正在授权的资源:配方
要评估的策略:“CanManageRecipe”

授权尝试返回AuthorizationResult对象,该对象指示尝试是否通过Succeeded属性成功。如果尝试不成功,您应该返回一个新的ForbidResult,它将被转换为HTTP 403 Forbidden响应,或者将用户重定向到“拒绝访问”页面,具体取决于您是使用Razor Pages还是web API构建传统的web应用程序。

注:如15.2.2节所述,生成的响应类型取决于配置的身份验证服务。Razor Pages使用的默认身份配置生成重定向。通常与Web API一起使用的JWT承载令牌认证会生成HTTP 401和403响应。

您已经在Edit.cshtml Razor页面本身中配置了强制授权,但仍需要定义用于授权用户的“CanManageRecipe”策略。这与声明性授权的过程相同,因此必须执行以下操作:

通过调用AddAuthorization()在ConfigureServices中创建策略
定义策略的一个或多个要求
为每个需求定义一个或多个处理程序
在DI容器中注册处理程序

除了处理程序之外,这些步骤都与带有[Authorize]属性的声明性授权方法相同,因此我只在这里简要介绍它们。

首先,您可以创建一个简单的IAuthorizationRequirement。与许多需求一样,这不包含任何数据,只是实现了标记器接口。

public class IsRecipeOwnerRequirement : IAuthorizationRequirement { }

在ConfigureServices中定义策略同样简单,因为您只有一个需求。请注意,到目前为止,这些代码中没有任何特定的资源:

public void ConfigureServices(IServiceCollection services)
{
  services.AddAuthorization(options => { options.AddPolicy("CanManageRecipe", policyBuilder =>
    policyBuilder.AddRequirements(new IsRecipeOwnerRequirement()));
  });
}

你已经走到一半了;现在您需要做的就是为IsRecipeOwnerRequirement创建一个授权处理程序,并将其注册到DI容器中。

15.5.2 创建基于资源的AuthorizationHandler

基于资源的授权处理程序与您在第15.4.2节中看到的授权处理器实现基本相同。唯一的区别是处理程序还可以访问被授权的资源。

要创建基于资源的处理程序,您应该从Authorization-handler<TRequision,TResource>基类派生,其中TRequisition是要处理的需求类型,而TResource是您在调用IAuthorizationService时提供的资源类型。将其与之前实现的AuthorizationHandler<T>类进行比较,在那里您只指定了需求。

此列表显示了配方应用程序的处理程序实现。您可以看到,您已将需求指定为IsRecipeOwnerRequirement,将资源指定为Recipe,并实现了HandleRequirementAsync方法。

清单15.15 基于资源的授权的IsRecipeOwnerHandler

//实现必要的基类,指定需求和资源类型
public class IsRecipeOwnerHandler :
AuthorizationHandler<IsRecipeOwnerRequirement, Recipe>
{
//使用DI注入UserManager<T>类的实例
private readonly UserManager<ApplicationUser> _userManager; public IsRecipeOwnerHandler(
UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, IsRecipeOwnerRequirement requirement,
Recipe resource) //除了上下文和需求之外,还提供了资源实例。
{
var appUser = await _userManager.GetUserAsync(context.User);
if(appUser == null) //如果未通过身份验证,appUser将为空。
{
return;
}
if(resource.CreatedById == appUser.Id) //检查当前用户是否通过检查CreatedById属性创建了配方
{
context.Succeed(requirement); //如果用户创建了文档,则要求成功;否则,什么也不做。
}
}
}

这个处理程序比您之前看到的示例稍微复杂一些,主要是因为您使用了一个附加服务UserManager<>来从请求中加载基于ClaimsPrincipal的ApplicationUser实体。

注意:在实践中,ClaimsPrincipal可能已经将Id添加为索赔,因此在这种情况下不需要额外的步骤。此示例显示了需要使用依赖注入服务时的一般模式。

另一个重要区别是HandleRequirementAsync方法提供了Recipe资源作为方法参数。这与在IAuthorizationService上调用AuthorizeAsync时提供的对象相同。您可以使用此资源验证当前用户是否创建了它;否则你什么都不做。

最后一项任务是将IsRecipeOwnerHandler添加到DI容器中。您的处理程序使用另一个依赖项UserManager<>,它使用EF Core,因此您应该将处理程序注册为作用域服务:

services.AddScoped<IAuthorizationHandler,  IsRecipeOwnerHandler>();

提示:如果您想知道如何将处理程序注册为作用域还是单例,请回想第10章。本质上,如果您有作用域依赖项,则必须将处理程序注册为作用域;否则singleton是好的。

一切就绪后,您可以对应用程序进行旋转。如果您试图通过单击配方上的edit按钮来编辑您没有创建的配方,那么您将被重定向到登录页面(如果您尚未通过身份验证),或者会出现一个“拒绝访问”页面,如图15.7所示。

图15.7 如果您已登录但未被授权编辑配方,您将被重定向到“拒绝访问”页面。如果您未登录,将重定向到登录页面。

通过使用基于资源的授权,您可以制定更细粒度的授权要求,这些要求可以在单个文档或资源级别应用。您不仅可以授权用户可以编辑任何配方,还可以授权用户是否可以编辑此配方。

基于资源的授权与业务逻辑检查
使用ASPNETCore框架的基于资源的授权方法的价值主张,基于业务逻辑的检查(如清单15.13所示)。使用IAuthorizationService和授权基础结构会增加对ASPNETCore框架的显式依赖,如果在域模型服务中执行授权检查,则可能不希望使用该框架。

这是一个有效的问题,没有简单的答案。我倾向于在域内进行简单的业务逻辑检查,而不依赖框架的授权基础设施,以使我的域更易于测试和独立于框架。但这样做会失去这样一个框架的一些好处:

IAuthorizationService使用声明性策略,即使您强制调用授权框架。
您可以将授权操作的需求与实际需求分离开来。
您可以很容易地依赖外围服务和请求的属性,这在业务逻辑检查中可能更困难(或不可取)。

您可以在业务逻辑检查中获得这些好处,但这通常也需要创建大量的基础设施,因此您失去了保持简单的许多好处。哪种方法最好取决于应用程序设计的具体情况,并且可能会同时使用这两种方法。

到目前为止,您看到的所有授权技术都集中于服务器端检查。[授权]属性和基于资源的授权方法都侧重于阻止用户在服务器上执行受保护的操作。从安全的角度来看,这很重要,但还有一个方面你也应该考虑:当用户没有权限时的用户体验。

您已经保护了在服务器上执行的代码,但可以说,如果不允许用户编辑配方,编辑按钮就不应该对用户可见!在下一节中,我们将研究如何通过在视图模型中使用基于资源的授权来有条件地隐藏Edit按钮。

15.6 对未经授权的用户隐藏Razor模板中的元素

到目前为止,您看到的所有授权代码都围绕着保护服务器端的操作方法或Razor Pages,而不是修改用户的UI。这一点很重要,应该成为您向应用程序添加授权的起点。

警告:恶意用户很容易绕过您的UI,因此务必始终在服务器上授权您的操作和Razor Pages,而不要仅在客户端上授权。

然而,从用户体验的角度来看,如果按钮或链接看起来是可用的,但单击时会显示一个“拒绝访问”页面,这是不友好的。更好的体验是禁用链接,或者根本看不到链接。

您可以在自己的Razor模板中通过多种方式实现这一点。在本节中,我将向您展示如何向PageModel添加一个名为CanEditRecipe的附加属性,Razor视图模板将使用该属性来更改呈现的HTML。

提示:另一种方法是使用@inject指令将IAuthorizationService直接注入到视图模板中,正如您在第10章中所看到的那样,但您应该更希望在页面处理程序中保持这样的逻辑。

完成后,呈现的HTML将看起来与您创建的配方相同,但当查看其他人创建的配方时,Edit按钮将隐藏,如图15.8所示。

图15.8 虽然您创建的配方的HTML显示不变,但当您查看其他用户创建的配方时,编辑按钮将隐藏。

下面的列表显示了View.cshtmlRazorPage的PageModel,它用于呈现图15.8所示的配方页面。正如您已经看到的基于资源的授权,您可以使用IAuthorizationService通过调用AuthorizeAsync来确定当前用户是否具有编辑配方的权限。然后可以将该值设置为PageModel上的一个附加属性,称为CanEditRecipe。

清单15.16 在View.cshtml Razor页面中设置CanEditRecipe属性

public class ViewModel : PageModel
{
public Recipe Recipe { get; set; }
public bool CanEditRecipe { get; set; } //CanEditRecipe属性将用于控制是否呈现“编辑”按钮。 private readonly RecipeService _service;
private readonly IAuthorizationService _authService; public ViewModel(
RecipeService service, IAuthorizationService authService)
{
_service = service;
_authService = authService;
}
public async Task<IActionResult> OnGetAsync(int id)
{
Recipe = _service.GetRecipe(id); //加载用于IAuthorizationService的配方资源
//验证用户是否有权编辑配方
var isAuthorised = await _authService.AuthorizeAsync(User, recipe, "CanManageRecipe");
CanEditRecipe = isAuthorised.Succeeded; //根据需要设置PageModel上的CanEditRecipe属性 return Page();
}
}

不要阻止Razor Page的执行(正如前面在Edit.cshtml页面处理程序中所做的那样),而是使用对AuthorizeAsync的调用结果来设置PageModel上的CanEditRecipe值。然后,您可以对View.chstml Razor模板进行简单的更改:在Edit链接的呈现周围添加if子句。

@if(Model.CanEditRecipe)
{
  <a asp-page="Edit" asp-route-id="@Model.Id" class="btn btn-primary">Edit</a>
}

这确保只有能够执行Edit.cshtml Razor页面的用户才能看到该页面的链接。

警告:if子句表示除非用户创建了配方,否则不会显示Edit链接,但恶意用户仍然可以绕过您的UI。在Edit.cshtml页面处理程序中保留服务器端授权检查,以防止这些规避尝试,这一点很重要。

通过最后的更改,您已经完成了向配方应用程序添加授权。匿名用户可以浏览其他人创建的食谱,但必须登录才能创建新的食谱。此外,经过身份验证的用户只能编辑他们创建的食谱,他们不会看到其他人食谱的编辑链接。

授权是大多数应用程序的一个关键方面,因此从早期就记住这一点很重要。尽管可以稍后添加授权,正如您在配方应用程序中所做的那样,但通常最好在应用程序开发过程中尽早考虑授权,而不是稍后。

在下一章中,我们将从不同的角度看您的ASPNETCore应用程序。我们将不再关注应用程序背后的代码和逻辑,而是关注如何为生产准备应用程序。您将看到如何指定应用程序使用的URL,以及如何发布应用程序,以便它可以托管在IIS中。最后,您将了解客户端资产的绑定和缩小,为什么您应该关注,以及如何在ASPNETCore中使用BundlerMinifier。

总结

身份验证是确定用户身份的过程。它不同于授权,即确定用户可以做什么的过程。身份验证通常发生在授权之前。
您可以在应用程序的任何部分使用授权服务,但它通常通过调用UseAuthorization()使用AuthorizationMiddleware来应用。这应该放在调用UseRouting()和UseAuthentication()之后,以及调用UseEndpoints()之前,以便正确操作。
您可以通过应用[Authorize]属性来保护Razor Pages和MVC操作。路由中间件将属性的存在记录为所选端点的元数据。授权中间件使用此元数据来确定如何授权请求。
最简单的授权形式要求在执行操作之前对用户进行身份验证。您可以通过将[Authorize]属性应用于Razor Page、操作、控制器或全局来实现这一点。您还可以按惯例将属性应用于Razor Pages的子集。
基于声明的授权使用当前用户的声明来确定他们是否被授权执行操作。您可以定义在策略中执行操作所需的声明。
策略有一个名称,并在Startup.cs中进行配置,作为ConfigureServices中Add-Authentication()调用的一部分。使用Add-policy()定义策略,传入定义所需声明的名称和lambda。
通过在authorize属性中指定策略,可以将策略应用于操作或Razor页面;例如,[Authorize(“CanAccessLounge”)]。AuthorizationMiddleware将使用此策略来确定是否允许用户执行所选端点。
在Razor Pages应用程序中,如果未经验证的用户尝试执行受保护的操作,他们将被重定向到应用程序的登录页面。如果他们已经通过身份验证,但没有所需的声明,则会显示一个“拒绝访问”页面。
对于复杂的授权策略,可以构建自定义策略。自定义策略由一个或多个需求组成,一个需求可以有一个或更多个处理程序。您可以组合需求和处理程序来创建任意复杂度的策略。
对于要授权的策略,必须满足所有要求。为了满足需求,一个或多个关联的处理程序必须指示成功,而没有一个必须指示显式失败。
AuthorizationHandler<T>包含确定是否满足需求的逻辑。例如,如果要求用户年满18岁,则处理程序可以查找出生日期声明并计算用户的年龄。
处理程序可以通过调用context.Sceede(requirement)将需求标记为已满足。如果一个处理程序不能满足需求,那么它就不应该在上下文中调用任何东西,因为不同的处理程序可以调用Succeede()来满足需求。
如果处理程序调用context.Fail(),则要求将失败,即使其他处理程序使用success()将其标记为成功。只有当您希望覆盖其他处理程序对Succeede()的任何调用时,才能使用此方法,以确保授权策略将失败授权。
基于资源的授权使用受保护资源的详细信息来确定当前用户是否被授权。例如,如果用户只允许编辑自己的文档,则在确定他们是否获得授权之前,您需要了解文档的作者。
基于资源的授权使用与以前相同的策略、要求和处理程序系统。您必须手动调用IAuthorizationService并提供所保护的资源,而不是使用[Authorize]属性应用授权。
您可以通过向PageModel添加其他属性来修改用户界面以考虑用户授权。如果用户无权执行操作,则可以在UI中删除或禁用指向该操作方法的链接。您应该始终在服务器上进行授权,即使您已经从UI中删除了链接。

第15章 授权:保护您的应用程序(ASP.NET Core in Action, 2nd Edition)的相关教程结束。

《第15章 授权:保护您的应用程序(ASP.NET Core in Action, 2nd Edition).doc》

下载本文的Word格式文档,以方便收藏与打印。