Asp-Net-Core开发笔记:进一步实现非侵入性审计日志功能

asp,net,core · 浏览次数 : 0

小编点评

The provided code demonstrates the implementation of an AuditLog middleware for a .NET Core application using FreeSql. Here's a summary of the key points: **Problem:** - Existing code needed to handle entity changes in a FreeSql database. **Solution:** - Create an `AuditLogMiddleware` class. - Override the `Invoke` method to: - Generate a unique EventId and store it in the HttpContext.Items dictionary. - Delegate the request execution to the next middleware or the application itself. **Key Features:** - The middleware intercepts requests and modifies the HttpContext.Items dictionary with the EventId. - It also sets the `_next` delegate to ensure the request is processed by the next middleware or the application. - The middleware uses a `AuditConstant.EventId` key to store and retrieve the EventId from the context. **Additional Notes:** - The `AuditLogMiddleware` uses a simple approach to logging entity changes by storing them in the `HttpContext.Items` dictionary. - The code could be extended to utilize the EventId for more complex logging and filtering purposes. - The purpose of using an EventId is not explicitly shown in the code, but it may be used for correlation with entity changes or other audit systems.

正文

前言

上次说了利用 AOP 思想实现了审计日志功能,不过有同学反馈还是无法实现完全无侵入,于是我又重构了一版新的。

回顾一下:Asp-Net-Core开发笔记:实现动态审计日志功能

现在已经可以实现对业务代码完全无侵入的审计日志了,在需要审计的接口上加上 [AuditLog] 特性,就可以记录这个接口的操作日志,还有相关的实体变化记录,还算是方便。

PS:后面我发现 ABP 里自带审计功能,突然感觉有点🤡了

重构

先对之前的代码进行重构,之前把跟审计有关的代码分散到各个目录中,这个功能其实是个整体,应该把代码归集到一起比较好。

创建 src/Acme.Demo/Contrib/Audit 目录 (注:Acme.Demo 是项目名称,随便起的)

目录结构

目录结构如下

 Audit
 ├─ Services
 │  ├─ IAuditLogService.cs
 │  ├─ AuditLogService.cs
 │  └─ AuditLogMongoService.cs
 ├─ Middlewares
 │  └─ AuditLogMiddleware.cs
 ├─ Filters
 │  └─ AuditLogAttribute.cs
 ├─ Extensions
 │  └─ CfgAudit.cs
 ├─ EventHandlers
 │  └─ FreeSqlAuditEventHandler.cs
 ├─ Entities
 │  ├─ EntityChangeInfo.cs
 │  └─ AuditLog.cs
 └─ AuditConstant.cs

6 directories, 10 files

创建 EntityChangeInfo 实体

用来保存实体变化

public class EntityChangeInfo {
  public string Entity { get; set; }
  public string Action { get; set; }
  public string Sql { get; set; }
  public Dictionary<string, object?> Parameters { get; set; }
}

AuditLog重构

之前我们是把实体变化内容直接保存在 AuditLog

现在要分离开,使用 List<EntityChangeInfo> 类型的 EntityChanges 属性来存放实体变化

public class AuditLog {
  /// <summary>
  /// 事件唯一标识
  /// </summary>
  public string EventId { get; set; }

  /// <summary>
  /// 事件类型(例如:登录、登出、数据修改等)
  /// </summary>
  public string EventType { get; set; }

  /// <summary>
  /// 执行操作的用户标识
  /// </summary>
  public string UserId { get; set; }

  /// <summary>
  /// 执行操作的用户名
  /// </summary>
  public string Username { get; set; }

  /// <summary>
  /// 事件发生的时间戳
  /// </summary>
  public DateTime Timestamp { get; set; }

  /// <summary>
  /// 用户的IP地址
  /// </summary>
  public string? IPAddress { get; set; }

  /// <summary>
  /// 实体更改内容,可根据实际情况以JSON格式存储
  /// </summary>
  public List<EntityChangeInfo>? EntityChanges { get; set; } = new();

  /// <summary>
  /// 路由信息
  /// </summary>
  public Dictionary<string, object?> RouteData { get; set; }

  /// <summary>
  /// 事件描述
  /// </summary>
  public string? Description { get; set; }

  /// <summary>
  /// 额外信息 (考虑以 JSON 格式保存)
  /// </summary>
  public object? Extra { get; set; }

  /// <summary>
  /// 创建时间
  /// </summary>
  public DateTime CreatedTime { get; set; } = DateTime.UtcNow;

