在.NET上实现现代认证:OpenID Connect、BFF、SPA

随着网络技术的持续进步,用于保护它们的方法和协议也在不断发展。OAuth 2.0 和 OpenID Connect 协议在应对新兴安全威胁和网络应用程序日益增长的复杂性方面已经发生了显著演变。传统的认证方法,尽管曾经有效,但现在对于现代单页应用程序(SPAs)来说已经变得过时,这些应用程序面临着新的安全挑战。在这种背景下,后端服务于前端(BFF)架构模式作为组织 SPA 与其后端系统之间交互的推荐解决方案应运而生,它提供了一种更安全、更易于管理的认证和会话管理方法。本文深入探讨了 BFF 模式,通过使用 .NET 和 React 实现的简单解决方案来展示其在实际中的应用。到本文结束时,您将清楚地了解如何利用 BFF 模式来增强 web 应用程序的安全性和功能。

历史背景

OAuth 2.0 和 OpenID Connect 协议的历史反映了互联网技术的持续演变。让我们更详细地了解这些协议以及它们对现代 web 应用程序的影响。

OAuth 2.0 协议于 2012 年推出,现已成为广泛采用的授权标准。它允许第三方应用程序在不向客户端暴露用户凭据的情况下,获取对用户资源的有限访问权限。OAuth 2.0 支持几种不同的流程,每种流程都旨在灵活适应各种使用场景。

基于OAuth 2.0的基础,OpenID Connect(OIDC)协议于2014年出现,增加了必要的认证功能。它为客户端应用程序提供了一种标准的方法来验证用户身份并通过标准化的访问点获取他们的基本信息,或者通过获取JWT(JSON Web Token)格式的ID令牌来实现。

威胁模型的演变

随着SPA(单页应用程序)的功能和流行度的不断提升,SPA的威胁模型也在演变。跨站脚本(XSS)和跨站请求伪造(CSRF)等漏洞变得越来越普遍。由于SPA通常通过API与服务器交互,因此安全存储和使用访问令牌和刷新令牌对于安全至关重要。

顺应时代的需求,OAuth和OpenID Connect协议继续发展,以适应新技术和新威胁带来的新挑战。同时,威胁的不断演变和安全实践的提高意味着过时的方法不再满足现代安全要求。因此,OpenID Connect协议目前提供了广泛的能力,但许多能力已经是,或者很快会被认为是过时且不安全的。这种多样性使得SPA开发者难以选择与OAuth 2.0和OpenID Connect服务器交互的最合适和安全的方式。

特别地,隐式流程现在可以被认为是过时的,对于任何类型的客户端,无论是SPA、移动应用程序还是桌面应用程序,现在都强烈建议使用带有Proof Key for Code Exchange (PKCE)的授权码流程。

现代SPA的安全性

为什么即使使用带有PKCE的授权码流程,现代SPA仍然被认为是脆弱的?这个问题有几个答案。

JavaScript代码漏洞

JavaScript是一种强大的编程语言,在现代单页应用程序(SPA)中起着关键作用。然而,其广泛的能力和普及性构成了一种潜在的威胁。建立在React、Vue或Angular等库和框架上的现代SPA,使用了大量的库和依赖项。您可以在node_modules文件夹中看到它们,这些依赖项的数量可能达到数百甚至数千。这些库中的每一个可能包含不同程度的关键漏洞,而SPA开发者无法彻底检查所用所有依赖项的代码。通常开发者甚至不跟踪完整的依赖项列表,因为它们相互之间是间接依赖的。即使开发自己的代码达到最高质量和安全标准,也无法完全确定最终应用程序中不存在漏洞。

恶意JavaScript代码可以通过各种方式注入应用程序,例如通过跨站脚本攻击(XSS)或者通过第三方库的沦陷,获得与合法应用程序代码相同的权限和访问数据级别。这使得恶意代码能够从当前页面窃取数据,与应用程序界面交互,向后端发送请求,从本地存储(localStorage、IndexedDB)窃取数据,甚至自行启动认证会话,使用相同的授权码和PKCE流程获取自己的访问令牌。

Spectre漏洞

现代处理器架构的特点被利用来访问本应被隔离的数据,这就是所谓的Spectre漏洞。这种漏洞对单页应用程序(SPA)尤其危险。

首先,SPA密集使用JavaScript来管理应用程序状态和与服务器的交互。这增加了恶意JavaScript代码利用Spectre漏洞的攻击面。其次,与传统的多页应用程序(MPAs)不同,SPA很少重新加载,这意味着页面及其加载的代码会保持活跃很长时间。这使得攻击者有足够的时间使用恶意JavaScript代码执行攻击。

Spectre漏洞允许攻击者窃取存储在JavaScript应用程序内存中的访问令牌,通过伪装成合法应用程序来访问受保护的资源。投机执行还可以用来窃取用户会话数据,使攻击者在SPA关闭后仍能继续其攻击。

未来发现类似于Spectre的其他漏洞的可能性不能被排除。

怎么办?

让我们总结一个重要的临时结论。现代单页应用程序(SPA),依赖于大量第三方JavaScript库,在用户设备的浏览器环境中运行,它们在开发者无法完全控制的软件和硬件环境中操作。因此,我们应该认为这类应用程序本质上是有漏洞的。

针对上述威胁,更多的专家倾向于完全避免在浏览器中存储令牌,并设计应用程序,使其访问令牌和刷新令牌仅由应用程序的服务器端获取和处理,并且绝不被传递到浏览器端。在具有后端的SPA中,可以使用后端为前端(BFF)架构模式来实现。

授权服务器(OP)、实现BFF模式的客户端(RP)和第三方API(资源服务器)之间的交互方案如下:

使用BFF模式保护SPA具有几个优点。访问令牌和刷新令牌存储在服务器端,并且绝不被传递到浏览器,这防止了由于漏洞导致的令牌盗窃。会话和令牌管理在服务器上处理,这使得安全控制更加完善,认证验证更加可靠。客户端应用程序通过BFF与服务器交互,这简化了应用程序逻辑,并减少了恶意代码执行的风险。

