使用Auth0和ASP.NET Core的后端用于前端认证模式

587 阅读16分钟

**TL;DR:**本文讨论了Backend For Frontend认证模式,以及如何在使用ASP.NET Core 5作为后端、用React实现的SPA中实际使用该模式。最好有OAuth 2.0和OpenID Connect的基本知识,但不是必须的。

什么是后端对前端的认证模式?

当你开始研究不同的OAuth流程和它们所涵盖的场景时,客户端类型是那些到处提到的相关方面之一。OAuth 2.0规范第2.1节中定义了两种不同的客户端类型,即公共和保密客户端。

公共客户端是指那些运行在源代码的一部分或二进制文件被反编译时可能会暴露秘密的地方。这些通常是在浏览器中运行的单页应用程序或在用户设备(如移动电话或智能电视)中运行的本地应用程序。

另一方面,保密客户端是可以将秘密保存在私人存储中的客户端,例如,在网络服务器中运行的网络应用程序,它可以在后端存储秘密。

客户端的类型将决定一个或多个适合应用实现的OAuth流程。通过坚持使用其中的一个流程,你也可以从认证和授权的角度降低让应用程序受损的风险。

后端对前端(a.k.a BFF)的认证模式的出现,是为了减轻从浏览器中运行的公共客户端协商和处理访问令牌可能出现的任何风险。这个名字也意味着必须有一个专门的后端来执行所有的授权代码交换和处理访问和刷新令牌。这种模式依赖于OpenID Connect,它是一个运行在OAuth之上的认证层,用于请求和接收关于认证用户的身份信息。

这种模式不适用于依赖直接从javascript或无服务器后端(如AWS Lamba或Azure Functions)调用外部API的纯SPA。

下图详细说明了这种模式的工作原理。

BFF sequence diagram

  1. 当前端需要对用户进行认证时,它会调用后端的API端点(/api/login)在后端启动登录握手。
  2. 后端使用OpenID connect与Auth0来验证用户,并获得id、访问和刷新令牌。
  3. 后台将用户的令牌存储在一个缓存中。
  4. 前台发出一个加密的cookie,代表用户的认证会话。
  5. 当前端需要调用外部API时,它将加密的cookie与URL和数据一起传递给后端,以调用该API。
  6. 后台从缓存中检索访问令牌,并对外部API进行调用,包括授权头中的该令牌。
  7. 当外部API向后端返回一个响应时,这个响应会转发到前端。

ASP.NET Core中用于前端的后端

Visual Studio为带有ASP.NET Core后端的SPA提供了三个模板。如下图所示,这些模板是带有Angular的ASP.NET Core,带有React.js的ASP.NET Core,以及带有React.js和Redux的ASP.NET Core,其中包括使用Redux的所有必要管道。

Available templates for SPA and ASP.NET Core

作为本文的一部分,我们将讨论如何用ASP.NET Core with React.js模板实现这一模式。

你可以把这个GitHub仓库作为你将要建立的项目的参考。

项目的结构

用该模板从Visual Studio创建的项目将有以下文件夹结构。

  • ClientApp,这个文件夹包含一个用React.js实现的SPA样本。这就是我们要修改以支持BFF模式的应用。
  • Controllers这个文件夹包含了用ASP.NET Core实现的控制器,用于从SPA消费的API。换句话说,它是后端。
  • Pages, 这个文件夹包含服务器端的页面,主要用于在后端渲染错误。
  • Startups.cs这是一个包含主类的文件,其中配置了ASP.NET Core中间件类以及依赖注入容器。

在修改任何代码之前,我们将着手首先在Auth0中配置我们的应用程序。该配置将使我们能够访问.NET核心中OpenID中间件的密钥和认证端点。

Auth0配置

要开始,你需要访问你的Auth0仪表板。如果你没有Auth0账户,你可以现在就注册一个免费账户

在Auth0仪表板上创建一个应用程序

我们要做的第一件事是在Auth0仪表板中创建一个新的品牌应用。Auth0应用程序是一个入口点,用于获取我们在网络应用中需要的密钥和端点。进入你的仪表板,点击左边的应用程序菜单,然后点击创建应用程序

Applications section in the Auth0 Dashboard