  /// <summary>
  /// 修改时间
  /// </summary>
  public DateTime ModifiedTime { get; set; } = DateTime.UtcNow;
}

过滤器重构

修改 AuditLogAttribute

涉及到的改动不多,就是简化了参数,只需要传入 EventType 就行

其他的都会自动获取

实体变化部分,需要使用到 ORM 的功能,接下来会介绍

public class AuditLogAttribute : ActionFilterAttribute {
  public string EventType { get; set; }

  public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) {
    var sp = context.HttpContext.RequestServices;
    var ctxItems = context.HttpContext.Items;

    try {
      var authService = sp.GetRequiredService<AuthService>();

      // 在操作执行前
      var executedContext = await next();

      // 在操作执行后

      // 获取当前用户的身份信息
      var user = await authService.GetUserFromJwt(executedContext.HttpContext.User);

      // 构造AuditLog对象
      var auditLog = new AuditLog {
        EventId = Guid.NewGuid().ToString(),
        EventType = this.EventType,
        UserId = user.UserId,
        Username = user.Username,
        Timestamp = DateTime.UtcNow,
        IPAddress = GetIpAddress(executedContext.HttpContext),
        Description = $"操作类型:{this.EventType}",
      };

      if (ctxItems.TryGetValue(AuditConstant.EntityChanges, out var item)) {
        auditLog.EntityChanges = item as List<EntityChangeInfo>;
      }

      var routeData = new Dictionary<string, object?>();
      foreach (var (key, value) in context.RouteData.Values) {
        routeData.Add(key, value);
      }

      auditLog.RouteData = routeData;

      var auditService = sp.GetRequiredService<IAuditLogService>();
      await auditService.LogAsync(auditLog);
    } catch (Exception ex) {
      var logger = sp.GetRequiredService<ILogger<AuditLogAttribute>>();
      logger.LogError(ex, "An error occurred while logging audit information.");
    }

    Console.WriteLine(
      "执行 AuditLogAttribute, " +
      $"EventId: {ctxItems["AuditLog_EventId"]}");
  }

  private string? GetIpAddress(HttpContext httpContext) {
    // 首先检查X-Forwarded-For头(当应用部署在代理后面时)
    var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
    if (!string.IsNullOrWhiteSpace(forwardedFor)) {
      return forwardedFor.Split(',').FirstOrDefault(); // 可能包含多个IP地址
    }

    // 如果没有X-Forwarded-For头,或者需要直接获取连接的远程IP地址
    return httpContext.Connection.RemoteIpAddress?.ToString();
  }
}

获取实体变化

实体变化部分,需要使用到 ORM 的功能,不同的 ORM 能实现的实体变化监控不太一样,需要每种 ORM 写一个

我目前只实现了 FreeSQL 的实体变化监控

代码在 FreeSqlAuditEventHandler

public class FreeSqlAuditEventHandler {
  private readonly ILogger<FreeSqlAuditEventHandler> _logger;
  private readonly IHttpContextAccessor _httpContextAccessor;
  private readonly IDictionary<object, object?> _ctxItems;

  public FreeSqlAuditEventHandler(IHttpContextAccessor httpContextAccessor,
                                  ILogger<FreeSqlAuditEventHandler> logger) {
    _httpContextAccessor = httpContextAccessor;
    _logger = logger;
    _ctxItems = httpContextAccessor.HttpContext?.Items ?? new Dictionary<object, object?>();
  }

  public void HandleCurdBefore(object? sender, CurdBeforeEventArgs args) {
    // 捕获变更信息
    var changeInfo = new EntityChangeInfo {
      Entity = args.EntityType.Name,
      Action = Enum.GetName(typeof(CurdType), args.CurdType) ?? "unknown",
      Sql = args.Sql,
      Parameters = new Dictionary<string, object?>(
        args.DbParms.Select(p => new KeyValuePair<string, object?>(p.ParameterName, p.Value))
      )
    };

    // 处理CurdBefore事件,将实体变化信息保存到HttpContext.Items
    _logger.LogDebug(
      $"执行 FreeSql CurdBefore, " +
      $"EventId: {_httpContextAccessor.HttpContext?.Items["AuditLog_EventId"]}, " +
      $"entityType: {args.EntityType.Name}, " +
      $"crud: {Enum.GetName(typeof(CurdType), args.CurdType)}, ");

    List<EntityChangeInfo> changes = new();
    if (_ctxItems.TryGetValue(AuditConstant.EntityChanges, out var item)) {
      changes = item as List<EntityChangeInfo> ?? new List<EntityChangeInfo>();
    } else {
      _ctxItems[AuditConstant.EntityChanges] = changes;
    }

    changes.Add(changeInfo);
  }
}

