Asp-Net-Core開發筆記:使用ActionFilterAttribute實現非侵入式的引數校驗

程序设计实验室發表於2024-05-17

前言

在現代應用開發中,確保API的安全性和可靠性至關重要。

面向切面程式設計(AOP)透過將橫切關注點(如驗證、日誌記錄、異常處理)與核心業務邏輯分離,極大地提升了程式碼的模組化和可維護性。

在ASP.NET Core中,利用ActionFilterAttribute可以方便地實現AOP的理念,能夠以簡潔、高效的方式進行自定義驗證。

本文將分享如何透過建立ValidateClientAttribute來驗證客戶端ID,並探討這種方法如何體現AOP的諸多優勢。

使用場景

本文使用場景是在我之前開發的單點認證專案中,當時的專案名稱是 IdentityServerLite ,作為參考 IdentityServer4 設計的一個輕量級單點認證解決方案,不過我做得還不是很完善,而且屬於是邊學習 OAuth2.0 和 OpenID Connect 邊做的,程式碼比較亂,關於這個單點認證專案,我後續可能會寫一篇文章單獨介紹,並且目前有一個重構後開源的計劃。

在單點認證專案中,像登入、獲取 AccessToken 、請求 Token 等操作都需要驗證使用者傳入的 Client ID 引數是否有效,這部分邏輯是有些重複的,於是我就像使用一種更高效的方式來實現這個功能。

正好上次使用 AOP 的思想來實現非侵入性的審計日誌功能,這次同樣利用這種思想來實現這個校驗功能。

ActionFilterAttribute

我發現之前那倆篇關於審計日誌的實現文章沒有怎麼介紹這個東西

回顧一下:

  • Asp-Net-Core開發筆記:實現動態審計日誌功能
  • Asp-Net-Core開發筆記:進一步實現非侵入性審計日誌功能

現在再贅述一下~

ActionFilterAttribute 是 ASP.NET Core 提供的一個方便工具,用於在控制器的操作方法執行之前或之後新增自定義邏輯。這種機制使得我們可以在不改變操作方法本身的情況下,插入額外的處理邏輯,如驗證、日誌記錄、異常處理等。這種特性體現了面向切面程式設計(AOP)的理念,能夠有效地分離關注點,提高程式碼的模組化和可維護性。

透過繼承 ActionFilterAttribute,可以重寫 OnActionExecutingOnActionExecuted 方法,分別在操作方法執行前後執行自定義邏輯。例如,驗證輸入引數的有效性、記錄請求的執行時間、處理異常等。

理清思路

要實現的功能

  • 根據配置,校驗傳入的 Client ID 引數是否有效(引數名和引數所在位置都不確定,需要配置)
  • 校驗不透過的話返回錯誤資訊
  • 校驗透過的話,介面裡需要能訪問到對應的 Client 物件

如何實現?

首先是確定了這個功能是使用 Attribute 的形式來新增到介面的外邊,然後覆蓋 ActionFilterAttributeOnActionExecutionAsync方法來實現具體的校驗邏輯。

之後還需要把從資料庫裡查詢到的 Client 物件儲存到 HttpContext 裡,方便介面中使用這個物件。

HttpContext 是 ASP.NET Core 中用於封裝 HTTP 請求和響應的物件。它提供了一種訪問 HTTP 特定資訊的統一方式,包括請求的詳細資訊、響應的內容、使用者資訊、會話資料、請求頭和響應頭等。每次 HTTP 請求對應一個 HttpContext 例項,該例項貫穿請求處理的整個生命週期。

這裡我們利用 HttpContext 提供的 Items 這個鍵值對集合(用於在請求的不同中介軟體和元件之間共享資料)來共享 Client 物件。

var client = HttpContext.Items["client"] as Client;

開始寫程式碼

ClientIdSource Enum

Client ID 所在的位置不確定,需要在使用的時候配置

定義一個列舉