在.NET平台上实现后端为前端模式

在我们继续在.NET平台上实现BFF(最佳前端朋友)模式的实际应用之前,让我们先考虑其必要的组成部分并规划我们的行动。假设我们 already 有一个配置好的OpenID Connect服务器,我们需要开发一个与后端协作的SPA(单页应用程序),并使用OpenID Connect实现认证,以及使用BFF模式组织服务器和客户端部分之间的交互。

根据文档 OAuth 2.0 for Browser-Based Applications,BFF架构模式假设后端作为OpenID Connect客户端行事,使用带有PKCE的授权码流进行认证,在其一端获取并存储访问和刷新令牌,并且永远不要在浏览器中将它们传递给SPA。BFF模式还假设后端一侧有一个API,由四个主要端点组成:

  1. 检查会话:用于检查活动用户认证会话。通常从SPA异步API(fetch)中调用,如果成功,将返回有关活动用户的信息。因此,从第三方来源(例如CDN)加载的SPA可以检查认证状态,并继续其工作或转到使用OpenID Connect服务器进行认证。
  2. 登录: 初始化在OpenID Connect服务器上的认证过程。通常,如果SPA在步骤1通过Check Session未能获取到认证用户数据,它将把浏览器重定向到这个URL,然后形成一个完整的请求发送到OpenID Connect服务器并将其重定向到那里。
  3. 注册: 在认证成功后的步骤2接收服务器发送的授权码。直接向OpenID Connect服务器请求,用授权码+PKCE代码验证器交换访问和刷新令牌。通过向用户发放认证cookie,在客户端发起一个认证会话。
  4. 登出: 用以终止认证会话。通常,SPA将浏览器重定向到这个URL,进而形成请求发送到OpenID Connect服务器的End Session端点以终止会话,以及删除客户端会话和认证cookie。

现在让我们来看看.NET平台提供的开箱即用的工具以及我们可以使用它们来实现BFF模式。.NET平台提供了Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet包,这是由Microsoft支持的开源ID Connect客户端的现成实现。这个包支持授权码流和PKCE,并且它添加了一个带有相对路径/signin-oidc的端点,该端点已经实现了必要的登录端点功能(如上文第3点所述)。因此,我们只需要实现剩下的三个端点。

以下是一个实用的集成示例,我们将使用基于Abblix OIDC Server库的测试OpenID Connect服务器。然而,下面提到的所有内容都适用于任何其他服务器,包括Facebook、Google、Apple等公开可用的服务器,以及任何遵守OpenID Connect协议规范的其他服务器。

在前端实现单页应用程序(SPA)时,我们将使用React库,而在后端,我们将使用.NET WebAPI。截至本文撰写时,这是最常用的技术栈之一。

组件及其交互的整体方案如下所示:

为了使用本文中的示例,你还需要安装.NET SDKNode.js。本文中的所有示例都是使用.NET 8、Node.js 22和React 18开发的,这些版本在撰写本文时是当前的。

在React上使用.NET后端创建客户端SPA。

为了快速创建客户端应用程序,使用现成的模板是非常方便的。截至.NET 7版本,SDK提供了一个内置的.NET WebAPI应用程序模板和一个React SPA模板。不幸的是,在.NET 8版本中,这个模板被移除了。这就是为什么Abblix团队创建了自己的模板,该模板包括一个.NET WebApi后端,一个基于React库的客户端SPA,以及使用Vite构建的TypeScript。这个模板作为Abblix.Templates软件包的一部分公开可用,您可以通过运行以下命令来安装它:

Shell

 

dotnet new install Abblix.Templates

现在我们可以使用名为abblix-react的模板。让我们用它来创建一个名为BffSample的新应用程序:

Shell

 

dotnet new abblix-react -n BffSample

此命令创建了一个包含.NET WebApi后端和React SPA客户端的应用程序。与SPA相关的文件位于BffSample\ClientApp文件夹中。

创建项目后,系统会提示您运行一个命令来安装依赖项:

Shell

 

cmd /c "cd ClientApp && npm install"

此操作是为了安装应用程序客户端部分所需的所有依赖项。为了成功启动项目,建议同意并执行此命令,通过输入Y(是)。

让我们立即将BffSample应用程序在本地运行的端口号更改为5003。这个操作不是强制性的,但它将简化OpenID Connect服务器的后续配置。为此,请打开BffSample\Properties\launchSettings.json文件,找到名为https的配置文件,并将applicationUrl属性的值更改为https://localhost:5003

接下来,通过导航到BffSample文件夹并执行以下命令来添加实现OpenID Connect客户端的NuGet包:

Shell

 

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

在应用程序中设置两个名为CookiesOpenIdConnect的认证方案,从应用程序配置中读取它们的设置。为此,请修改BffSample\Program.cs文件:

C#

 

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************** END ********************
var app = builder.Build();

并在BffSample\appsettings.json文件中添加连接到OpenID Connect服务器的必要设置:

JSON

 