创建应用程序按钮将启动一个向导来定义我们应用程序的配置。为你的网络应用程序选一个名字,并选择常规网络应用程序这一选项。不要把你的应用程序与单页Web应用程序混淆。即使我们要用React实现一个SPA,我们也要依靠.NET Core后端来协商ID令牌。当选择常规Web应用程序时,我们告诉Auth0,我们的应用程序将使用授权代码流,这需要一个后端通道来接收OpenID Connect的ID令牌,而这正是我们需要在ASP.NET Core后端实现这种神奇的事情。

Creating applications in the Auth0 Dashboard

一旦应用程序被创建,进入设置选项卡并注意以下设置。

  • 域名
  • 客户端ID
  • 客户秘密

Auth0 app configuration settings

这些是你在网络应用程序中配置OpenID中间件所需要的。

配置回调URL

下一件事是为我们的Web应用程序配置回调URL。这是Auth0为OpenID Connect发布授权码和ID令牌的URL。这个URL可以被添加到我们应用程序的允许URL字段中。对于我们的例子,我们将使用https://localhost:5001/callback。如果你计划将应用程序部署到一个不同的URL,你也需要确保它被列在这里。

配置注销URL

注销URL是Auth0在完成注销过程后将用户重定向的地方。我们的Web应用程序将把这个URL作为 returnTo 查询字符串参数的一部分传递给Auth0。你的应用程序的注销URL必须被添加到应用程序设置下的允许注销URL字段中,否则当用户试图进行注销时,Auth0将返回一个错误。对于我们的例子,我们将使用https://localhost:5001。

在Auth0仪表板上创建一个API

我们还需要在Auth0仪表板上创建一个Auth0 API。因此,进入API部分,点击创建API,如下图所示。

Creating an API in the Auth0 Dashboard

这将打开一个用于配置API的新窗口。在该窗口的设置标签下配置以下字段。

  • 名称,一个友好的名称或API的描述。为这个样本输入天气预报API
  • IdentifierAudience,这是一个标识符,客户端应用程序使用它来请求API的访问令牌。输入字符串 https://weatherforecast.

在权限标签下,添加一个新的权限 read:weather并说明它允许获取天气预报。这是Auth0在用户在同意屏幕中批准的情况下,将在访问令牌中注入的范围。

最后,点击 "保存 "按钮来保存更改。在这一点上,我们的API已经准备好从.NET Core中使用。

配置ASP.NET Core应用程序

我们的应用程序将使用两个中间件。

  • OpenID Connect中间件用于处理与Auth0的所有认证握手。
  • Authentication Cookie中间件用于将认证会话持久化在一个cookie中,并与运行React的前端共享。

在Visual Studio中打开NuGet的包管理器控制台并运行以下命令。

Install-Package Microsoft.AspNetCore.Authentication.Cookies
Install-Package Microsoft.AspNetCore.Authentication.OpenIdConnect

一旦在我们的项目中安装了Nuget包,我们就可以继续在ASP根目录下的 Startup.cs类中配置中间件,该类位于ASP.NET Core项目的根文件夹下。

修改该类中的ConfigureServices 方法,包括以下代码。

// BFF/Startup.cs

// ...existing code...

public void ConfigureServices(IServiceCollection services)
{
  services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(o =>
    {
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.Cookie.SameSite = SameSiteMode.Strict;
        o.Cookie.HttpOnly = true;
    })
    .AddOpenIdConnect("Auth0", options => ConfigureOpenIdConnect(options));
  
    services.AddHttpClient();

   // ...existing code...
 }