public enum ClientIdSource {
  Query,
  Body,
  Route,
  Header
}

ValidateClientAttribute 實現

Filters 目錄中建立 ValidateClientAttribute.cs 檔案

根據配置,從指定的位置根據指定的引數名稱讀取 Client ID ,然後在資料庫中查詢。

public class ValidateClientAttribute(
  ClientIdSource source = ClientIdSource.Query
) : ActionFilterAttribute {
  /// <summary>
  /// 客戶端ID的引數名稱,注意是 DTO 裡的屬性名稱,不是請求體JSON的欄位名
  /// </summary>
  public string ParameterName { get; set; } = "client_id";

  /// <summary>
  /// 設定驗證成功之後,儲存在 `HttpContext.Items` 物件中的 `Client` 物件的 key
  /// </summary>
  public string ClientItemKey { get; set; } = "client";

  public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) {
    var clientId = "";

    switch (source) {
      case ClientIdSource.Query:
        clientId = context.HttpContext.Request.Query[ParameterName];
        break;
      case ClientIdSource.Body:
        // 使用反射從請求體中讀取 client_id
        // 這裡讀取到的 body 是 Controller 下 Action 方法的第一個引數,通常是請求體中的 JSON 資料模型繫結轉換為對應 DTO 例項
        var body = context.ActionArguments.Values.FirstOrDefault();
        if (body != null) {
          var clientProp = body.GetType().GetProperty(ParameterName);
          if (clientProp != null) {
            clientId = clientProp.GetValue(body) as string;
          }
        }
        break;
      case ClientIdSource.Route:
        clientId = context.RouteData.Values[ParameterName] as string;
        break;
      case ClientIdSource.Header:
        clientId = context.HttpContext.Request.Headers[ParameterName];
        break;
    }

    if (string.IsNullOrWhiteSpace(clientId)) {
      throw new ArgumentNullException(ParameterName);
    }

    var clientRepo = context.HttpContext.RequestServices.GetRequiredService<IBaseRepository<Client>>();
    var client = await clientRepo.Select.Where(a => a.ClientId == clientId).FirstAsync();

    if (client != null) {
      context.HttpContext.Items["client"] = client;
      await next();
    }
    else {
      context.Result = new NotFoundObjectResult(
        new ApiResponse { Message = $"client with id {clientId} not found" });
    }
  }
}

有幾點需要注意的,下面介紹一下

透過反射獲取 request body 的引數

其他幾個引數位置還好,獲取都比較容易

如果是 POST 或者 PUT 方法,一般都是把資料以 JSON 的形式放在 Request Body 裡

這個時候,我們可以去讀取這個 Body 的值,但讀取完之後得自己解析 JSON,還得把 Stream 寫回去,有點麻煩。而且如果 Body 是 XML 形式,還要用其他的解析方式。

這裡我使用了反射的方式,讓 AspNetCore 框架去處理這個 Request Body ,然後我直接用反射,根據引數名去讀取 Client ID

使用

這是幾個使用例子

引數在 Body 裡

然後 DTO 裡的引數名是 ClientId

public class PwdLoginDto : LoginDto {
  [Required]
  [JsonPropertyName("username")]
  public string Username { get; set; }

  [Required]
  [JsonPropertyName("password")]
  public string Password { get; set; }
}

在介面中使用

[HttpPost("login/password")]
[ValidateClient(ClientIdSource.Body, ParameterName = "ClientId")]
public async Task<IActionResult> LoginByPassword(PwdLoginDto dto) {

}

引數在 Query Params 裡

引數名稱是 client_id

[HttpGet("authorize/url")]
[ValidateClient(ClientIdSource.Query, ParameterName = "client_id")]
public ApiResponse<string> GetAuthorizeUrl([FromQuery] AuthorizeInput input) {
  return new ApiResponse<string>(GenerateAuthorizeUrl(input));
}

參考資料

  • https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/filters
  • https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-context

相關文章