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


    前言#

    上次说了利用 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 类型的 EntityChanges 属性来存放实体变化

    public class AuditLog {
      /// 
      /// 事件唯一标识
      /// 
      public string EventId { get; set; }
    
      /// 
      /// 事件类型(例如:登录、登出、数据修改等)
      /// 
      public string EventType { get; set; }
    
      /// 
      /// 执行操作的用户标识
      /// 
      public string UserId { get; set; }
    
      /// 
      /// 执行操作的用户名
      /// 
      public string Username { get; set; }
    
      /// 
      /// 事件发生的时间戳
      /// 
      public DateTime Timestamp { get; set; }
    
      /// 
      /// 用户的IP地址
      /// 
      public string? IPAddress { get; set; }
    
      /// 
      /// 实体更改内容,可根据实际情况以JSON格式存储
      /// 
      public List? EntityChanges { get; set; } = new();
    
      /// 
      /// 路由信息
      /// 
      public Dictionary<string, object?> RouteData { get; set; }
    
      /// 
      /// 事件描述
      /// 
      public string? Description { get; set; }
    
      /// 
      /// 额外信息 (考虑以 JSON 格式保存)
      /// 
      public object? Extra { get; set; }
    
      /// 
      /// 创建时间
      /// 
      public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
    
      /// 
      /// 修改时间
      /// 
      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();
    
          // 在操作执行前
          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;
          }
    
          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();
          await auditService.LogAsync(auditLog);
        } catch (Exception ex) {
          var logger = sp.GetRequiredService>();
          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 _logger;
      private readonly IHttpContextAccessor _httpContextAccessor;
      private readonly IDictionary _ctxItems;
    
      public FreeSqlAuditEventHandler(IHttpContextAccessor httpContextAccessor,
                                      ILogger logger) {
        _httpContextAccessor = httpContextAccessor;
        _logger = logger;
        _ctxItems = httpContextAccessor.HttpContext?.Items ?? new Dictionary();
      }
    
      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 changes = new();
        if (_ctxItems.TryGetValue(AuditConstant.EntityChanges, out var item)) {
          changes = item as List ?? new List();
        } 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(sp =>
                                                new AuditLogMongoService(conf.GetConnectionString("MongoDB"), "stu_data_hub"));
        services.AddSingleton();
    
        return services;
      }
    
      public static IApplicationBuilder UseAudit(this IApplicationBuilder app) {
        app.UseMiddleware();
    
        return app;
      }
    }
    

    Program.cs 里注册

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

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

    使用效果#

    来看下使用效果

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

    /// 
    /// 设置反馈结果
    /// 
    [AuditLog(EventType = "设置反馈结果")]
    [HttpPost("{taskId}/sub-tasks/{subId}/set-feedback")]
    public async Task 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 关系起来的,不过后面发现用不上。

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

  • 相关阅读:
    什么是跨域问题?如何解决?
    通信工程学习:什么是接入网(AN)中的CF核心功能
    北京大学计算机考研经验分享汇总
    程序猿成长之路之密码学篇-密码学简介
    可以为一个servlet定义多个servlet-mapping、或url-pattern
    用大顶堆和小顶堆实现优先队列
    SQL报了一个不常见的错误,让新来的实习生懵了
    [C/C++]_[中级]_[获取月份的最后一天]
    Google Earth Engine(GEE)——landsat 8 去云一个简单的ui.select()结果
    每日刷题记录 (十三)
  • 原文地址:https://www.cnblogs.com/deali/p/18165737