随着网路技術的不斷進步,保護這些技術的方法和協議也在不斷演進。OAuth 2.0和OpenID Connect協議在面對新兴安全威脅和网路應用程式的複雜性增加時,已有顯著的發展。传统的认证方法,雖然一度有效,但现在對於現代单页應用(SPA)來說已經變得過時,因為SPA面臨新的安全挑戰。在這種情境下,後端為前端(BFF)架構模式已經浮現出來,作為組織SPA和它們後端系統間互動的推薦解決方案,提供一個更安全和易於管理的认证和会话管理方法。本文深入探讨BFF模式,通過使用.NET和React实施的簡潔解決方案,展示其實際應用。到最後,你將有一個清晰的 Understanding of how to use the BFF pattern to enhance the security and functionality of your web applications.
历史背景
OAuth 2.0和OpenID Connect的历史反映了互联網技術的持續進化。讓我們近距離观察這些協議以及它們對現代网路應用程式的影响。
2012年問世的OAuth 2.0協議已經成為广為采用的是標準化授權 protocol。它允許第三方應用程序在不对客户端暴露用戶凭证的情况下,获取对用户资源的有限访问权。OAuth 2.0支持几种流程,每种流程都旨在靈活地适应不同的使用案例。
建立在OAuth 2.0基礎上,OpenID Connect (OIDC) Protocol 在 2014 年問世,增添了必要的認證功能。它為客戶應用提供了一個標準化的方法,以核實用戶的身份並通過標準化的接入點或通過取得 JWT(JSON Web Token)格式的 ID 憑据來獲得他們的基本資訊。
威脅模型演變
隨著 SPA 的功能日益增強和受歡迎程度日益提高,SPA 的威脅模型也已演變。像跨站點腳本(XSS)和跨站點請求伪造(CSRF)等漏洞變得更加普遍。由於 SPA 通常通過 API 与服務器互動, Therefore, securely storing and using access tokens and refresh tokens has become crucial for security.
應對時代的需求,OAuth 和 OpenID Connect 協程繼續演變,以適應新技术和新威脅帶來的挑戰。同時,威脅的不斷演變和安全的實踐進步意味著過時的方法已不再滿足現代安全的要提高。因此,OpenID Connect 協程目前提供廣泛的功能,但很多已經、或即將被認為過時且往往不安全。這種多樣性為 SPA 開發者選擇與 OAuth 2.0 和 OpenID Connect 服務器互動的最恰當和安全的方法帶來困難。
特別是他仕,隐式流(Implicit Flow)已經可以认为是過時,對於任何類型的用戶端,無論是單頁應用程式(SPA)、移動應用程式還是桌面應用程式,現在都強烈建議與Proof Key for Code Exchange (PKCE)一同使用授權代碼流(Authorization Code Flow)。
現代SPA的安全性
為什麼即使在使用PKCE與授權代碼流的情況下,現代SPA仍被認為是脆弱的?這里有幾個答案。
JavaScript代码漏洞
JavaScript是一種強大的程式設計語言,對現代單頁應用程式(SPA)扮演著關鍵角色。然而,它的廣泛功能和普及性帶來了潛在的風險。基於React、Vue或Angular等庫和框架建立的現代SPA,使用大量的庫和依賴。您可以在node_modules
文件夾中看到它們,這些依賴的數量可能达到数百甚至数千。這些庫中的每一個都可能包含不同程度的重要漏洞,而SPA開發者無法彻底檢查所用所有依賴的代碼。通常開發者甚至不會跟蹤完整的依賴列表,因為它們相互依賴。即使開發者將自己的代碼開發到最高質量和安全性標準,也無法完全確定最終應用程序中是否存在漏洞。
惡意的JavaScript代碼可以通過多種方式注入到應用程式中,例如跨站腳本攻擊(XSS)或第三方庫的妥協,從而獲得與合法應用程式代碼相同的特權和數據訪問級別。這使得惡意代碼能夠竊取當前頁面的數據,與應用介面進行互動,向後端發送請求,從本地存儲(localStorage, IndexedDB)中竊取數據,甚至可以自行啟動認證會話,使用相同的授權碼和PKCE流程獲取其自己的訪問令牌。
Spectre漏洞
Spectre漏洞利用現代處理器架構的特性來訪問應該是隔離的數據。這些漏洞對於單頁應用(SPAs)特別危險。
首先,SPAs大量使用JavaScript來管理應用狀態和與伺服器交互。這增加了惡意JavaScript代碼利用Spectre漏洞的攻擊面。其次,與傳統的多頁應用(MPAs)不同,SPAs很少重新加載,這意味著頁面及其加載的代碼會長時間保持活動狀態。這為攻擊者使用惡意JavaScript代碼進行攻擊提供了更多的時間。
Spectre漏洞允許攻擊者竊取存儲在JavaScript應用記憶體中的訪問令牌,從而通過冒充合法應用程式來訪問受保護的資源。推測執行也可以用來竊取用戶會話數據,使得攻擊者即使在SPA關閉後也能繼續其攻擊。
未來發現與Spectre相似的漏洞並不無可能。
應該怎麼做?
讓我們總結一個重要的中途結論。現代的SPA,倚賴大量第三方的JavaScript庫,在用戶設備的瀏覽器環境中運行,是在開發者無法完全控制的軟件和硬體環境中運行。因此,我們應該認為这类應用 inherently vulnerable( inherently: 天生的, 固有的)。
對於列出的威脅,有更多的專家傾向於完全不將令牌存儲在瀏覽器中,並設計應用程序,使其訪問令牌和刷新令牌僅由應用程序的服務器端獲取和處理,從不傳遞到瀏覽器端。在具有後端的SPA中,可以使用後端為前端(BFF)建築模式來實現。
授权服務器(OP)、實現BFF建築模式的客戶端(RP)和第三方的API(資源服務器)之間的交互动作方案如下:
使用BFF建築模式保護SPA具有幾個優點。訪問令牌和刷新令牌存儲在服務器端,從不傳遞到瀏覽器,防止因漏洞而被竊取。會話和令牌管理在服務器上處理,讓安全控制更出色,身份驗證核實更可靠。客戶端應用程序通過BFF與服務器交 interact(交互动作),簡化了應用程序邏輯,減少了惡意代碼執行的風險。
在.NET平台上實施後端為前端模式。
在 .NET 平台上實作好友谊模型(BFF)之前,讓我們考慮其必要的組件並計劃我們的行動。假設我們已經有了一個配置好的 OpenID Connect 服務器,並且我們需要開發一個與後端协作的 SPA,並使用 OpenID Connect 實現認證,以及使用 BFF 模式組織服務器與客戶端之間的互動。
根據文件 OAuth 2.0 for Browser-Based Applications,BFF 架構模式假設後端作為 OpenID Connect 客戶端行動,使用带有 PKCE 的授權代碼流進行認證,在其一面獲得並保存訪問和刷新令牌,並從不上傳到 SPA 的一面在瀏覽器中。BFF 模式還假設後端一面存在一個 API,該 API 包括四個主要端點:
- Check Session: 用於檢查活躍的使用者認證會話。通常從 SPA 使用異步 API (fetch) 調用,如果成功,則返回關於活躍使用者的信息。因此,從第三個來源(例如 CDN)上加載的 SPA 可以檢查認證狀態,並繼續與使用者协作,或進行使用 OpenID Connect 服務器進行認證。
- 登入: 開始在 OpenID Connect 伺服器上的認證過程。通常,如果 SPA 在步驟 1 中通過 Check Session 無法取得認證用戶數據,它會將瀏覽器重定向到此 URL,然後形成一個完整的請求送往 OpenID Connect 伺服器並將瀏覽器重定向至此。
- 登錄: 接收到伺服器在顺利完成認證的第二步後發送的授權碼。直接向 OpenID Connect 伺服器請求,以交換授權碼 + PKCE 码验证器來獲得訪問和刷新令牌。通過向用戶發送認證cookies,在客戶端初始化一個經過認證的會話。
- 登出: 用於結束認證會話。通常,SPA將瀏覽器重定向到此URL,进而形成一個請求,向 OpenID Connect 伺服器的 End Session 端點結束會話,同時在客戶端刪除會話和认证cookies。
現在讓我們檢查 .NET 平台提供的箱中工具並看看我們可以用的东西来实现 BFF 模式。.NET 平台提供了 Microsoft.AspNetCore.Authentication.OpenIdConnect
NuGet 包,这是由 Microsoft 支持的 OpenID Connect 客戶端的现成实现。这个包支持授权码流和 PKCE,并添加了一个相对路径 /signin-oidc 的端点,该端点已经实现了必要的登录端点功能(如上文第3点所述)。因此,我们只需要实现剩下的三个端点。
為了一個實際的整合範例,我們將採用基於Abblix OIDC Server庫的測試 OpenID Connect 伺服器。然而,以下所述的內容適用於任何其他伺服器,包括 Facebook、Google、Apple 以及任何其他符合 OpenID Connect 協議規範的公開伺服器。
在前端實現 SPA,我們將使用 React 庫,而在後端,我們將使用 .NET WebAPI。這是在撰寫本文時最常見的技術堆疊之一。
組件及其互動的整體方案如下:
要使用本文中的範例,您還需要安裝.NET SDK和Node.js。本文中的所有範例均使用 .NET 8、Node.js 22 和 React 18 開發和測試,這些都是在撰寫本文時的最新版本。
使用 .NET 後端在 React 上創建客戶端 SPA。
快速創建客戶端應用程序時,使用预制模板非常方便。直到 .NET 7 版本,SDK 提供了內置的 .NET WebAPI 應用程序模板和 React SPA。不幸的是,在 .NET 8 版本中移除了這個模板。这就是為什麼 Abblix 團隊創建了自家模板,該模板包括一個 Based on Vite 的 .NET WebApi 後端、基於 React 庫的前端 SPA 和 TypeScript。這個模板作為 Abblix.Templates
包的一部分公开可用,您可以通過運行以下命令來安裝它:
dotnet new install Abblix.Templates
現在我們可以使用名稱為 abblix-react
的模板。讓我們用它來創建一個稱為 BffSample
的新應用程序:
dotnet new abblix-react -n BffSample
這個命令創建了一個包括一個 .NET WebApi 後端和一個 React SPA 客戶端的應用程序。與 SPA 相關的文檔位於 BffSample\ClientApp
目錄中。
在創建項目後,系統將提示您運行命令以安装依賴:
cmd /c "cd ClientApp && npm install"
這個動作是必要的,以安裝應用程序客戶端部分所需的全部依賴。為成功啟動項目,建議同意並執行這個命令,通過輸入 Y
(yes)。
讓我們立即將 BffSample
應用程序在本地運行的端口號更改為 5003。這個動作不是強制的,但它將簡化 OpenID Connect 服務器的後續配置。要做到這一點,請打開 BffSample\Properties\launchSettings.json
文件,尋找名稱為 https
的配置文件,並將 applicationUrl
屬性的值更改為 https://localhost:5003
。
接下來,在 BffSample
文件夾中加入實現 OpenID Connect 客戶端的 NuGet 包,執行以下命令:
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
在應用中設定兩個認證方案,分別命名為 Cookies
和 OpenIdConnect
,並從應用配置中讀取它們的設定。为此,請修改 BffSample\Program.cs
文件:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// ******************* 開始 *******************
var configuration = builder.Configuration;
builder.Services
.AddAuthorization()
.AddAuthentication(options => configuration.Bind("Authentication", options))
.AddCookie()
.AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************** 結束 ********************
var app = builder.Build();
並在地 BffSample\appsettings.json
文件中加入连接到 OpenID Connect 服務器的必要設定:
{
// ******************* 開始 *******************
"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
},
// ******************** 結束 ********************
"Logging": {
"LogLevel": {
"Default": "Information",
並在地 BffSample\appsettings.Development.json
文件中也是這樣做:
{
// ******************* 開始 *******************
"OpenIdConnect": {
"Authority": "https://localhost:5001",
"ClientId": "bff_sample",
"ClientSecret": "secret"
},
// ******************** 結束 ********************
"Logging": {
"LogLevel": {
"Default": "Information",
讓我們简要地复查每個設定及其用途:
<強>驗證
區段:DefaultScheme
屬性設定預設使用Cookies
方案進行驗證,而DefaultChallengeScheme
在用戶無法通過預設方案驗證時,將驗證執行權委派給OpenIdConnect
方案。因此,當用戶對應用程序而言是未知時,將呼叫 OpenID Connect 服務器進行驗證,然後經過驗證的用戶將接收到一個驗證 Cookie,並且所有進一步的服务器調用均會使用它進行驗證,而无需再聯繫 OpenID Connect 服務器。OpenIdConnect
節:SignInScheme
和SignOutScheme
屬性指定Cookies
方案,該方案將用於登錄後保存用戶的信息。- 《code>
Authority
屬性包含 OpenID Connect 服務器的基礎 URL。ClientId
和ClientSecret
指定客戶端應用程式的識別碼和私密金鑰,這些值在 OpenID Connect 服務器上註冊。 SaveTokens
指示需要保存從 OpenID Connect 服務器radius身份驗證後收到的令牌。Scope
包含BffClient
應用程式请求的範圍列表。在此案例中,请求了標準範圍openid
(用戶識別符)、profile
(用戶个人资料)和email
(電子郵件)。MapInboundClaims
負責將從 OpenID Connect 服務器到来的声明轉換為應用程式中使用的声明。值为false
表示声明將以從 OpenID Connect 服務器收到的形式保存於已驗證用戶的會話中。ResponseType
值為code
表示客戶端將使用授权碼流。ResponseMode
指定在查询字符串中传送授权码,这是授权码流的默认方法。UsePkce
屬性指示在驗證过程中使用 PKCE 以防止窃取授權码。GetClaimsFromUserInfoEndpoint
屬性指示應該從 UserInfo 端點取得用戶个人资料數據。
由於我們的應用程序不信賴未通過驗證的用戶交互动,我們將確保 React SPA 只能在成功驗證後加載。當然,如果 SPA 是從外部源,例如靜態網頁托管,例如從內容分发網絡 (CDN) 伺服器或以 npm start
命令啟動的本地開發伺服器(例如,在運行我們的示例時以debug模式運行)加載,那麼在上加載 SPA 之前無法檢查驗證狀態。但是,當我們自己的 .NET後端負責上加載 SPA時,這是可能的。
為此,在 BffSample\Program.cs
文件中添加負責驗證和授權的中間件:
app.UseRouting();
// ******************* 開始 *******************
app.UseAuthentication();
app.UseAuthorization();
// ******************** 結束 ********************
在 BffSample\Program.cs
文件的末尾,添加對上加載 SPA 的轉移直接進行時,加入授權要求 .RequireAuthorization()
:
app.MapFallbackToFile("index.html").RequireAuthorization();
設定 OpenID Connect 服務器。
如早前所提,對於實際整合示例,我們將使用基於 Abblix OIDC Server 庫的測試 OpenID Connect 服務器。基於 ASP.NET Core MVC 的應用程序的基本模板,使用 Abblix OIDC Server
庫,也已在我們早先安裝的 Abblix.Templates
套件中可用。讓我們使用這個模板來創建一個命名為 OpenIDProviderApp
的新應用程序:
dotnet new abblix-oidc-server -n OpenIDProviderApp
要配置服務器,我們需要在 OpenID Connect 服務器上註冊 BffClient
應用程序作為客戶端,並添加一個測試用戶。為此,需要在 OpenIDProviderApp\Program.cs
文件中添加以下片段:
var userInfoStorage = new TestUserStorage(
// ******************* 開始 *******************
new UserInfo(
Subject: "1234567890",
Name: "John Doe",
Email: "[email protected]",
Password: "Jd!2024$3cur3")
// ******************** 結束 ********************
);
builder.Services.AddSingleton(userInfoStorage);
// ...
// 註冊並配置 Abblix OIDC 服務器
builder.Services.AddOidcServices(options =>
{
// 在此處配置 OIDC 服務器選項:
// ******************* 開始 *******************
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) },
}
};
// ******************** 結束 ********************
// 下列 URL 指向 AuthController 的 Login 動作
options.LoginUri = new Uri($"/Auth/Login", UriKind.Relative);
// 下列行生成用於签名的新的金鑰。如果您想使用自己的金鑰,請替換它。
options.SigningKeys = new[] { JsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig) };
});
我們來詳細查看這段程式碼。我們為客戶端註冊一個標識bff_sample
及一個密鑰secret
(將它存儲為SHA512散列值),指明取得憑據時將使用帶有密鑰的客戶端驗證(ClientAuthenticationMethods.ClientSecretPost
)。AllowedGrantTypes
指定了客戶端只允許使用授權碼流。ClientType
將客戶端定義為保密性,意味著它可以安全地存放其密钥。OfflineAccessAllowed
允許客戶端使用更新憑據。PkceRequired
強制要求在驗證時使用PKCE。RedirectUris
和PostLogoutRedirectUris
分别 contain lists of allowed URLs for redirection after authentication and session termination, respectively.
對於任何其他的OpenID Connect服務器,設定將類似,差異只在于它們的配置方式。
實現基本BFF API
之前,我們提到使用Microsoft.AspNetCore.Authentication.OpenIdConnect
套件會自動將签入端點的實作添加到我們的示例應用中。現在,是實現BFF API的剩餘部分的時候了。我們將使用ASP.NET MVC控制器來實現這些額外的端點。讓我們從添加一個Controllers
文件夾和一個BffController.cs
文件開始,在BffSample
项目中,並將以下代碼放在里面:
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 (跨源资源共享) 策略的名稱。我們稍後再 refers to it.- 三個方法
CheckSession
、Login
和Logout
实现了上述必要的 BFF 功能。它們分別處理/bff/check_session
上的GET請求、/bff/login
上的GET請求以及/bff/logout
上的POST請求。 CheckSession
方法檢查用戶的驗證狀態。如果用戶未通過驗證,它返回401 Unauthorized
代碼,這應該會強制 SPA 重定向到驗證端點。如果驗證成功,該方法返回一組声明及其值。該方法還包括一個名稱為CorsPolicyName
的 CORS 策略绑定,因為對於用戶登錄的跨源請求,此方法的調用可能需要包含用於用戶驗證的cookies。- SPA 如果之前的
CheckSession
呼叫返回401 Unauthorized
,就會呼叫Login
方法。這確保使用者尚未通過身份驗證,並啟動已配置的Challenge
流程,這將導致重定向到 OpenID Connect 伺服器,使用授權碼流程和 PKCE 進行使用者身份驗證,並發出身份驗證 cookie。之後,控制返回到我們應用程式的根"~/"
,這將觸發 SPA 重新載入並以已驗證的使用者開始。 - SPA 也會呼叫
Logout
方法,但會終止當前的身份驗證會話。它會移除由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
檔案並添加以下代碼塊:
// ******************* 開始 *******************
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();
这段程式碼允許從CorsSettings:AllowedOrigins
配置中指定的來源 load 的 SPAs 通過 CORS 策略方法,使用 GET
方法,並允許在這個呼叫中傳送cookies。另外,確保 app.UseCors(...)
的呼叫置於 app.UseAuthentication()
之前:
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ******************* 開始 *******************
app.UseCors(BffController.CorsPolicyName);
// ******************** 結束 ********************
app.UseAuthentication();
app.UseAuthorization();
為了確保 CORS 策略正確運作,請將相對應的設定添加到 BffSample\appsettings.Development.json
配置文件中:
{
// ******************* 開始 *******************
"CorsSettings": {
"AllowedOrigins": [ "https://localhost:3000" ]
},
// ******************** 結束 ********************
"OpenIdConnect": {
"Authority": "https://localhost:5001",
"ClientId": "bff_sample",
在我們的示例中,位址 https://localhost:3000
是使用 npm run dev
命令啟動 React SPA 的開發服務器地址。您可以通过開啟 BffSample.csproj
文件並尋找 SpaProxyServerUrl
參數的值來找到這個位址。在實際應用中,CORS 策略可能包括您的 CDN (內容傳送網絡) 或類似的服務地址。重要的是要記住,如果您的 SPA 從與提供 BFF API 的位址和端口不同的位址和端口 load,您必須將此位址添加到 CORS 策略配置中。
在 React 應用中透過 BFF 实施認證
我們已在伺服器端实施了 BFF API。現在是關注 React SPA 並為其添加呼叫此 API 相應功能的時機。讓我們從前往 BffSample\ClientApp\src\
資料夾,創建一個 components
資料夾,並新增一個 Bff.tsx
文件,並加入以下內容:
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;
}
};
// 當用戶需要身份驗證時,`login` 函數會重定向到登錄頁面
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 工作需要用的函式物件:checkSession
、login
和logout
。它是用於功能性 React 组件的。 - 用于类式 React 组件的高阶组件(HOC)
withBff
。
接下來,創建一個 UserClaims
组件,該组件在成功认证後顯示當前用戶的声明。在 BffSample\ClientApp\src\components
文件夾中創建 UserClaims.tsx
文件,並倒入以下內容:
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
,並插入主要组件代碼:
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
端點,該端點會終止伺服器端的使用者會話。
在這個階段,你現在可以一起運行 BffSample
應用程式和 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,你將在瀏覽器中看到已認證的用戶聲明:
sub: 1234567890
sid: V14fb1VQbAFG6JXTYQp3D3Vpa8klMLcK34RpfOvRyxQ
auth_time: 1717852776
name: John Doe
email: [email protected]
此外,點擊 Logout
按鈕將把瀏覽器重定向到 /bff/logout
,這將使用戶登出,你將再次看到登錄頁面,提示你輸入用戶名和密碼。
如果您遇到任何錯誤,您可以將您的代碼與我們的GitHub倉庫 Abblix/Oidc.Server.GettingStarted 進行比較,該倉庫包含了此例及其他準備好運行的示例。
解決HTTPS憑證信任問題
當您在本地測試 configured to run over HTTPS 的web應用程序時,您可能會遇到瀏覽器警告 SSL憑證不受信任。這個問題出現在因為ASP.NET Core使用的開發憑證不是由认可的 Certification Authority (CA) 發行的,而是自簽名或根本不在系統中。這些警告可以通过執行以下命令一次消除:
dotnet dev-certs https --trust
此命令為 localhost
生成的自簽名憑證並安裝在您的系統中,使其信任此憑證。憑證將由ASP.NET Core用於本地運行web應用程序。運行此命令後,請重新啟動您的瀏覽器以使更改生效。
Chrome用戶的特殊注意:即使作為可信憑證安裝了開發憑證,某些Chrome版本仍可能出于安全原因限制對 localhost
網站的訪問。如果您遇到指示您的連接不安全且Chrome阻止對 localhost
的訪問的錯誤,您可以按照以下方式 bypass 此問題:
- 在錯誤頁面上click 任何地方並輸入
thisisunsafe
或badidea
,取決於您的Chrome版本。這些keystroke sequence 在Chrome中作為bypass command 使用,允許您繼續到localhost
網站。
在開發環境中使用這些旁路方法是重要的,因為它們可能會帶來實際的安全風險。
通過BFF調用第三方API
我們已在BffSample
應用程序中成功实施了認證。現在,讓我們前進到調用需要訪問令牌的第三方API。
假設我們有一個分離的服務,該服務提供必要的數據,如天氣預報,並且只有訪問令牌才能访问。BffSample
應用程序的服务器部分將扮演反向代理的角色,即接受並認證SPA的數據請求,添加訪問令牌,將此請求转发給天氣服務,然後將服務的响应返回給SPA。
創建ApiSample服務
在示範通過BFF調用遠程API之前,我們需要創建一個應用程序,該應用程序將作為我們示例中的API。
創建應用程序時,我們將使用.NET提供的模板。進入包含OpenIDProviderApp
和BffSample
項目的文件夾,並運行以下命令以創建ApiSample
應用程序:
dotnet new webapi -n ApiSample
這個ASP.NET Core 最小API應用程序提供一個端點,端點路徑為/weatherforecast
,該端點以JSON格式提供天氣數據。
首先,將ApiSample
應用程序在本地使用的隨機分配端口號更改為固定的端口5004。如前所述,此步驟非必要,但它簡化了我們的設定。要做到这一点,請打開ApiSample\Properties\launchSettings.json
文件,尋找名為https
的配置文件,並將applicationUrl
屬性的值更改為https://localhost:5004
。
現在,讓我們使天氣API只能通過訪問令牌访问。前往ApiSample
項目文件夾並添加NuGet包以進行JWT令牌身份驗證:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
在ApiSample\Program.cs
文件中配置名為WeatherApi
的身份驗證方案和授權策略:
// ******************* 開始 *******************
using System.Security.Claims;
// ******************** 結束 ********************
var builder = WebApplication.CreateBuilder(args);
// 向容器添加服務。
// 了解有關配置Swagger/OpenAPI的更多信息,請訪問 https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ******************* 開始 *******************
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);
});
}));
// ******************** 結束 ********************
var app = builder.Build();
此代碼區塊通過從應用程式設定中讀取配置來設定身份驗證,並使用JWT(JSON Web Tokens)進行授權,並配置了一個名為WeatherApi
的授權策略。WeatherApi
授權策略設定以下要求:
policy.RequireAuthenticatedUser()
:確保只有通過身份驗證的使用者才能訪問受保護的資源。政策.RequireAssertion(context => ...)
: 用戶必須具有scope
声明,其值包括weather
。由于scope
声明可以根据 RFC 8693 包含多个由空格分隔的值,实际的scope
值会被分割成单独的部分,并且检查结果数组是否包含所需的weather
值。
这些条件一起确保只有具有经授权用于 weather
范围访问令牌的已认证用户可以调用此策略保护的端点。
我们需要将此策略应用于 /weatherforecast
端点。添加如下所示的 RequireAuthorization()
调用:
app.MapGet("/weatherforecast", () =>
{
// ...
})
.WithName("GetWeatherForecast")
// ******************* 开始 *******************
.WithOpenApi()
.RequireAuthorization(policyName);
// ******************** 结束 ********************
在 ApiSample
应用程序的 appsettings.Development.json
文件中为认证方案添加必要的配置设置:
{
// ******************* 开始 *******************
"JwtBearerAuthentication": {
"Authority": "https://localhost:5001",
"MapInboundClaims": false,
"TokenValidationParameters": {
"ValidTypes": [ "at+jwt" ],
"ValidAudience": "https://localhost:5004",
"ValidIssuer": "https://localhost:5001"
}
},
// ******************** 结束 ********************
"Logging": {
"LogLevel": {
"Default": "Information",
让我们详细检查每个设置:
Authority
: 这是指向发行 JWT 令牌的 OpenID Connect 授权服务器的 URL。ApiSample
应用程序中配置的认证提供程序将使用此 URL 来获取验证令牌所需的信息,例如签名密钥。MapInboundClaims
: 此設定控制從JWT憑證 incoming claims 如何映射到 ASP.NET Core 内部的 claims。它被設定為false
,意味著 claims 將使用從JWT來的原始名稱。TokenValidationParameters
:ValidTypes
: 設定為at+jwt
,根據 RFC 9068 2.1 這表示 JWT 格式的存取憑證。ValidAudience
: 指定應用程序將接受為客戶端https://localhost:5004
發出的憑證。ValidIssuer
: 指定應用程序將接受由服務器https://localhost:5001
發出的憑證。
OpenIDProviderApp 的其他設定
认证服務 OpenIDProviderApp
和客戶端應用程式 BffSample
的組合對於提供用戶認證工作效率良好。然而,為了使遠程API調用成為可能,我們需要將 ApiSample
應用程序作為資源在 OpenIDProviderApp
中註冊。在我們的示例中,我們使用支持 RFC 8707: OAuth 2.0 的資源標記 的 Abblix OIDC 服務器
。因此,我們將 ApiSample
應用程序作為具有 weather
範圍的資源進行註冊。如果您使用的是不支持資源標記的另一個 OpenID Connect 服務器,仍然建議為此遠程API註冊唯一的範圍(例如在我們的示例中為 weather
)。
將以下代碼添加到文件 OpenIDProviderApp\Program.cs
中:
// 註冊並配置 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
需要作為一個反向代理服務器來運作。
Instead of manually implementing request proxying in our client application, we will use YARP (Yet Another Reverse Proxy), a ready-made product developed by Microsoft. YARP is a reverse proxy server written in .NET and available as a NuGet package.
要在BffSample
應用程序中使用YARP,首先添加NuGet包:
dotnet add package Yarp.ReverseProxy
然後在文件BffSample\Program.cs
的開頭添加以下命名空間:
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Net.Http.Headers;
using Yarp.ReverseProxy.Transforms;
在呼叫 `var app = builder.Build();` 之前,加入以下代碼:
builder.Services.AddHttpForwarder();
在呼叫 `app.MapControllerRoute()` 和 `app.MapFallbackToFile()` 之間:
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}
用來捕捉在/bff/
之後 URL 的所有剩余部分。configuration.GetValue<string>("OpenIdConnect:Resource")
使用應用程式的配置來取得OpenIdConnect:Resource
節段的值。此值指定了請求將被轉發到的資源地址。在我們的示例中,這個值將是https://localhost:5004
–ApiSample
應用程序運作的基礎 URL。建造器上下文 => ...
添加了 YARP 将对 SPA 发来的每个请求执行的必要转换。在我们的案例中,将会有两个这样的转换:builderContext.AddPathRemovePrefix("/bff")
移除原始请求路径中的/bff
前缀。builderContext.AddRequestTransform(async transformContext => ...)
向请求中添加一个Authorization
HTTP 头,其中包含在认证过程中先前获取的访问令牌。因此,即使 SPA 本身无法访问此令牌,SPA 发往远程 API 的请求也将使用该访问令牌进行认证。
.RequireAuthorization()
指定所有转发请求都需要认证。只有经过认证的用户才能访问路由/bff/{**catch-all}
。
要在驗證過程中為資源 https://localhost:5004
請求存取令牌,請在 BffSample/appsettings.Development.json
文件中的 OpenIdConnect
配置中添加 Resource
參數,值為 https://localhost:5004
:
"OpenIdConnect": {
// ******************* 開始 *******************
"Resource": "https://localhost:5004",
// ******************** 結束 ********************
"Authority": "https://localhost:5001",
"ClientId": "bff_sample",
此外,還要在 BffSample/appsettings.json
文件中的 scope
數組中添加另一個值 weather
:
{
"OpenIdConnect": {
// ...
// ******************* 開始 *******************
"Scope": ["openid", "profile", "email", "weather"],
// ******************** 結束 ********************
// ...
}
}
注意:在實際项目中,有必要监视存取令牌的过期。當令牌快要過期時,您應該使用來自驗證服務的刷新令牌提前請求新的令牌,或者通過獲取新令牌並重試原始請求來處理遠程API发的存取拒絶錯誤。為了簡潔,我們故意在本文中省略了這一部分。
通過BFF在SPA應用程序中请求天气API
現在后端已經準備好。我們有 ApiSample
應用程序,它實現了一個帶有令牌基礎驗證的API,以及 BffSample
應用程序,它包括一個內置的反向代理服務器以提供對這個API的安全訪問。最後一步是添加功能以請求這個API并在React SPA中顯示獲取的数据。
將檔案 WeatherForecast.tsx
新增到 BffSample\ClientApp\src\components
,內容如下:
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
。- 根據加載標誌的值,元件將顯示 “Loading…” 訊息或渲染包含天氣預報數據的表格。表格包括日期、攝氏和華氏溫度,以及每個預報的天氣概述列。
現在,將 WeatherForecast
元件新增到 BffSample\ClientApp\src\App.tsx
:
// ******************* 開始 *******************
import { WeatherForecast } from "./components/WeatherForecast";
// ******************** 結束 ********************
// ...
// ******************* 開始 *******************
// ******************** 結束 ********************
執行與測試
如果一切都没問題,現在您可以開始我們三個項目中的所有應用。為每個應用使用控制台命令 dotnet run -lp https
以通過 HTTPS 運行它們。
在啟動所有三個應用後,在瀏覽器中打开 BffSample
應用(https://localhost:5003),並使用凭证 [email protected]
和 Jd!2024$3cur3
進行驗證。在成功驗證後,您應該會看到從驗證服務器接收到的声明列表,與之前看到的一樣。在這個下面,您還會看到天氣預報。
天氣預報是由分離的应用 ApiSample
提供的,該應用使用由驗證服務 OpenIDProviderApp
發出的存取令牌。在 BffSample
應用窗口中看到天氣預報意味著我們的 SPA 成功呼叫了 BffSample
的後端,後端然後通過添加存取令牌將呼叫轉發給 ApiSample
。ApiSample
驗證了呼叫並以 JSON 格式返回了天氣預報。
完整的解決方案可在 GitHub 上找到。
如果您在實施測試項目時遇到任何問題或錯誤,您可以參考在GitHub存儲庫中提供的完整解決方案。只需克隆存儲庫 Abblix/Oidc.Server.GettingStarted 以存取本文章描述的完全实施的項目。此資源既作為解決問題的工具,也作為創建您自己的項目的堅實起點。
結論
認證協議如OAuth 2.0和OpenID Connect的演變反映了網絡安全和瀏覽器功能等方面的更廣泛趨勢。從過時的方法如隐式流轉變為更安全的做法,如带PKCE的授权码流,已顯著提高安全性。然而,在不受控制的環境中操作的固有風險使得保護現代SPA變得具有挑戰性。將令牌 exclusive 存儲在後端並採用後端為前端 (BFF) 模式是一種有效的策略,用於減輕風險並確保堅實的用戶數據保護。
開發人员必須保持警覺,通過实施新的認證方法和新颖的架構方法來應對不斷變化的威脅環境。這種積極的方法對建立安全和可靠的網絡應用至關重要。在本文中,我們探索並实施了使用流行的.NET和React技術堆栈的現代方法,以整合OpenID Connect、BFF和SPA。這種方法可以作為您未來項目的堅實基礎。
未來,網絡安全的持續發展將需要更多創新在認證和架構模式上。我們鼓勵您 explore 我們的 GitHub 倉庫,貢獻於現代認證解決方案的開發,並继续保持對不斷進程的關注。感謝您的關注!
Source:
https://dzone.com/articles/modern-authentication-on-dotnet