private void ConfigureOpenIdConnect(OpenIdConnectOptions options)
{
    // Set the authority to your Auth0 domain
    options.Authority = $"https://{Configuration["Auth0:Domain"]}";

    // Configure the Auth0 Client ID and Client Secret
    options.ClientId = Configuration["Auth0:ClientId"];
    options.ClientSecret = Configuration["Auth0:ClientSecret"];

    // Set response type to code
    options.ResponseType = OpenIdConnectResponseType.CodeIdToken;

    options.ResponseMode = OpenIdConnectResponseMode.FormPost;

    // Configure the scope
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("offline_access");
    options.Scope.Add("read:weather");
    
    // Set the callback path, so Auth0 will call back to http://localhost:3000/callback
    // Also ensure that you have added the URL as an Allowed Callback URL in your Auth0 dashboard
    options.CallbackPath = new PathString("/callback");

    // Configure the Claims Issuer to be Auth0
    options.ClaimsIssuer = "Auth0";

    // This saves the tokens in the session cookie
    options.SaveTokens = true;
    
    options.Events = new OpenIdConnectEvents
    {
        // handle the logout redirection
        OnRedirectToIdentityProviderForSignOut = (context) =>
        {
            var logoutUri = $"https://{Configuration["Auth0:Domain"]}/v2/logout?client_id={Configuration["Auth0:ClientId"]}";

            var postLogoutUri = context.Properties.RedirectUri;
            if (!string.IsNullOrEmpty(postLogoutUri))
            {
                if (postLogoutUri.StartsWith("/"))
                {
                    // transform to absolute
                    var request = context.Request;
                    postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri;
                }
                logoutUri += $"&returnTo={ Uri.EscapeDataString(postLogoutUri)}";
            }
            context.Response.Redirect(logoutUri);
            context.HandleResponse();

            return Task.CompletedTask;
        },
        OnRedirectToIdentityProvider = context => {
            context.ProtocolMessage.SetParameter("audience", Configuration["Auth0:ApiAudience"]);
            return Task.CompletedTask;
        }
    };
}

// ...existing code...

这段代码将OpenID Connect中间件配置为指向Auth0进行认证,将Cookie中间件配置为在Cookie中持久化认证会话。让我们更详细地讨论这段代码的不同部分,以便你能理解它的作用。

services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(o =>
    {
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.Cookie.SameSite = SameSiteMode.Strict;
        o.Cookie.HttpOnly = true;
    })

它将认证配置为:如果在Web应用程序的控制器中没有指定其他的认证机制,则依靠会话cookie作为主要的认证机制。它还为cookie中间件注入了一些设置,以限制cookie在浏览器上的使用方式。在我们的例子中,cookie只能在HTTPS下使用(CookieSecurePolicy.Always),它不能在客户端使用(HttpOnly = true),并使用相当于严格的网站政策(SameSiteMode.Strict).最后一项意味着只有当cookie的域与浏览器的URL中的域完全匹配时,cookie才会被发送。所有这些设置都有助于防止在客户端使用脚本的潜在攻击。

options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
options.ResponseMode = OpenIdConnectResponseMode.FormPost;

OpenID Connect中间件被配置为使用ResponseType 等于CodeIdToken (混合流),这意味着我们的Web应用程序将在用户被认证后直接从授权端点收到授权码和ID令牌。我们将使用授权码来换取访问令牌,以调用托管在不同网站上的后端API。

// Configure the scope
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("offline_access");
options.Scope.Add("read:weather");

openid 范围是作为OpenID连接认证流程的一部分而需要的。offline_access 是用来请求刷新令牌的。 read:weather是特定于我们稍后将调用的API,作为本示例的一部分。

options.SaveTokens = true;

SaveTokens 选项告诉OpenID Connect中间件,在初始握手过程中从授权端点收到的所有令牌(ID令牌、刷新令牌和访问令牌)必须被持久化,以便以后使用。默认情况下,中间件会将这些令牌保存在加密的会话cookie中,我们将在我们的样本中使用它。

OnRedirectToIdentityProvider = context => {
    context.ProtocolMessage.SetParameter("audience", Configuration["Auth0:ApiAudience"]);
    return Task.CompletedTask;
},

OpenID Connect中间件没有任何属性来配置Auth0为返回API的授权码所需的audience 参数。我们将一些代码附加到OnRedirectToIdentityProvider 事件中,以便在用户被重定向到Auth0进行认证之前设置该参数。

services.AddHttpClient();

扩展方法AddHttpClient ,注入一个带有默认设置的IHttpClientFactory ,以创建类的实例HttpClient 。我们将用它来对外部API进行调用。

下一步是修改Configure 方法,告诉ASP.NET Core我们要使用认证和授权中间件。这些中间件将自动与认证会话cookies集成。

插入以下代码,如下图所示。

// Startup.cs

// ...existing code...

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...existing code...
    app.UseRouting();

    // Code goes here
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
      endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller}/{action=Index}/{id?}");
     });
  
    // ...existing code...
}

更新现有的 appSettings.json文件,包括我们之前从Auth0仪表板上得到的设置。这些是域名客户ID客户秘密ApiAudience