这里很简单,利用 FreeSQL 的 Aop.CurdBefore 事件,把 HandleCurdBefore 绑定到事件上,就可以获取实体的变化了。

// 创建 IFreeSQL 实例
IFreeSql inst = ...;
// 实体 CRUD操作(create read update delete)事件
inst.Aop.CurdBefore += auditEventHandler.HandleCurdBefore;

这里吐槽一下 FreeSQL 的命名,一般都叫 crud ,你却搞特殊变成 curd ……

不过为了用国产数据库,只能凑合用咯~

扩展方法

为了使用方便

我把注册服务和中间件都放在扩展方法中,符合 AspNetCore 的开发习惯

public static class CfgAudit {
  public static IServiceCollection AddAudit(this IServiceCollection services, IConfiguration conf) {
    services.AddSingleton<IAuditLogService>(sp =>
                                            new AuditLogMongoService(conf.GetConnectionString("MongoDB"), "stu_data_hub"));
    services.AddSingleton<FreeSqlAuditEventHandler>();

    return services;
  }

  public static IApplicationBuilder UseAudit(this IApplicationBuilder app) {
    app.UseMiddleware<AuditLogMiddleware>();

    return app;
  }
}

Program.cs 里注册

// 注册服务
builder.Services.AddAudit(builder.Configuration);
// 添加中间件
app.UseAudit();

PS:这里把配置传进去有点蠢,其实我完全可以在 AddAudit 方法里通过依赖注入的方式来获取配置对象的,不过既然都这样写了,懒得改了。

使用效果

来看下使用效果

首先在需要审计的接口上加上 [AuditLog] 特性

/// <summary>
/// 设置反馈结果
/// </summary>
[AuditLog(EventType = "设置反馈结果")]
[HttpPost("{taskId}/sub-tasks/{subId}/set-feedback")]
public async Task<ApiResponse> SetSubTaskFeedback(string taskId, string subId, [FromBody] SubTaskFeedbackDto dto) {}

之后在 MongoDB 里可以看到审计日志(数据已脱敏)

{
  "_id": {
    "$oid": "65ff019f6de4b7290e1da9e9"
  },
  "EventId": "eb81f052-ce84-4923-bf9e-57582e464992",
  "EventType": "设置反馈结果",
  "UserId": "eb81f052",
  "Username": "用户名",
  "Timestamp": {
    "$date": "2024-03-23T16:21:49.697Z"
  },
  "IPAddress": "1.2.3.4",
  "EntityChanges": [
    {
      "Entity": "实体名称",
      "Action": "Select",
      "Sql": "Select 语句已脱敏",
      "Parameters": {}
    },
    {
      "Entity": "实体名称",
      "Action": "Update",
      "Sql": "UPDATE entity set some_col=:p_0",
      "Parameters": {
        ":p_0": 6
      }
    }
  ],
  "RouteData": {
    "area": "Market",
    "action": "SetSubTaskFeedback",
    "controller": "Task",
    "taskId": "eb81f052",
    "subId": "57582e464992"
  },
  "Description": "操作类型:设置反馈结果",
  "Extra": null,
  "CreatedTime": {
    "$date": "2024-03-23T16:21:49.697Z"
  },
  "ModifiedTime": {
    "$date": "2024-03-23T16:21:49.697Z"
  }
}

可以看到 EntityChanges 字段包含了这次事件中的实体操作,也就是对数据库的操作,共有两个,一个是 select 查询,另一个是 update 修改数据库。

AuditLog 中间件

最后说下这个 AuditLogMiddleware

代码很简单,就是在每个请求进来的时候,在 HttpContext.Items 里添加一个 AuditConstant.EventId

public class AuditLogMiddleware {
  private readonly RequestDelegate _next;

  public AuditLogMiddleware(RequestDelegate next) {
    _next = next;
  }

  public async Task Invoke(HttpContext context) {
    // 生成 EventId 并存储到 HttpContext.Items 中
    context.Items[AuditConstant.EventId] = Guid.NewGuid().ToString();

    await _next(context);
  }
}

虽然写了这个中间件,不过后面并没有用上这个 EventId