{
  // ******************* START *******************
  "Authentication": {
      "DefaultScheme": "Cookies",
      "DefaultChallengeScheme": "OpenIdConnect"
  },
  "OpenIdConnect": {
      "SignInScheme": "Cookies",
      "SignOutScheme": "Cookies",
      "SaveTokens": true,
      "Scope": ["openid", "profile", "email"],
      "MapInboundClaims": false,
      "ResponseType": "code",
      "ResponseMode": "query",
      "UsePkce": true,
      "GetClaimsFromUserInfoEndpoint": true
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

以及在BffSample\appsettings.Development.json文件中:

JSON

 

{
  // ******************* START *******************
  "OpenIdConnect": {
      "Authority": "https://localhost:5001",
      "ClientId": "bff_sample",
      "ClientSecret": "secret"
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

让我们简要回顾一下每个设置及其用途:

  • 认证 部分DefaultScheme 属性默认设置使用 Cookies 方案进行认证,而 DefaultChallengeScheme 当用户不能通过默认方案认证时,将认证执行委托给 OpenIdConnect 方案。因此,当用户对应用程序未知时,将调用 OpenID Connect 服务器进行认证,认证后,已认证的用户将收到一个认证 Cookie,并且所有后续的服务器调用都将使用它进行认证,而无需联系 OpenID Connect 服务器。
  • `OpenIdConnect部分
    • SignInSchemeSignOutScheme 属性指定了 Cookies 方案,该方案将在用户登录后用于保存用户信息。
    • `Authority 属性包含了 OpenID Connect 服务器的基 URL。`ClientId 和 `ClientSecret 指定了客户端应用程序的标识符和密钥,这些标识符和密钥在 OpenID Connect 服务器上注册。
    • `SaveTokens 指示需要保存从 OpenID Connect 服务器认证后获得的令牌。
    • `Scope 包含 `BffClient 应用程序请求访问的 scope 列表。在此案例中,请求了标准 scope `openid(用户标识)、`profile(用户资料)和 `email(电子邮件)。
    • `MapInboundClaims 负责将来自 OpenID Connect 服务器的传入声明转换为应用程序中使用的声明。值为 `false 表示声明将以从 OpenID Connect 服务器接收到的形式保存到已认证用户的会话中。
    • `ResponseType 的值为 `code 表示客户端将使用授权码流。
    • `ResponseMode 指定了在查询字符串中传输授权码的方法,这是授权码流的默认方法。
    • `UsePkce 属性指示在认证过程中需要使用 PKCE 以防止拦截授权码。
    • `GetClaimsFromUserInfoEndpoint 属性指示应从 UserInfo 端点获取用户资料数据。

    `

由于我们的应用程序假设在没有认证的情况下不与用户进行交互,因此我们将确保在React SPA加载之前必须成功认证。当然,如果SPA是从外部源加载的,例如从静态网页托管服务,例如从内容交付网络(CDN)服务器或使用`npm start`命令启动的本地开发服务器(例如,在以调试模式运行我们的示例时),那么在加载SPA之前就无法检查认证状态。但是,当我们的.NET后端负责加载SPA时,是可以做到的。

为此,在`BffSample\Program.cs`文件中添加负责认证和授权的中间件:

C#

 

app.UseRouting();
// ******************* START *******************
app.UseAuthentication();
app.UseAuthorization();
// ******************** END ********************

在`BffSample\Program.cs`文件的末尾,即直接进行SPA加载的地方,添加授权要求,即` .RequireAuthorization()`:

C#

 

app.MapFallbackToFile("index.html").RequireAuthorization();

设置OpenID Connect服务器。

正如前面所提到的,对于实际集成示例,我们将使用基于Abblix OIDC Server库的测试OpenID Connect服务器。基于ASP.NET Core MVC的应用程序的基本模板,带有`Abblix OIDC Server`库,也包含在我们早些时候安装的`Abblix.Templates`包中。让我们使用这个模板来创建一个名为`OpenIDProviderApp`的新应用程序:

Shell

 

dotnet new abblix-oidc-server -n OpenIDProviderApp

为了配置服务器,我们需要在OpenID Connect服务器上注册`BffClient`应用程序作为一个客户端,并添加一个测试用户。为此,请在`OpenIDProviderApp\Program.cs`文件中添加以下代码块:

C#

 

var userInfoStorage = new TestUserStorage(
    // ******************* START *******************
    new UserInfo(
        Subject: "1234567890",
        Name: "John Doe",
        Email: "[email protected]",
        Password: "Jd!2024$3cur3")
    // ******************** END ********************
);
builder.Services.AddSingleton(userInfoStorage);

// ...

// 在此注册并配置Abblix OIDC Server
builder.Services.AddOidcServices(options =>
{
    // 在此处配置OIDC服务器选项:
    // ******************* START *******************
    options.Clients = new[] {
        new ClientInfo("bff_sample") {
            ClientSecrets = new[] {
                new ClientSecret {
                    Sha512Hash = SHA512.HashData(Encoding.ASCII.GetBytes("secret")),
                }
            },
            TokenEndpointAuthMethod = ClientAuthenticationMethods.ClientSecretPost,
            AllowedGrantTypes = new[] { GrantTypes.AuthorizationCode },
            ClientType = ClientType.Confidential,
            OfflineAccessAllowed = true,
            PkceRequired = true,
            RedirectUris = new[] { new Uri("https://localhost:5003/signin-oidc", UriKind.Absolute) },
            PostLogoutRedirectUris = new[] { new Uri("https://localhost:5003/signout-callback-oidc", UriKind.Absolute) },
        }
    };
    // ******************** END ********************
    // 下面的URL指向AuthController的Login操作
    options.LoginUri = new Uri($"/Auth/Login", UriKind.Relative);

    // 下面的代码行生成用于签名令牌的新密钥。如果您想使用自己的密钥,请替换它。
    options.SigningKeys = new[] { JsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig) };
});

让我们详细回顾这段代码。我们注册了一个客户端,标识符为`bff_sample`,密钥为`secret`(将其存储为SHA512散列),表明获取令牌时将使用客户端身份验证,并通过POST消息发送密钥(`ClientAuthenticationMethods.ClientSecretPost`)。`AllowedGrantTypes`指定了客户端只允许使用授权码流。`ClientType`将客户端定义为保密的,意味着它可以安全地存储其密钥。`OfflineAccessAllowed`允许客户端使用刷新令牌。`PkceRequired`强制在认证过程中使用PKCE。`RedirectUris和`PostLogoutRedirectUris`分别包含认证后和会话终止后允许重定向的URL列表。

对于任何其他的OpenID Connect服务器,设置将会类似,差异只在于它们的配置方式。

实现基本BFF API

之前,我们提到使用`Microsoft.AspNetCore.Authentication.OpenIdConnect`包会自动将登录端点的实现添加到我们的示例应用程序中。现在,是时候实现BFF API的其余部分了。我们将使用ASP.NET MVC控制器来处理这些附加端点。让我们首先在`BffSample`项目中添加一个`Controllers`文件夹和一个`BffController.cs`文件,并在此文件中加入以下代码:

C#

 

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;

namespace BffSample.Controllers;

[ApiController]
[Route("[controller]")]
public class BffController : Controller
{
    public const string CorsPolicyName = "Bff";

    [HttpGet("check_session")]
    [EnableCors(CorsPolicyName)]
    public ActionResult> CheckSession()
    {
        // 当用户未经授权时,返回401未授权以强制SPA重定向到登录端点
        if (User.Identity?.IsAuthenticated != true)
            return Unauthorized();

        return User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
    }

    [HttpGet("login")]
    public ActionResult> Login()
    {
        // 初始化授权码流的逻辑
        return Challenge(new AuthenticationProperties { RedirectUri = Url.Content("~/") });
    }

    [HttpPost("logout")]
    public IActionResult Logout()
    {
        // 处理用户登出的逻辑
        return SignOut();
    }
}

让我们详细分解这个类代码:

  • [Route("[controller]")]属性为控制器中的所有操作设置了基本路由。在这种情况下,路由将与控制器的名称匹配,意味着我们的API方法的路径将以/bff/开始。
  • 常量CorsPolicyName = "Bff"定义了在方法属性中使用的CORS(跨源资源共享)策略的名称。我们稍后会有所涉及。
  • 三个方法CheckSessionLoginLogout实现了上述必要的BFF功能。它们分别处理/bff/check_session的GET请求,/bff/login的GET请求以及/bff/logout的POST请求。
  • CheckSession方法检查用户的认证状态。如果用户未认证,它将返回401 Unauthorized代码,这应该强制SPA重定向到认证端点。如果认证成功,该方法将返回一组声明及其值。此方法还包括一个名为CorsPolicyName的CORS策略绑定,因为调用此方法可能是跨域的,并且可能包含用于用户认证的cookie。
  • 如果之前的CheckSession调用返回了401 Unauthorized,SPA会调用Login方法。它确保用户仍然没有认证,并启动了配置的Challenge过程,这将导致重定向到OpenID Connect服务器,使用授权码流和PKCE进行用户认证,并颁发一个认证cookie。之后,控制返回到我们应用程序的根目录"~/",这将触发SPA重新加载,并以认证用户开始。
  • Logout方法也是由SPA调用的,但会终止当前的认证会话。它移除了由BffSample服务器部分颁发的认证cookie,并在OpenID Connect服务器端调用结束会话端点。

为BFF配置CORS

如上所述,CheckSession方法是为了异步调用SPA(通常使用Fetch API)。这个方法的正确运行取决于浏览器发送认证cookie的能力。如果SPA从不同的静态Web托管加载,例如CDN或运行在单独端口的开发服务器,这个调用就变成了跨域。这使得配置CORS策略成为必要,否则SPA将无法调用此方法。

我们在Controllers\BffController.cs文件中的控制器代码中已经指出了要使用名为CorsPolicyName = "Bff"的CORS策略。现在是配置这个策略的参数以解决我们的任务的时候了。让我们回到BffSample\Program.cs文件中,并添加以下代码块:

C#

 


// ******************* 开始 *******************
using BffSample.Controllers;
// ******************** 结束 ********************
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

// ...

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************* 开始 *******************
builder.Services.AddCors(
    options => options.AddPolicy(
        BffController.CorsPolicyName,
        policyBuilder =>
        {
            var allowedOrigins = configuration.GetSection("CorsSettings:AllowedOrigins").Get();

            if (allowedOrigins is { Length: > 0 })
                policyBuilder.WithOrigins(allowedOrigins);

            policyBuilder
                .WithMethods(HttpMethods.Get)
                .AllowCredentials();
        }));
// ******************** 结束 ********************
var app = builder.Build();

这段代码允许CORS策略方法从配置中指定的源(作为一个字符串数组CorsSettings:AllowedOrigins)使用GET方法调用,并允许在这次调用中发送cookie。另外,确保app.UseCors(...)的调用位于app.UseAuthentication()之前:

C#

 

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ******************* 开始 *******************
app.UseCors(BffController.CorsPolicyName);
// ******************** 结束 ********************
app.UseAuthentication();
app.UseAuthorization();

为了确保CORS策略正确工作,请将相应的设置添加到BffSample\appsettings.Development.json配置文件中:

JSON

 

{
  // ******************* 开始 *******************
  "CorsSettings": {
    "AllowedOrigins": [ "https://localhost:3000" ]
  },
  // ******************** 结束 ********************
 "OpenIdConnect": {
   "Authority": "https://localhost:5001",
   "ClientId": "bff_sample",

在我们的示例中,地址https://localhost:3000是使用npm run dev命令启动的带有React SPA的dev服务器的位置。您可以通过打开BffSample.csproj文件并找到SpaProxyServerUrl参数的值来找到这个地址。在实际应用程序中,CORS策略可能包括您的CDN(内容分发网络)或类似服务的地址。重要的是要记住,如果您的SPA从提供BFF API的不同地址和端口加载,您必须将此地址添加到CORS策略配置中。

在 React 应用程序中通过 BFF 实现认证

我们已经在外部服务器上实现了 BFF API。现在该关注 React SPA 并添加相应的功能来调用这个 API。让我们先导航到 BffSample\ClientApp\src\ 文件夹,创建一个 components 文件夹,并添加一个 Bff.tsx 文件,内容如下:

TypeScript

 

import React, { createContext, useContext, useEffect, useState, ReactNode, FC } from 'react';

// 定义BFF上下文的形状
interface BffContextProps {
    user: any;
    fetchBff: (endpoint: string, options?: RequestInit) => Promise;
    checkSession: () => Promise;
    login: () => void;
    logout: () => Promise;
}

// 创建BFF上下文,以便在应用程序中共享状态和功能
const BffContext = createContext({
    user: null,
    fetchBff: async () => new Response(),
    checkSession: async () => {},
    login: () => {},
    logout: async () => {}
});

interface BffProviderProps {
    baseUrl: string;
    children: ReactNode;
}

export const BffProvider: FC = ({ baseUrl, children }) => {
    const [user, setUser] = useState(null);

    // 通过删除末尾的斜杠来规范化基础URL,以避免不统一的URL
    if (baseUrl.endsWith('/')) {
        baseUrl = baseUrl.slice(0, -1);
    }

    const fetchBff = async (endpoint: string, options: RequestInit = {}): Promise => {
        try {
            // fetch函数包含凭据,以处理必要进行身份验证的cookies
            return await fetch(`${baseUrl}/${endpoint}`, {
                credentials: 'include',
                ...options
            });
        } catch (error) {
            console.error(`Error during ${endpoint} call:`, error);
            throw error;
        }
    };

    // 登录函数在用户需要身份验证时重定向到登录页面
    const login = (): void => {
        window.location.replace(`${baseUrl}/login`);
    };

    // checkSession函数负责在初始渲染时验证用户会话
    const checkSession = async (): Promise => {
        const response = await fetchBff('check_session');

        if (response.ok) {
            // 如果会话有效,用接收到的声明数据更新用户状态
            setUser(await response.json());
        } else if (response.status === 401) {
            // 如果用户未登录,重定向到登录页面
            login();
        } else {
            console.error('Unexpected response from checking session:', response);
        }
    };

    // 用户登出函数
    const logout = async (): Promise => {
        const response = await fetchBff('logout', { method: 'POST' });

        if (response.ok) {
            // 登出成功后重定向到主页
            window.location.replace('/');
        } else {
            console.error('Logout failed:', response);
        }
    };

    // useEffect用于在组件挂载时运行checkSession函数
    // 这确保了当应用加载时会话立即被检查
    useEffect(() => { checkSession(); }, []);

    return (
        // 为整个应用程序提供与BFF上下文相关的值和功能
        
            {children}
        
    );
};

// 定制钩子,以便在其他组件中轻松使用BFF上下文
export const useBff = (): BffContextProps => useContext(BffContext);

// 导出HOC,以提供对BFF上下文的访问
export const withBff = (Component: React.ComponentType) => (props: any) =>
    
        {context => }
    ;

此文件导出:

  • 组件BffProvider,它创建了一个BFF上下文,并为整个应用程序提供与身份验证和会话管理相关的函数和状态。
  • 自定义钩子 useBff(),返回一个包含当前用户状态和与BFF交互的函数的对象:checkSessionloginlogout。它旨在用于功能型React组件中。
  • 用于类式React组件的高级组件(HOC)withBff

接下来,创建一个 UserClaims 组件,用于在成功认证后显示当前用户声明。在 BffSample\ClientApp\src\components 文件夹中创建一个 UserClaims.tsx 文件,内容如下:

TypeScript

 

import React from 'react';
import { useBff } from './Bff';

export const UserClaims: React.FC = () => {
    const { user } = useBff();

    if (!user)
        return <div>Checking user session...</div>;

    return (
        <>
            <h2>User Claims</h2>
            {Object.entries(user).map(([claim, value]) => (
                <div key={claim}>
                    <strong>{claim}</strong>: {String(value)}
                </div>
            ))}
        </>
    );
};

这段代码使用 useBff() 钩子检查认证的用户,并在用户已认证时显示用户的声明列表。如果用户数据尚不可用,则显示文本 Checking user session...

现在,让我们转到 BffSample\ClientApp\src\App.tsx 文件。用必要的代码替换其内容。从 components/Bff.tsx 导入 BffProvider 和从 components/UserClaims.tsx 导入 UserClaims,并插入主组件代码:

TypeScript

 

import { BffProvider, useBff } from './components/Bff';
import { UserClaims } from './components/UserClaims';

const LogoutButton: React.FC = () => {
    const { logout } = useBff();
    return (
        <button className="logout-button" onClick={logout}>
            Logout
        </button>
    );
};

const App: React.FC = () => (
    <BffProvider baseUrl="https://localhost:5003/bff">
        <div className="card">
            <UserClaims/>
        </div>
        <div className="card">
            <LogoutButton />
        </div>
    </BffProvider>
);

export default App;

在此处,baseUrl 参数指定了我们BFF API的基础URL https://localhost:5003/bff。这种简化是有意的,并为了简化而这样做。在实际应用程序中,您应该动态地提供此设置,而不是硬编码它。有多种方法可以实现这一点,但讨论这些超出本文的范围。

Logout按钮允许用户退出登录。它调用通过useBff钩子提供的logout函数,并将用户的浏览器重定向到/bff/logout端点,从而终止服务器端的用户会话。

在这个阶段,您现在可以运行应用程序和OpenIDProviderApp并测试其功能。您可以在每个项目中使用dotnet run -lp https命令或您喜欢的IDE来启动它们。两个应用程序必须同时运行。

之后,打开您的浏览器并导航到https://localhost:5003。如果一切设置正确,SPA将加载并调用/bff/check_session/check_session端点将返回401响应,提示SPA将浏览器重定向到/bff/login,然后通过使用PKCE的OpenID Connect授权码流程在服务器上启动认证。您可以通过打开浏览器中的开发控制台并进入网络选项卡观察这一系列请求。在成功输入用户凭证([email protected]Jd!2024$3cur3)后,控制将返回到SPA,您将在浏览器中看到经过身份验证的用户声明:

Plain Text

 

sub: 1234567890
sid: V14fb1VQbAFG6JXTYQp3D3Vpa8klMLcK34RpfOvRyxQ
auth_time: 1717852776
name: John Doe
email: [email protected]

此外,点击Logout按钮将把浏览器重定向到/bff/logout,这将使用户退出登录,并且您将再次看到登录页面,提示您输入用户名和密码。

如果您遇到任何错误,您可以将您的代码与我们的GitHub存储库Abblix/Oidc.Server.GettingStarted进行比较,该存储库包含此以及其他准备运行的示例。

解决HTTPS证书信任问题

当在本地测试配置为通过HTTPS运行的Web应用程序时,您可能会遇到浏览器警告SSL证书不受信任。这个问题是因为ASP.NET Core使用的开发证书不是由公认的证书颁发机构(CA)签发,而是自签名或根本不在系统中。这些警告可以通过执行以下命令一次来消除:

Shell

 

dotnet dev-certs https --trust

此命令为localhost生成自签名证书并安装在您的系统上,使其信任此证书。证书将用于ASP.NET Core在本地运行Web应用程序。运行此命令后,请重新启动浏览器以使更改生效。

特别注意Chrome用户:即使将开发证书作为信任安装,某些版本的Chrome可能仍然出于安全原因限制对localhost站点的访问。如果您遇到指示您的连接不安全且Chrome阻止了对localhost的访问的错误,您可以按照以下方式绕过此问题:

  • 在错误页面的任何地方点击,并输入thisisunsafebadidea,这取决于您的Chrome版本。这些键盘序列在Chrome中作为绕过命令,允许您继续访问localhost站点。

在开发场景中使用这些绕过方法很重要,因为它们可能带来实际的安全风险。

通过 BFF 调用第三方 API

我们已经在 BffSample 应用中成功实现了认证。现在让我们继续调用需要访问令牌的第三方 API。

假设我们有一个单独的服务提供必要的数据,例如天气预报,并且只有通过访问令牌才能访问它。BffSample 的服务器部分的角色将是充当反向代理,即接受和认证来自 SPA 的数据请求,添加访问令牌,将此请求转发给天气服务,然后将服务的响应返回给 SPA。

创建 ApiSample 服务

在演示通过 BFF 进行远程 API 调用之前,我们需要创建一个应用程序作为示例中的这个 API。

要创建应用程序,我们将使用 .NET 提供的模板。导航到包含 OpenIDProviderAppBffSample 项目的文件夹,并运行以下命令以创建 ApiSample 应用程序:

Shell

 

dotnet new webapi -n ApiSample

这个 ASP.NET Core 最小 API 应用程序提供一个路径为 /weatherforecast 的单一端点,以 JSON 格式提供天气数据。

首先,将ApiSample应用程序在本地使用的随机分配端口号更改为固定端口5004。如前所述,这一步不是强制性的,但它简化了我们的设置。为此,打开ApiSample\Properties\launchSettings.json文件,找到名为https的配置文件,并将applicationUrl属性的值更改为https://localhost:5004

接下来,让我们使得天气API只能通过访问令牌访问。导航到ApiSample项目文件夹,并添加JWT令牌身份验证的NuGet包:

Shell

 

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

ApiSample\Program.cs文件中配置名为WeatherApi的身份验证方案和授权策略:

C#

 

// ******************* START *******************
using System.Security.Claims;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);

// 在容器中添加服务。
// 了解有关配置Swagger/OpenAPI的信息请访问 https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthentication()
    .AddJwtBearer(options => configuration.Bind("JwtBearerAuthentication", options));

const string policyName = "WeatherApi";

builder.Services.AddAuthorization(
    options => options.AddPolicy(policyName, policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireAssertion(context =>
        {
            var scopeValue = context.User.FindFirstValue("scope");
            if (string.IsNullOrEmpty(scopeValue))
                return false;

            var scope = scopeValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
            return scope.Contains("weather", StringComparer.Ordinal);
        });
    }));
// ******************** END ********************
var app = builder.Build();

此代码块通过从应用程序设置中读取配置来设置身份验证,包括使用JWT(JSON Web Tokens)进行授权,并配置了一个名为WeatherApi的授权策略。WeatherApi授权策略设置了以下要求:

  • policy.RequireAuthenticatedUser():确保只有经过身份验证的用户才能访问受保护的资源。
  • `policy.RequireAssertion(context => …)`: 用户必须有一个 `scope` 声明,其值包括 `weather`。由于根据 `RFC 8693` ,`scope` 声明可以使用空格分隔的多个值,实际的 `scope` 值会被分割成单独的部分,然后检查结果数组是否包含所需的 `weather` 值。

这些条件一起确保只有具有对 `weather` 范围授权的访问令牌的已认证用户可以调用受此策略保护的端点。

我们需要将此策略应用到 `/weatherforecast` 端点。添加如下所示的 `RequireAuthorization()` 调用:

C#

 

app.MapGet("/weatherforecast", () =>
{

// ...

})
.WithName("GetWeatherForecast")
// ******************* START *******************
.WithOpenApi()
.RequireAuthorization(policyName);
// ******************** END ********************

在 `ApiSample` 应用程序的 `appsettings.Development.json` 文件中为身份验证方案添加必要的配置设置:

JSON

 

{
  // ******************* START *******************
  "JwtBearerAuthentication": {
    "Authority": "https://localhost:5001",
    "MapInboundClaims": false,
    "TokenValidationParameters": {
      "ValidTypes": [ "at+jwt" ],
      "ValidAudience": "https://localhost:5004",
      "ValidIssuer": "https://localhost:5001"
    }
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

让我们详细查看每个设置:

  • Authority: 这是指向发行 JWT 令牌的 OpenID Connect 授权服务器的 URL。`ApiSample` 应用程序中配置的身份验证提供程序将使用此 URL 来获取验证令牌所需的信息,例如签名密钥。
  • MapInboundClaims:此设置用于控制JWT令牌中的传入声明如何映射到ASP.NET Core中的内部声明。它设置为false,意味着声明将使用JWT中的原始名称。
  • TokenValidationParameters
    • ValidTypes:设置为at+jwt,根据RFC 9068 2.1,这表示JWT格式的访问令牌。
    • ValidAudience:指定应用程序将接受为客户端https://localhost:5004发行的令牌。
    • ValidIssuer:指定应用程序将接受由服务器https://localhost:5001发行的令牌。

OpenIDProviderApp的额外配置

认证服务OpenIDProviderApp与客户端应用程序BffSample的组合可以很好地提供用户认证。然而,要启用对远程API的调用,我们需要将ApiSample应用程序作为资源注册到OpenIDProviderApp中。在我们的示例中,我们使用的是Abblix OIDC服务器,它支持RFC 8707: OAuth 2.0的资源指标。因此,我们将ApiSample应用程序作为具有weather作用域的资源进行注册。如果你使用的是不支持资源指标的另一个OpenID连接服务器,仍然建议为此远程API注册一个唯一的作用域(例如,在我们的示例中为weather)。

请将以下代码添加到文件OpenIDProviderApp\Program.cs中:

C#

 

// 注册并配置Abblix OIDC服务器
builder.Services.AddOidcServices(options => {
    // ******************* 开始 *******************
    options.Resources =
    [
        new(new Uri("https://localhost:5004", UriKind.Absolute), new ScopeDefinition("weather")),
    ];
    // ******************** 结束 ********************
    options.Clients = new[] {
        new ClientInfo("bff_sample") {

在这个示例中,我们注册了ApiSample应用程序,指定了其基本地址https://localhost:5004作为资源,并定义了一个名为weather的具体作用域。在实际应用中,特别是那些由许多端点组成的复杂API,建议为每个单独的端点或相关端点组定义不同的作用域。这种方法允许更精确的访问控制,并在管理访问权限方面提供了灵活性。例如,您可以为不同的操作、应用程序模块或用户访问级别创建不同的作用域,从而使您可以更细致地控制谁可以访问API的特定部分。

详细介绍BffSample以代理请求到远程API

客户端应用程序BffSample现在需要的不仅仅是为ApiSample请求访问令牌。它还必须处理来自SPA到远程API的请求。这涉及到将从未OpenIDProviderApp服务获得的访问令牌添加到这些请求中,将它们转发到远程服务器,然后将服务器的响应返回给SPA。本质上,BffSample需要作为一个反向代理服务器发挥作用。

我们不会在客户端应用程序中手动实现请求代理,而是将使用由微软开发的现成产品YARP(Yet Another Reverse Proxy)。YARP是一个用.NET编写的反向代理服务器,作为NuGet包提供。

要在BffSample应用程序中使用YARP,首先添加NuGet包:

Shell

 

dotnet add package Yarp.ReverseProxy

然后在文件BffSample\Program.cs的开头添加以下命名空间:

C#

 

using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Net.Http.Headers;
using Yarp.ReverseProxy.Transforms;

在调用 `var app = builder.Build();` 之前,添加以下代码:

C#

 

builder.Services.AddHttpForwarder();

在调用 `app.MapControllerRoute()` 和 `app.MapFallbackToFile()` 之间:

C#

 

app.MapForwarder(
    "/bff/{**catch-all}",
    configuration.GetValue("OpenIdConnect:Resource") ?? throw new InvalidOperationException("Unable to get OpenIdConnect:Resource from current configuration"),
    builderContext =>
    {
        // 从请求路径中移除 "/bff" 前缀
        builderContext.AddPathRemovePrefix("/bff");

        builderContext.AddRequestTransform(async transformContext =>
        {
            // 获取在认证过程中早些时候接收到的访问令牌
            var accessToken = await transformContext.HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            
            // 在代理请求中添加带有访问令牌的头部
            transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        });
    }).RequireAuthorization();

让我们来分解这段代码做了什么:

  • builder.Services.AddHttpForwarder() 在 DI 容器中注册了 YARP 必要的服务。
  • app.MapForwarder 设置请求转发到另一个服务器或端点。
  • "/bff/{**catch-all}" 是反向代理将响应的路径模式。所有以 /bff/ 开头的请求将由 YARP 处理。{**catch-all} 是用于捕获 URL 中 /bff/ 之后的所有剩余部分的占位符。
  • configuration.GetValue<string>("OpenIdConnect:Resource") 使用应用程序的配置来获取 `OpenIdConnect:Resource` 节点的值。此值指定了请求将被转发到的资源地址。在我们示例中,这个值将是 `https://localhost:5004` – 这是 `ApiSample` 应用程序运行的基础 URL。
  • `builderContext => ...`为YARP针对来自SPA的每个传入请求执行必要的转换。在我们的案例中,将会有两个这样的转换:
    • builderContext.AddPathRemovePrefix("/bff")从原始请求路径中移除/bff前缀。
    • builderContext.AddRequestTransform(async transformContext => ...)为请求添加一个Authorization HTTP头,其中包含在认证过程中之前获取的访问令牌。因此,即使SPA本身无法访问此令牌,SPA对远程API的请求也将使用访问令牌进行认证。
  • .RequireAuthorization()指定所有转发请求都需要授权。只有经过授权的用户才能访问路由/bff/{**catch-all}

为了在认证过程中请求资源 https://localhost:5004 的访问令牌,您需要将 Resource 参数添加到 BffSample/appsettings.Development.json 文件中的 OpenIdConnect 配置中,值为 https://localhost:5004

JSON

 

  "OpenIdConnect": {
    // ******************* START *******************
    "Resource": "https://localhost:5004",
    // ******************** END ********************
    "Authority": "https://localhost:5001",
    "ClientId": "bff_sample",

同时,还需要在 BffSample/appsettings.json 文件中将 scope 数组的另一个值添加为 weather

JSON

 

{
  "OpenIdConnect": {

    // ...

    // ******************* START *******************
    "Scope": ["openid", "profile", "email", "weather"],
    // ******************** END ********************

    // ...

  }
}

Notes: 在实际项目中,有必要监控访问令牌的过期。当令牌即将过期时,您应该通过从认证服务中使用刷新令牌提前请求一个新的令牌,或者处理来自远程 API 的访问拒绝错误,通过获取新令牌并重试原始请求。为了简洁起见,本文故意省略了这一部分。

通过SPA应用程序使用BFF请求天气API

后端现在已经准备就绪。我们有一个 ApiSample 应用程序,它实现了一个带有基于令牌的授权的API,以及一个 BffSample 应用程序,它包括一个内嵌的反向代理服务器,以提供对API的安全访问。最后一步是添加功能以请求这个API并在React SPA中显示获取的数据。

将文件WeatherForecast.tsx添加到BffSample\ClientApp\src\components,内容如下:

TypeScript

 

import React, { useEffect, useState } from 'react';
import { useBff } from './Bff';

interface Forecast {
    date: string;
    temperatureC: number;
    temperatureF: number;
    summary: string;
}

interface State {
    forecasts: Forecast[];
    loading: boolean;
}

export const WeatherForecast: React.FC = () => {
    const { fetchBff } = useBff();
    const [state, setState] = useState<State>({ forecasts: [], loading: true });
    const { forecasts, loading } = state;

    useEffect(() => {
        fetchBff('weatherforecast')
            .then(response => response.json())
            .then(data => setState({ forecasts: data, loading: false }));
    }, [fetchBff]);

    const contents = loading
        ? <p><em>Loading...</em></p>
        : (
            <table className="table table-striped" aria-labelledby="tableLabel">
                <thead>
                <tr>
                    <th>Date</th>
                    <th>Temp. (C)</th>
                    <th>Temp. (F)</th>
                    <th>Summary</th>
                </tr>
                </thead>
                <tbody>
                {forecasts.map((forecast, index) => (
                    <tr key={index}>
                        <td>{forecast.date}</td>
                        <td align="center">{forecast.temperatureC}</td>
                        <td align="center">{forecast.temperatureF}</td>
                        <td>{forecast.summary}</td>
                    </tr>
                ))}
                </tbody>
            </table>
        );

    return (
        <div>
            <h2 id="tableLabel">Weather forecast</h2>
            <p>This component demonstrates fetching data from the server.</p>
            {contents}
        </div>
    );
};

我们来分解这段代码:

  • Forecast接口定义了天气预报数据的结构,包括日期、摄氏和华氏温度,以及天气概述。State接口描述了组件状态的结构,包括一个天气预报数组和一个加载标志。
  • WeatherForecast组件从useBff钩子中获取fetchBff函数,并使用它从服务器获取天气数据。组件的状态使用useState钩子管理,初始化为空的预报数组和加载标志设为true。
  • useEffect钩子在组件挂载时触发fetchBff函数,从/bff/weatherforecast端点获取天气预报数据。一旦收到服务器的响应并转换为JSON,数据将存储在组件的状态中(通过setState),并将加载标志更新为false
  • 根据加载标志的值,组件要么显示“正在加载…”消息,要么渲染包含天气预报数据的表格。表格包括日期、摄氏和华氏温度以及每个预报的天气概述列。

现在,将WeatherForecast组件添加到BffSample\ClientApp\src\App.tsx

TypeScript

 

// ******************* 开始 *******************
import { WeatherForecast } from "./components/WeatherForecast";
// ******************** 结束 ********************

// ...

    
// ******************* 开始 *******************
// ******************** 结束 ********************
   

运行和测试

如果一切步骤都正确完成,你现在可以启动我们的三个项目。使用控制台命令dotnet run -lp https为每个应用程序以HTTPS方式运行。

在启动所有三个应用程序之后,打开浏览器中的BffSample应用程序(https://localhost:5003),并使用凭据[email protected]Jd!2024$3cur3进行认证。成功认证后,你应该会看到来自认证服务器的声明列表,与之前看到的一样。在此之下,你还会看到天气预报。

天气预报是由单独的应用程序ApiSample提供的,它使用由认证服务OpenIDProviderApp颁发的访问令牌。在BffSample应用程序窗口中看到天气预报表明我们的SPA成功调用了BffSample的后端,然后通过添加访问令牌将调用代理给ApiSampleApiSample认证了调用,并以包含天气预报的JSON响应。

完整的解决方案可以在GitHub上找到。

如果您在实施测试项目时遇到任何问题或错误,您可以参考GitHub存储库中提供的完整解决方案。只需克隆Abblix/Oidc.Server.GettingStarted存储库,即可访问本文中描述的完全实现的项目。此资源既作为故障排除工具,也是创建您自己的项目的坚实起点。

结论

OAuth 2.0和OpenID Connect等认证协议的发展反映了网络安全和浏览器功能更广泛的趋势。从过时的隐式流方法转向更安全的授权码流与PKCE方法,极大地提高了安全性。然而,在不受控制的环境中操作固有的漏洞使得保护现代SPA变得具有挑战性。将令牌独家存储在后端并采用后端对于前端(BFF)模式是一种有效的缓解风险和确保强大用户数据保护的策略。

开发人员必须保持警惕,通过实施新的认证方法和最新的架构方法来应对不断变化的安全威胁。这种主动的方法对于构建安全和可靠的网络应用程序至关重要。在本文中,我们探索并实现了使用流行的.NET和React技术堆栈的OpenID Connect、BFF和SPA集成的现代方法。这种方法可以为您未来的项目提供一个坚实的基础。

随着我们展望未来,网络安全的持续发展将需要对身份验证和架构模式进行更大的创新。我们鼓励您探索我们的GitHub仓库,为现代身份验证解决方案的发展做出贡献,并与不断进步保持联系。感谢您的关注!

Source:
https://dzone.com/articles/modern-authentication-on-dotnet