{
  "Logging": {
      "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
      }
    },
  "AllowedHosts": "*",
  "Auth0": {
    "Domain": "<domain>",
    "ClientId": "<client id>",
    "ClientSecret": "<client secret>",
    "ApiAudience": "https://weatherforecast"
  }
}

添加用于处理认证的ASP.NET Core控制器

Controllers 文件夹中创建一个新的ASP.NET控制器,并将其称为AuthController 。这个控制器有三个动作。

  • Login 用于启动OpenID Connect与Auth0的登录握手。
  • Logout 从Web应用程序和Auth0中注销。
  • GetUser 获取当前会话中的认证用户的数据。这是一个API,React应用程序将调用它来获取用户的认证上下文。

这就是Login 动作的代码。

// BFF/Controllers/AuthController.cs

// ...existing code...

public ActionResult Login(string returnUrl = "/")
{
  return new ChallengeResult("Auth0", new AuthenticationProperties() { RedirectUri = returnUrl });
}

// ...existing code...

这是一个动作,它返回一个带有要使用的认证模式的ChallengeResult 。在这种情况下,它是Auth0,这是我们在Startup 类中与我们的OpenID Connect中间件关联的模式。这个结果是ASP.NET Core提供的一个内置类,用于从认证中间件启动认证握手。

注销动作看起来如下。

// BFF/Controllers/AuthController.cs

// ...existing code...

[Authorize]
public ActionResult Logout()
{
  return new SignOutResult("Auth0", new AuthenticationProperties
  {
    RedirectUri = Url.Action("Index", "Home")
  });
}

// ...existing code...

它返回一个SignOutResult ,将用户从应用程序中注销,同时启动Auth0的签出过程。正如ChallengeResult ,这个SignOutResult ,也是认证中间件将处理的一个内置结果。我们还为这个动作加上了 [Authorize]属性,因为只有当用户被认证后,它才会被调用。

最后,GetUser 的API代码如下。

// BFF/Controllers/AuthController.cs

// ...existing code...

public ActionResult GetUser()
{
  if (User.Identity.IsAuthenticated)
  {
    var claims = ((ClaimsIdentity)this.User.Identity).Claims.Select(c =>
                    new { type = c.Type, value = c.Value })
                    .ToArray();

    return Json(new { isAuthenticated = true, claims = claims });
 }

 return Json(new { isAuthenticated = false });
}

// ...existing code...

如果用户通过了认证,它将用户身份作为一组序列化的JSON索赔返回。否则,它只是返回一个标志,表明用户没有被认证。

在其他控制器中要求认证

模板中包含的WeatherForecast 控制器允许匿名调用。为了使它在我们的示例中更有趣,我们将把它转换为需要认证的调用。幸运的是,这就像在类定义中添加一个顶级的Authorize 属性一样简单。

// BFF/Controllers/WeatherForecastController.cs

// ...existing code...

[ApiController]
[Authorize]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{

// ...existing code...

协商一个访问令牌并调用一个远程API

我们将转换我们的Web应用程序中的WeatherForecast 控制器,作为一个反向代理,并调用不同网站上远程托管的同等API。这个API将需要一个访问令牌,所以控制器将不得不首先协商会话cookie中的授权代码。

 public WeatherForecastController(
            IHttpClientFactory httpClientFactory,
            IConfiguration configuration)
{
    _httpClientFactory = httpClientFactory;

    if (configuration["WeatherApiEndpoint"] == null)
        throw new ArgumentNullException("The Weather Api Endpoint is missing from the configuration");

    _apiEndpoint = new Uri(configuration["WeatherApiEndpoint"], UriKind.Absolute);
}

这个控制器的构造函数接收一个IHttpClientFactory 的实例,这个实例是我们之前在创建 实例的文件中注册的。 Startup.cs文件中注册的一个IConfiguration 的实例,用于创建HttpClient 实例,以及一个 的实例,用于从配置文件中获取设置。天气API的端点是使用WeatherApiEndpoint 密钥从配置中检索出来的。该密钥在 appSettings.json只引用远程API的URL,如下图所示。

// appSettings.json
{
  // ... other settings ...
  "WeatherApiEndpoint": "https://localhost:44385/"
}

下面的代码显示了Get 方法的实现。这是通过传递预期的授权头文件来调用实际的远程API。

// BFF/Controllers/WeatherForecastController.cs

// ...existing code...

[HttpGet]
public async Task Get()
{
    var accessToken = await HttpContext.GetTokenAsync("Auth0", "access_token");

    var httpClient = _httpClientFactory.CreateClient();

    var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_apiEndpoint, "WeatherForecast"));
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

    var response = await httpClient.SendAsync(request);

    response.EnsureSuccessStatusCode();

    await response.Content.CopyToAsync(HttpContext.Response.Body);
}