这个本来是用来把实体更新和 Filter 关系起来的,不过后面发现用不上。

先留着吧,万一后面有用呢?

与Asp-Net-Core开发笔记:进一步实现非侵入性审计日志功能相似的内容:

Asp-Net-Core开发笔记:进一步实现非侵入性审计日志功能

前言 上次说了利用 AOP 思想实现了审计日志功能,不过有同学反馈还是无法实现完全无侵入,于是我又重构了一版新的。 回顾一下:Asp-Net-Core开发笔记:实现动态审计日志功能 现在已经可以实现对业务代码完全无侵入的审计日志了,在需要审计的接口上加上 [AuditLog] 特性,就可以记录这个接

Asp-Net-Core开发笔记:使用原生的接口限流功能

前言 之前介绍过使用 AspNetCoreRateLimit 组件来实现接口限流 从 .Net7 开始,AspNetCore 开始内置限流组件,当时我们的项目还在 .Net6 所以只能用第三方的 现在都升级到 .Net8 了,当然是得来试试这个原生组件 体验后:配置使用都比较简单,不过功能也没有 A

Asp-Net-Core开发笔记:给SwaggerUI加上登录保护功能

前言 在 SwaggerUI 中加入登录验证,是我很早前就做过的,不过之前的做法总感觉有点硬编码,最近 .Net8 增加了一个新特性:调用 MapSwagger().RequireAuthorization 来保护 Swagger UI ,但官方的这个功能又像半成品一样,只能使用 postman c

Asp-Net-Core开发笔记:使用ActionFilterAttribute实现非侵入式的参数校验

前言 在现代应用开发中,确保API的安全性和可靠性至关重要。 面向切面编程(AOP)通过将横切关注点(如验证、日志记录、异常处理)与核心业务逻辑分离,极大地提升了代码的模块化和可维护性。 在ASP.NET Core中,利用ActionFilterAttribute可以方便地实现AOP的理念,能够以简

Asp-Net-Core开发笔记:EFCore统一实体和属性命名风格

前言 C# 编码规范中,类和属性都是大写驼峰命名风格(PascalCase / UpperCamelCase),而在数据库中我们往往使用小写蛇形命名(snake_case),在默认情况下,EFCore会把原始的类名和属性名直接映射到数据库,这不符合数据库的命名规范。 为了符合命名规范,而且也为了看起

Asp-Net-Core开发笔记:快速在已有项目中引入EFCore

前言 很多项目一开始选型的时候没有选择EFCore,不过EFCore确实好用,也许由于种种原因后面还是需要用到,这时候引入EFCore也很方便。 本文以 StarBlog 为例,StarBlog 目前使用的 ORM 是 FreeSQL ,引入 EFCore 对我来说最大的好处是支持多个数据库,如果是

Asp-Net-Core开发笔记:使用RateLimit中间件实现接口限流

前言 最近一直在忙(2月份沉迷steam,3月开始工作各种忙),好久没更新博客了,不过也积累了一些,忙里偷闲记录一下。 这个需求是这样的,我之前做了个工单系统,现在要对登录、注册、发起工单这些功能做限流,不能让用户请求太频繁。 从 .Net7 开始,已经有内置的限流功能了,但目前我们的项目还在使用

Asp-Net-Core开发笔记:API版本管理

## 前言 对于Web API应用程序而言,随着时间的推移以及需求的增加或改变,API必然会遇到升级的需求。事实上,Web API应用程序应该从创建时就考虑到API版本的问题。业务的调整、功能的增加、接口的移除与改名、接口参数变动、实体属性的添加、删除和更改等都会改变API的功能,从而带来版本的变更

Asp-Net-Core开发笔记:FrameworkDependent搭配docker部署

## 前言 之前我写过一篇使用 docker 部署 AspNetCore 应用的文章,这种方式搭配 CICD 非常方便, build 之后 push 到私有的 dockerhub ,在生产服务器上 pull 下来镜像就可以直接运行了。 然而,有时需要一种更传统的部署方式,比如在本地打包可执行文件之后

Asp-Net-Core学习笔记:gRPC快速入门

## 前言 此前,我在做跨语言调用时,用的是 Facebook 的 Thrift,挺轻量的,还不错。 >Thrift是一种接口描述语言和二进制通讯协议,它被用来定义和创建跨语言的服务。它被当作一个远程过程调用(RPC)框架来使用,是由Facebook为“大规模跨语言服务开发”而开发的。它通过一个代码