SPA + ASP.NET Core Minimal API 練習 - 實作 Windows 登入與 CSRF 防護
9 |
ASP.NET Core Minimal API 在我的日常應用愈來愈廣泛,最近有個構想是前端寫成純 HTML (類 SPA 概念,但拆分多個 HTML 以簡化複雜度) 呼叫 Minimal API 完成作業,因為是內部系統,身分證驗部分就走 Windows 驗證用 AD 帳號登入。另外,雖然被攻擊風險不如對外網站高,我還是決定加上 CSRF 防護,反正學會技巧,遲早有用到的一天。
先用 dotnet new web -o spa-minapi-anti-csrf
建立範例專案,要使用 Windows 驗證需 dotnet add package Microsoft.AspNetCore.Authentication.Negotiate
參照 Windows 驗證程式庫。在 Program.cs 用 UseStaticFiles() 加入 wwwroot 靜態目錄(裡面會放一個 index.html 當主要介面)、設定登入及授權(參考),最後用 MapGet('/ajax') 提供一個傳回登入身分及隨機產生 GUID 的簡單 WebAPI:
using Microsoft.AspNetCore.Authentication.Negotiate;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapPost("/ajax", (HttpContext ctx) =>
(ctx.User.Identity?.IsAuthenticated == true?
ctx.User.Identity.Name : "Anonymous") + ":" +
Guid.NewGuid().ToString())
.RequireAuthorization();
app.Run();
wwwroot\index.html 內容如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Home</title>
</head>
<body>
<button onclick="callAjax()">Get GUID by AJAX</button>
<script>
function callAjax() {
fetch('/ajax', { method: 'POST' })
.then(response => response.ok ? response.text() : response.statusText)
.then(data => alert(data));
}
</script>
</body>
</html>
若一切順利,按鈕後會看到登入身分及 GUID。
如同之前的ASP.NET MVC 展示,這種寫法有個資安漏洞,有心人士可以在第三方網頁部署陷阱,誘騙使用者觸發這個 AJAX 呼叫:
<!DOCTYPE html>
<html>
<body>
<form action="http://localhost:5177/ajax" target="result" method="post">
<button>點我拿好康</button>
</form>
<iframe name="result" style="width: 250px; height: 60px"></iframe>
</body>
</html>
接下來示範如何為這種 SPA 架構加上 CSRF 防護。
針對 CSRF 攻擊,ASP.NET Core 已有相關內建機制,MS Learn 的這篇防止 ASP.NET Core中的跨網站偽造要求 (XSRF/CSRF) 攻擊包含了原理到做法的說明,是不錯的入門。
若你使用的是 MVC、RazorPage,ASP.NET Core 有現成的 Html.AntiForgeryToken() Helper 方法、[ValidateAntiForgeryToken] Attribute 可用,做法相對簡單。
這次的做法屬於 SPA 形式式,則需使用 IAntiforgery.GetAndStoreTokens(context) 產生成對 Token,其中一個為 CookieToken (名為 .AspNetCore.Antiforgery.XXXX) 會自動存成 HttpOnly Cookie,瀏覽器將自動保存並附在後續的每次請求中;另一個 RequetToken,則可設定非 HttpOnly Cookie 傳回,由 JavaScript 讀取保存,之後發 AJAX 請求時以 Request Header 方式附上。伺服器端將同時檢查 CookieToken (來自 Cookie) 及 RequestToken (來自 Request Header),二者必須相符,且 Token 中記載的 Claim 需與當時登入身分 (User.Identity) 一致才算有效。參考
由於 Minimal API 無法用 ValidateAntiForgeryToken, AutoValidateAntiforgeryToken, IgnoreAntiforgeryToken 等 Filter,官方文件有示範實作 MapPost().ValidateAntiforgery() 擴充方法加上檢查,但它依賴 ASP.NET Core 7.0+ 新加入 API。在 .NET 6 我想到的解法是比照之前檢查 API Key 的做法,寫一段 Middleware 邏輯加入自訂檢查。
Program.cs 改寫如下:
using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
// 註冊 IAntiforgery 服務
builder.Services.AddAntiforgery(o => o.HeaderName = "X-XSRF-TOKEN");
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseDefaultFiles();
app.UseStaticFiles();
app.Use(async (context, next) =>
{
// TODO: 此處用 /api 字首隨意示範,實務上應加入自訂邏輯,針對需要防止 CSRF 的動作進行檢查
if (context.Request.Path.StartsWithSegments("/api"))
{
try
{
var antiForgeryService = context.RequestServices.GetRequiredService<IAntiforgery>();
await antiForgeryService.ValidateRequestAsync(context.Request.HttpContext);
await next.Invoke();
}
catch (AntiforgeryValidationException)
{
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Antiforgery token validation failed.");
}
}
else await next.Invoke();
});
// 註冊 /antiforgery/token 方法寫入 X-XSRF-TOKEN Cookie
app.MapPost("/antiforgery/token", ([FromServices] IAntiforgery forgeryService, HttpContext context) =>
{
// 為這個 Request 產生 AntiforgeryTokenSet,在 Response 加入 CookieToken,
// 並將 Response 的 Cache-control/Pragma Header 設成 "no-cache"
// 另外也會將 X-Frame-Options Header 設成 SAMEORIGIN 以防止被外部網站內嵌
var tokens = forgeryService.GetAndStoreTokens(context);
// 將 AntiforgeryTokenSet 中的 RequestToken 設成 Cookie,並指定 HttpOnly 為 false,允許 JavaScript 取出作為 Request Header
// 註:瀏覽器會自動傳送 Cookie,使用 Header 傳送安全性較高
context.Response.Cookies.Append(tokens.HeaderName!, tokens.RequestToken!, new CookieOptions { HttpOnly = false });
return Results.Ok();
})
// RequestToken 會包含登入身分 Claim,必須符合驗證時的登入身分
// 若 API 需登入使用,則產生 Token 時也必須要求登入
.RequireAuthorization();
app.MapPost("/api/ajax", (HttpContext ctx) =>
(ctx.User.Identity?.IsAuthenticated == true?
ctx.User.Identity.Name : "Anonymous") + ":" +
Guid.NewGuid().ToString())
.RequireAuthorization();
app.Run();
// 官方文件提供的 ValidateAntiforgery() 擴充方法,ASP.NET Core 7.0+ 支援 AddEndpointFilter
/*
internal static class AntiForgeryExtensions
{
public static TBuilder ValidateAntiforgery<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder
{
return builder.AddEndpointFilter(routeHandlerFilter: async (context, next) =>
{
try
{
var antiForgeryService = context.HttpContext.RequestServices.GetRequiredService<IAntiforgery>();
await antiForgeryService.ValidateRequestAsync(context.HttpContext);
}
catch (AntiforgeryValidationException)
{
return Results.BadRequest("Antiforgery token validation failed.");
}
return await next(context);
});
}
}
*/
index.html 小做修改,一開始先呼叫 /antiforgery/token,取出 RequestToken,之後發送 AJAX 請求時設成 Request Header X-XSRF-TOKEN:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Home</title>
</head>
<body>
<button onclick="callAjax()">Get GUID by AJAX</button>
<script>
// 取得 XSRF-TOKEN
let xsrfToken;
fetch("/antiforgery/token", { method: "POST" })
.then(res => {
xsrfToken = document.cookie
.split("; ")
.find(row => row.startsWith("X-XSRF-TOKEN="))
.split("=")[1];
});
function callAjax() {
fetch('/api/ajax', { method: 'POST', headers: { 'X-XSRF-TOKEN': xsrfToken } })
.then(response => response.ok ? response.text() : response.statusText)
.then(data => alert(data));
}
</script>
</body>
</html>
加上防護後,攻擊者就無法透過跨端台或本機 HTML 網站誘騙使用者進行呼叫。
最後,實地觀察 /antiforgery/token 的回傳結果證驗理論:
如上圖,共寫入兩個 Cookie,第一個名為 .AspNetCore.Antiforery.j66w7ZNqXeA [1] 為 CookieToken,設定 samesite=strict 及 httponly [2];第二個 X-XSRF-TOKEN [3] 是我們用 Cookies.Add 自己加的,不含 httponly 旗標,JavaScript 可讀取。另外,GetAndStoreTokens() 說明有提到會自動加上 X-Frame-Options SAMEORIGIN [4],也在觀察中得到印證。
【延伸閱讀】
- ASP.NET Core 極簡風 - Minimal API
- Authentication and authorization in minimal APIs
- ASP.NET Core 基礎 - 使用靜態檔案
- ASP.NET Core 練習 - 啟用 Windows 驗證
- ASP.NET Core 練習 - 用 Middleware 為 Minimal API 加上 API Key 檢查
- Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core
- ASP.NET Core CSRF defence with Antiforgery
- 淺談 ASP.NET Cookie 安全設定
Example of how to implement anti CSRF mechanism in SPA + ASP.NET Core Minimal API.
Comments
# by Ho.Chun
請問在 Web 中,說要”走 Windows 驗證”,是什麼意思 🤔 假設 UserA 登入 ComputerA,並打開瀏覽器 http://ComputerB/Site/index.html UserB 登入 ComputerB,建立ㄧ個 AppPool,並將識別設定為 UserC,這個 AppPool 有ㄧ個 Site,說要”走 Windows 驗證” 請問 此時的 Windows 驗證,會回傳誰登入 ?
# by Jeffrey
to Ho.Chun, 「網站走 Windows 驗證」算是我平日作業的口頭用語(黑話? :P),其正式術語為 [整合式 Windows 驗證]( https://learn.microsoft.com/zh-tw/aspnet/web-api/overview/security/integrated-windows-authentication )。 使用者連上使用 整合式 Windows 驗證 的網站,瀏覽器會跳出帳號密碼對話框(或使用登入 Windows 的帳號自動登入),通過身分驗證後,ASP.NET 程式從 User.Identity.Name 可讀取到登入的 AD 帳號或 Windows 本機帳號,以此判定使用者是誰,決定可以存取哪些資源。而 ASP.NET 程式讀取伺服器上檔案等資源用的則是 AppPool 的識別身分,IIS 7.5 + 建議用 IIS AppPool\AppPoolName 專屬虛擬帳號,權限很小,以降低網站不幸被入侵時的危害。 整合式 Windows 驗證又可分為 Kerberos 及 NTLM,補充幾篇相關研究: * [關於IIS整合式Windows驗證的冷知識]( https://blog.darkthread.net/blog/ntlm-and-kerberos-on-iis/ ) * [Windows驗證歷程觀察與Kerberos/NTLM判別]( https://blog.darkthread.net/blog/check-auth-method-of-browser/ ) * [設定 Chrome/Edge 自動登入 Windows 驗證網站]( https://blog.darkthread.net/blog/chrome-auto-login-win-auth-iis/ )
# by Cash
如果禁止前端不能讀 cookie 的話,那就只能由 api 返回 token 了 XD
# by Toolman
黑大好 想請問 api try catch 驗證 xsrf token 那段 用 if else 替代 try catch 會不會比較好呢? 根據我自己的經驗,流程控制用 try catch 的方式,滿容易拖垮系統效能的 尤其量大的時候,速度可能會差到指數倍
# by Ho.Chun
想確認一下 "瀏覽器會跳出帳號密碼對話框(或使用登入 Windows 的帳號自動登入)" 這邊的帳密,應該指的是伺服器電腦上的使用者帳密,對嗎 ? 與瀏覽器的電腦上的使用者帳密無關 ?
# by Jeffrey
to Toolman, Try Catch Block是否會影響效能? https://blog.darkthread.net/blog/try-catch-performance/ 只有出現 Exception 進入 catch 段才會有效能差異,而 ValidateRequestAsync 在驗證失敗時是以 throw 錯誤方式表現,要用這個 API 只能用 try catch 捕捉,不然就要另外自己寫檢核改傳 boolean 走 if 邏輯
# by Jeffrey
to Ho.Chun,帳號必須要被伺服器認可,可能是伺服器主機上開的帳號,也可能是AD網域帳號
# by Toolman
黑大好 感謝回復,這突然讓我想到,如果駭客想攻擊 web service 好像 ddos 隨便亂帶 csrf token 就能取得不錯的效果 (?
# by Jeffrey
to Toolman, DDoS 防護是另一個獨立議題,我個人覺得不用為了它改變做法。try catch 邏輯在 .NET 程式四處可見,只在這裡避開意義不大。例如:先傳送一個超大但有錯的 JSON Body,一方面先消耗資源解析,最後再出錯丟 Exception,攻擊效果應就勝過鎖定 CSRF Token 驗證的 Exception 了。