// ...existing code...

获取访问令牌的技巧在下面一行。

var accessToken = await HttpContext.GetTokenAsync("Auth0", "access_token");

GetTokenAsync 是作为ASP.NET Core中认证中间件的一部分的一个扩展方法。第一个参数指定了用于获取令牌的认证模式,这是我们的OpenID Connect中间件,配置的名称是 "Auth0"。第二个参数是要使用的令牌。在OpenID Connect的情况下,可能的值是 "access_token "或 "id_token"。如果访问令牌不可用或已经过期,中间件将使用刷新令牌和授权码来获得一个。由于我们的中间件是用受众属性和我们之前配置的范围指向 API,Auth0将返回该API的访问令牌。WeatherForecast

var httpClient = _httpClientFactory.CreateClient();

var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_apiEndpoint, "WeatherForecast"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

var response = await httpClient.SendAsync(request);

response.EnsureSuccessStatusCode();

await response.Content.CopyToAsync(HttpContext.Response.Body);

上面的代码使用一个新的HttpClient 实例将请求转发给远程API,该实例是在构造函数中注入了IHttpClientFactory 。访问令牌被作为授权头中的Bearer令牌传递。

配置远程API

作为远程API,我们将使用Visual Studio的ASP.NET Web API模板提供的API,它可以返回天气预报数据。

在Visual Studio中创建ASP.NET Core API

Visual Studio为.NET Core APIs提供了一个单一的模板。这就是ASP.NET Core Web API,如下图所示。

ASP.NET template in Visual Studio

项目的结构

用该模板从Visual Studio创建的项目将有以下结构。

  • Controllers, 这个文件夹包含API实现的控制器。
  • Startup.cs,这是配置ASP.NET Core中间件类和依赖注入容器的主类。

配置项目

我们的应用程序将只使用中间件来支持以JWT作为承载令牌的认证。

在Visual Studio中打开NuGet的包管理器控制台,运行以下命令。

Install-Package Microsoft.AspNetCore.Authentication.JwtBearer

一旦在我们的项目中安装了NuGet包,我们就可以继续将它们配置在 Startup.cs类文件中配置它们。

修改该类中的ConfigureServices 方法,包括以下代码。

// Api/Startup.cs

// ...existing code...

public void ConfigureServices(IServiceCollection services)
{
    var authentication = services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer("Bearer", c =>
        {
        c.Authority = $"https://{Configuration["Auth0:Domain"]}";
        c.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidAudiences = Configuration["Auth0:Audience"].Split(";"),
            ValidateIssuer = true,
            ValidIssuer = $"https://{Configuration["Auth0:Domain"]}";
        };
    });

    services.AddControllers();
            
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Api", Version = "v1" });
    });

    services.AddAuthorization(o =>
    {
        o.AddPolicy("read:weather", p => p.
            RequireAuthenticatedUser().
            RequireScope("read:weather"));
    });
}

// ...existing code...

这段代码执行了两件事。它配置了JWT中间件以接受由Auth0发出的访问令牌,并定义了一个授权策略来检查令牌上设置的范围。该策略检查一个名为scope的要求或属性,其值为 read:weather``RequireScope ,这是我们之前在Auth0仪表盘中为我们的API配置的范围。我们将编写一个自定义扩展,作为本示例的一部分,以检查JWT访问令牌中的范围。

下一步是修改Configure 方法,告诉ASP.NET Core我们要使用认证和授权中间件。该中间件将自动与认证会话cookies集成。

在文件中插入如下所示的新代码 Startup.cs文件中。

// Api/Startup.cs

// ...existing code...

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  // ...existing code...
  
  app.UseRouting();
            
  app.UseAuthentication();
  app.UseAuthorization();

  app.UseEndpoints(endpoints =>
  {
    endpoints.MapControllers();
  });
}

// ...existing code...

更新现有的 appSettings.json文件,并包括我们之前从Auth0仪表板上得到的设置。这些是Domain和API的Audience

{
  "Logging": {
      "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
      }
    },
  "AllowedHosts": "*",
  "Auth0": {
    "Domain": "<domain>",
    "Audience": "https://weatherforecast"
  }
}

RequireScope策略

ASP.NET Core不包括任何用于检查JWT访问令牌中单个范围的开箱即用策略。为了克服这一缺陷,我们将创建一个自定义策略。为此目的,创建一个新的Authorization 文件夹。然后在其中添加三个新文件。 ScopeHandler.cs, ScopeRequirement.cs、 、 和 AuthorizationPolicyBuilderExtensions.cs.接下来我们将讨论每个文件的用途。

添加一个新文件 ScopeHandler.csAuthorization 文件夹中,内容如下。

// Api/Authorization/ScopeHandler.cs

using Microsoft.AspNetCore.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Api.Authorization
{
    public class ScopeHandler :
             AuthorizationHandler<ScopeRequirement>
    {
        protected override Task HandleRequirementAsync(
          AuthorizationHandlerContext context,
          ScopeRequirement requirement)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            var success = context.User.Claims.Any(c => c.Type == "scope" && 
                c.Value.Contains(requirement.Scope));

            if (success)
                context.Succeed(requirement);
            
            return Task.CompletedTask;
        }
    }
}

认证中间件解析JWT访问令牌,并将令牌中的每个属性转换为连接到上下文中的当前用户的 claim。我们的策略处理程序使用与作用域相关的 claim 来检查预期的作用域是否存在 (read:weather).

AuthorizationHandler 的每个实现都必须与IAuthorizationRequirement 的实现相关联,描述处理程序的授权要求。在我们的案例中,该实现看起来就像下面描述的那样。

在文件中添加以下内容 ScopeRequirement.cs文件中添加以下内容。

// Api/Authorization/ScopeRequirement.cs

using Microsoft.AspNetCore.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Api.Authorization
{
    public class ScopeRequirement : IAuthorizationRequirement
    {
        public string Scope { get; private set; }

        public ScopeRequirement(string scope)
        {
            Scope = scope;
        }
    }
}

这是一个非常简单的实现,它用一个范围来描述一个要求。这就是JWT访问令牌中的预期范围。

最后,该类 AuthorizationPolicyBuilderExtensions.cs包括RequireScope 扩展方法,用于在策略配置时将ScopeHandler 实例注入到 Startup.cs类中,当策略被配置时。

// Api/Authorization/AuthorizationPolicyBuilderExtensions.cs

using Microsoft.AspNetCore.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Api.Authorization
{
    public static class AuthorizationPolicyBuilderExtensions
    {
        public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder builder, string scope)
        {
            return builder.AddRequirements(new ScopeRequirement(scope));
        }
    }
}

在API控制器中要求认证

模板中包含的WeatherForecast 控制器允许匿名调用。我们将使用Authorize 属性将其转换为需要认证的调用。该属性也将引用我们之前在文件中定义的策略。 Startup.cs文件中定义的策略。

// Api/Controllers/WeatherForecastController.cs

// ...existing code...

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
  [HttpGet]
  [Authorize("read:weather")]
  public IEnumerable<WeatherForecast> Get()
  {
    
// ...existing code...

这个属性将做两件事。

  • 它将激活授权中间件,该中间件将检查调用是否经过认证,并且在当前执行上下文中有一个用户身份设置。
  • 它将运行 read:weather策略,以确保用户身份包含所需的权限。在我们的例子中,它将检查访问令牌包括一个名为 read:weather.

一旦我们在Visual Studio中运行这个项目,API将只接受来自Auth0的访问令牌的认证调用。

确保React应用程序的安全

到目前为止,我们已经在后端添加了所有的管道代码,以便使用OpenID Connect启用Auth0的认证。后台处理用户认证并配置一个cookie,我们可以与React应用共享。我们还添加了一个GetUser API,可以用来确定用户是否被认证,并获得他们的基本身份信息。现在让我们看看React客户端应用程序需要的变化。

用于认证的React上下文

由于认证是一个核心问题,我们将在React应用程序的所有组件中使用,所以使用上下文模式使其成为一个全局上下文是有意义的。移动到 BFF/ClientApp/src文件夹,并创建一个context 文件夹。然后添加一个文件 AuthContext.js到新创建的文件夹中。在该文件上粘贴以下代码。

// BFF/ClientApp/src/context/AuthContext.js

import React, { useState, useEffect, useContext } from "react";

export const AuthContext = React.createContext();
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({
    children
}) => {
    const [isAuthenticated, setIsAuthenticated] = useState();
    const [user, setUser] = useState();
    const [isLoading, setIsLoading] = useState(false);

    const getUser = async () => {
        const response = await fetch('/auth/getUser');
        const json = await response.json();

        setIsAuthenticated(json.isAuthenticated);
        setIsLoading(false);
        if (json.isAuthenticated) setUser(json.claims);
    }

    useEffect(() => {
        getUser();
    }, []);

    const login = () => {
        window.location.href = '/auth/login';
    }

    const logout = () => {
        window.location.href = '/auth/logout';
    }

    return (
        <AuthContext.Provider
            value={{
                isAuthenticated,
                user,
                isLoading,
                login,
                logout
            }}
        >
            {children}
        </AuthContext.Provider>
    );
};

这个上下文对象提供了启动与后台的登录和注销握手的方法,并获得认证的用户。

修改该 index.js文件来引用这个上下文提供者。

// BFF/ClientApp/src/index.js

// ...existing code...

ReactDOM.render(
    <AuthProvider>
        <BrowserRouter basename={baseUrl}>
            <App />
        </BrowserRouter>
    </AuthProvider>,
    rootElement);

// ...existing code...

添加登录和注销路由

React Router配置使用认证上下文来重定向用户到后台的登录和注销URL。对于受保护的路由,如获取天气数据的路由,它也会强制用户认证。

要添加这些受保护的路由,请修改 App.js文件,以包括这段代码。

// BFF/ClientApp/src/App.js

import { Redirect, Route } from 'react-router';
import { Layout } from './components/Layout';
import { Home } from './components/Home';
import { FetchData } from './components/FetchData';
import { useAuth } from './context/AuthContext';

import './custom.css'

const App = () => {

    const { isAuthenticated, login, logout } = useAuth();

    return (
        <Layout>
            <Route exact path='/' component={Home} />
            <Route path='/fetch-data' component={isAuthenticated ? () => { return <FetchData /> } : () => { login(); return null; }}/>
            <Route path='/login' component={() => { login(); return null }} />
            <Route path='/logout' component={() => { logout(); return null }}></Route>
        </Layout>
    );
}

export default App;

例如,该 fetch-data路由,例如,在返回FetchData 组件之前,检查用户是否经过认证。如果用户没有被认证,它就调用认证上下文中的login 函数,最终将用户重定向到后台的Login 端点。

修改应用程序的菜单

在Web应用程序中另一个非常常见的功能是,根据用户认证状态,使菜单选项可见或不可见。正如我们在React Router中所做的那样,使用认证上下文中的isAuthenticated 功能,可以为菜单选项完成同样的事情。

因此,移动到 ClientApp/src/components文件夹。然后修改 NavMenu.js文件来检查认证状态,如下图所示。

// BFF/ClientApp/src/components/NavMenu.js

// ...existing code...

return (
        <header>
            <Navbar className="navbar-expand-sm navbar-toggleable-sm ng-white border-bottom box-shadow mb-3" light>
                <Container>
                    <NavbarBrand tag={Link} to="/">Auth0 Backend For FrontEnd Authentication</NavbarBrand>
                    <NavbarToggler onClick={toggleNavbar} className="mr-2" />
                    <Collapse className="d-sm-inline-flex flex-sm-row-reverse" isOpen={!collapsed} navbar>
                        <ul className="navbar-nav flex-grow">
                            <NavItem>
                                <NavLink tag={Link} className="text-dark" to="/">Home</NavLink>
                            </NavItem>
                            <NavItem>
                                <NavLink tag={Link} className="text-dark" to="/fetch-data">Fetch data</NavLink>
                            </NavItem>
                            <NavItem>
                            {!isAuthenticated && <NavItem>
                                <NavLink tag={Link} className="text-dark" to="/login">Login</NavLink>
                            </NavItem>}
                            {isAuthenticated && <NavItem>
                                <NavLink tag={Link} className="text-dark" to="/logout">Logout</NavLink>
                            </NavItem>}
                        </ul>
                    </Collapse>
                </Container>
            </Navbar>
        </header>
 );
                             
// ...existing code...

添加一个组件来显示用户数据

认证上下文提供了一个getUser 函数,如果你想在React应用程序上显示来自Auth0的用户的基本数据。该函数返回来自后台API的关于用户身份的声明集合GetUser

下面的代码显示了一个列举这些权利要求的组件。

// BFF/ClientApp/src/components/User.js

import React, { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';

export const User = () => {

    const { user } = useAuth();

    const renderClaimsTable = function (claims) {
        return (
            <table className='table table-striped' aria-labelledby="tabelLabel">
                <thead>
                    <tr>
                        <th>Type</th>
                        <th>Value</th>
                    </tr>
                </thead>
                <tbody>
                    {claims.map(claim =>
                        <tr key={claim.type}>
                            <td>{claim.type}</td>
                            <td>{claim.value}</td>
                        </tr>
                    )}
                </tbody>
            </table>
        );
    }

    return (
        <div>
            <h1 id="tabelLabel" >User claims</h1>
            <p>This component demonstrates fetching user identity claims from the server.</p>
            {renderClaimsTable(user)}
        </div>
    );

}

运行Web应用程序

在Visual Studio中,点击运行按钮,但从下拉选项中选择你的项目名称而不是 "IIS Express"。这将使用Kestrel运行应用程序,Kestrel是包含在.NET Core中的内置Web服务器。Kestrel在"https://localhost:5001 "上运行,这就是我们之前在Auth0中配置的URL。

Running your application

关于登录流程

以下是当用户对我们建立的应用程序进行认证时发生的情况。

  • 用户点击登录按钮并被引导到Login 路线。
  • ChallengeResult 响应告诉ASP.NET认证中间件,向用Auth0认证方案参数注册的认证处理程序发出挑战。该参数使用你在Startup 类中调用AddOpenIdConnect 时传递的 "Auth0 "值。
  • OIDC处理程序将用户重定向到Auth0的 /authorize端点,该端点显示通用登录页面。用户可以用他们的用户名和密码、社交提供者或任何其他身份提供者来登录。
  • 一旦用户登录,Auth0就会回调到你应用程序中的 /callback端点,并传递一个授权码。
  • OIDC处理程序拦截向 /callback路径的请求。
  • 该处理程序寻找授权代码,Auth0在查询字符串中发送了该代码。
  • OIDC处理程序调用Auth0的 /oauth/token端点来交换用户的ID和访问令牌的授权码。
  • OIDC中间件从ID令牌中的索赔中提取用户信息。
  • OIDC中间件返回一个成功的认证响应,并设置一个cookie,表明用户已被认证。该cookie包含带有用户信息的索赔。该cookie被存储起来,以便cookie中间件在今后的任何请求中自动验证用户。OIDC中间件不会再收到任何请求,除非它被明确挑战。
  • React应用程序使用认证上下文,向GetUser API发出API调用。这个API从认证cookie中返回用户请求。
  • React应用程序使用认证用户的身份渲染UI组件。

总结

如果你有能力支付额外的钱购买一个专门的后台,BFF模式是一个理想的认证解决方案。它将帮助你在处理访问令牌时避免头痛,以及如何在你的客户端应用程序上保持它们的安全。后台将完成所有繁重的工作,这样你就可以只关注前端的UI/UX问题。

你可以从这个GitHub仓库下载本文中所构建的示例项目的完整源代码。

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon

Pablo Cibraro

Agilesight联合创始人

Pablo是国际公认的专家和企业家,在使用微软技术设计和实施大型分布式系统方面拥有超过22年的经验,是连接系统的MVP。
在过去的几年中,Pablo帮助许多微软团队开发了工具和框架,用于用.NET构建面向服务和网络应用。
Pablo现在专注于使开发人员能够在云上构建大规模系统和网络应用的技术,如HTML5、Node.js、ASP.NET、Windows Azure和亚马逊AWS。

查看资料

Pablo Cibraro

Agilesight联合创始人

Pablo是国际公认的专家和企业家,在利用微软技术设计和实施大型分布式系统方面拥有超过22年的经验,是连接系统的MVP。
在过去的几年中,Pablo帮助许多微软团队开发了工具和框架,用于用.NET构建面向服务和网络应用。
Pablo现在专注于使开发人员能够在云上构建大规模系统和网络应用的技术,如HTML5、Node.js、ASP.NET、Windows Azure和亚马逊AWS。

查看资料


© 2013-2021 Auth0公司。保留所有权利。