讹我 發表於 2024-3-21 10:49:00

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

<h2 id="前言">前言</h2>
<p>最近一直在写 Go 和 Python ,好久没写 C# ,重新回来写 C# 代码时竟有一种亲切感~</p>
<p>说回正题。</p>
<p>在当今这个数字化迅速发展的时代,每一个操作都可能对业务产生深远的影响,无论是对数据的简单查询,还是对系统配置的修改。在这样的背景下,审计日志不仅仅是一种遵循最佳实践的手段,更是确保数据安全、提高系统透明度、促进责任归属明晰的关键工具。通过详细记录谁在何时对系统进行了何种操作,审计日志帮助组织追踪用户活动,分析系统问题,甚至在发生安全事件时,提供必要的线索进行调查。</p>
<p>实现审计日志的方法多样,但如何在不干扰主业务逻辑的同时,高效地集成这一功能,是开发者们面临的一大挑战。本文着重探讨如何借鉴面向切面编程(Aspect-Oriented Programming, AOP)的设计思想,在ASP.NET Core应用中以最小化代码侵入性实现动态审计日志功能。AOP允许我们通过预定义的模式,如日志记录、性能统计和安全控制,以声明的方式增强代码功能,而无需修改实际的业务逻辑代码。</p>
<p>本文将指导读者从概念的理解到具体的实施,再到最终的数据持久化处理,特别是如何利用MongoDB这一强大的NoSQL数据库来持久化审计日志数据。无论你是刚刚接触ASP.NET Core的新手,还是寻求为现有项目增加审计功能的资深开发者,本文都将提供从理论到实践的全面指导。通过本文,你将学习到如何设计和实现一个灵活、可扩展的审计日志系统,同时保持对主业务逻辑的最小化干扰。</p>
<p>让我们开始这一旅程,一步步探索如何在ASP.NET Core应用中集成高效、灵活的审计日志机制,利用AOP设计思想实现高度解耦和动态增强的系统功能。</p>
<h2 id="审计日志基础">审计日志基础</h2>
<h3 id="定义和用途">定义和用途</h3>
<p>审计日志有助于追踪用户的操作行为、数据变更记录以及系统的安全性分析等。</p>
<p>常用的审计日志有这些类型。</p>
<ul>
<li><strong>操作审计</strong>:记录用户对系统的所有操作,例如登录、登出、数据增删改查等。</li>
<li><strong>数据审计</strong>:记录数据的变更详情,如记录数据修改前后的值。</li>
<li><strong>安全审计</strong>:记录安全相关事件,如失败的登录尝试、权限变更等。</li>
<li><strong>性能审计</strong>:记录关键操作的性能数据,帮助分析系统瓶颈。</li>
</ul>
<p>本文的代码以实现操作审计为例。</p>
<h3 id="模型定义关键信息">模型定义&amp;关键信息</h3>
<p>审计日志是系统安全和管理的关键部分,它帮助我们理解系统内发生了什么、何时发生、由谁触发。为了实现这一目标,审计日志记录需要包含几个关键的组成部分。</p>
<ul>
<li><strong>EventId</strong> 是每条审计记录的唯一标识符。就像每个人都有一个独一无二的身份证号一样,每条审计日志也有一个独特的EventId。这使我们能够轻松地找到和引用特定的审计事件。</li>
<li><strong>EventType</strong> 描述了发生的事件类型。这告诉我们这条记录是关于什么的——是用户登录、数据修改,还是权限更改等。通过查看EventType,我们可以快速了解记录的核心信息,而无需深入研究细节。</li>
<li><strong>UserId</strong> 是触发事件的用户的标识。在审计日志中记录UserId非常重要,因为它帮助我们追踪谁负责了什么操作。如果发现了问题或者不当行为,我们可以通过UserId来确定责任人。</li>
</ul>
<h2 id="设计审计日志模型">设计审计日志模型</h2>
<h3 id="auditlog-类">AuditLog 类</h3>
<p>新建 <code>AuditLog.cs</code> 类,每个字段都有注释,我就不再赘述了。</p>
<pre><code class="language-c#">public class AuditLog {
/// &lt;summary&gt;
/// 事件唯一标识
/// &lt;/summary&gt;
public string EventId { get; set; }

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

/// &lt;summary&gt;
/// 执行操作的用户标识
/// &lt;/summary&gt;
public string UserId { get; set; }

/// &lt;summary&gt;
/// 执行操作的用户名
/// &lt;/summary&gt;
public string Username { get; set; }

/// &lt;summary&gt;
/// 事件发生的时间戳
/// &lt;/summary&gt;
public DateTime Timestamp { get; set; }

/// &lt;summary&gt;
/// 用户的IP地址
/// &lt;/summary&gt;
public string? IPAddress { get; set; }

/// &lt;summary&gt;
/// 被操作的实体名称
/// &lt;/summary&gt;
public string EntityName { get; set; }

/// &lt;summary&gt;
/// 被操作的实体标识
/// &lt;/summary&gt;
public string EntityId { get; set; }

/// &lt;summary&gt;
/// 修改前的数据,可根据实际情况以JSON格式存储
/// &lt;/summary&gt;
public string? OriginalValues { get; set; }

/// &lt;summary&gt;
/// 修改后的数据,可根据实际情况以JSON格式存储
/// &lt;/summary&gt;
public string? CurrentValues { get; set; }

/// &lt;summary&gt;
/// 具体的更改内容,可根据实际情况以JSON格式存储
/// &lt;/summary&gt;
public string? Changes { get; set; }

/// &lt;summary&gt;
/// 事件描述
/// &lt;/summary&gt;
public string? Description { get; set; }
}
</code></pre>
<h2 id="捕获审计日志">捕获审计日志</h2>
<h3 id="iauditlogservice-接口">IAuditLogService 接口</h3>
<p>先写一个接口,用来操作审计日志。使用接口可以保持代码的整洁和重用,同时也便于将来对审计日志记录逻辑进行扩展或修改。</p>
<p>为了简单起见,目前这里我们只写了一个记录的方法。</p>
<pre><code class="language-c#">public interface IAuditLogService {
Task LogAsync(AuditLog auditLog);
}
</code></pre>
<p>之后在依赖注入容器里注册(假设实现类的名称为 <code>AuditLogService</code>)</p>
<pre><code class="language-c#">builder.Services.AddScope&lt;IAuditLogService, AuditLogService&gt;();
</code></pre>
<p>这个设计既保持了代码的清晰与简洁,也为将来可能的需求变更(如改变审计日志的存储方式、增加审计字段等)提供了足够的灵活性。</p>
<p>具体实现会在后续的数据持久化部分介绍。</p>
<h3 id="actionfilter-方式">ActionFilter 方式</h3>
<p>在ASP.NET Core中,Action过滤器提供了一种强大的机制,允许我们在控制器的动作执行前后插入自定义逻辑。</p>
<p>我们可以在不修改现有业务逻辑代码的情况下,自动地捕获用户的操作以及数据的更改。这种方式充分利用了AOP的思想,实现了代码的最小化侵入。</p>
<h4 id="创建-auditlogattribute-类">创建 <code>AuditLogAttribute</code> 类</h4>
<p>直接上代码了,继承自 <code>ActionFilterAttribute</code> 类,可以实现一个 Action 过滤器的特性,其中 <code>EventType</code> 和 <code>EntityName</code> 我设计成需要手动指定,其他的属性可以通过各种方法来获取。</p>
<pre><code class="language-c#">public class AuditLogAttribute : ActionFilterAttribute {
public string EventType { get; set; }
public string EntityName { 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&lt;AuthService&gt;();

      // 在操作执行前
      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),
      EntityName = this.EntityName,
      EntityId = ctxItems["AuditLog_EntityId"]?.ToString() ?? "",
      OriginalValues = ctxItems["AuditLog_OriginalValues"]?.ToString(),
      CurrentValues = ctxItems["AuditLog_CurrentValues"]?.ToString(),
      Changes = ctxItems["AuditLog_Changes"]?.ToString(),
      Description = $"操作类型:{this.EventType},实体名称:{this.EntityName}",
      };

      var auditService = sp.GetRequiredService&lt;IAuditLogService&gt;();
      await auditService.LogAsync(auditLog);
    } catch (Exception ex) {
      var logger = sp.GetRequiredService&lt;ILogger&lt;AuditLogAttribute&gt;&gt;();
      logger.LogError(ex, "An error occurred while logging audit information.");
    }
}
}
</code></pre>
<h5 id="注意事项">注意事项</h5>
<ul>
<li><strong>异常处理</strong>:考虑到日志记录不应影响主要业务流程的执行,需要添加异常处理逻辑,确保即使日志记录过程中发生异常,也不会干扰到正常的业务逻辑。</li>
<li><strong>性能问题</strong>:虽然已经在异步方法中记录审计日志,但如果审计日志的记录过程很慢,可能会略微延迟响应时间。可以使用批处理、缓存来异步写入数据库,或者将记录逻辑放到后台任务、消息队列中。</li>
</ul>
<h4 id="获取ip地址">获取IP地址</h4>
<p>通过<code>HttpContext.Connection.RemoteIpAddress</code>属性可以获取 IP 地址,但如果应用部署在了代理服务器后面(例如使用了负载均衡器),直接获取的IP地址可能是代理服务器的地址,而不是客户端的真实IP地址。</p>
<p>所以这里我封装了 <code>GetIpAddress</code> 方法</p>
<pre><code class="language-c#">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();
}
</code></pre>
<p>首先尝试从<code>X-Forwarded-For</code>请求头中获取IP地址,这是一个标准的HTTP头,用于识别通过HTTP代理或负载均衡器发送请求的客户端的原始IP地址。如果请求没有经过代理,或者想要获取代理服务器的地址,那么它会回退到使用<code>HttpContext.Connection.RemoteIpAddress</code>。</p>
<p><code>X-Forwarded-For</code>可能包含多个IP地址(如果请求通过多个代理传递),因此代码中使用了<code>Split(',')</code>来处理这种情况,并且仅取第一个IP地址作为客户端的真实IP地址。</p>
<h4 id="使用方法">使用方法</h4>
<p>经过封装后可以很方便的使用这个审计功能了,只需要在接口上添加一行代码就可以实现审计功能。</p>
<pre><code class="language-c#">

public async Task&lt;ApiResponse&gt; SetSubTaskFeedback(string subId, SubTaskFeedbackDto dto) {}
</code></pre>
<h3 id="手动记录方式">手动记录方式</h3>
<p>尽管使用Action过滤器是一种高效的自动化方式,但在某些情况下,需要更精细地控制审计日志的记录。这时候只能修改接口代码,在业务逻辑里加入审计日志记录。</p>
<p>这种方式虽然需要直接修改业务代码,但它提供了最大的灵活性和控制能力。</p>
<p>这个代码就没什么特别的了,直接在接口中调用 <code>IAuditLogService</code> 的 <code>LogAsync</code> 方法来记录审计日志即可。</p>
<h3 id="通过-httpcontext-共享数据">通过 HttpContext 共享数据</h3>
<p>有些参数是很难在 ActionFilter 里自动获取到的,这些往往跟业务逻辑是有关的,这时候 HttpContext 就成为了一个理想的桥梁。</p>
<p>我们可以将一些临时数据,比如操作前的数据快照,存储在 <code>HttpContext.Items</code>中,然后在过滤器中访问这些数据来完成审计日志的记录。这种方法不仅保持了代码的解耦,还允许我们灵活地在应用的不同部分共享数据。</p>
<p><code>HttpContext.Items</code>是一个键值对集合,可用于在一个请求的生命周期内共享数据。</p>
<p>这样在接口中的代码就是</p>
<pre><code class="language-c#">HttpContext.Items["AuditLog_OriginalValues"] = item.FeedbackId;
HttpContext.Items["AuditLog_CurrentValues"] = dto.FeedbackId;
HttpContext.Items["AuditLog_Changes"] = $"更新反馈结果 {item.FeedbackId} -&gt; {dto.FeedbackId}";
</code></pre>
<h4 id="注意事项-1">注意事项</h4>
<ul>
<li>确保业务逻辑和<code>AuditLogAttribute</code>中使用的键(如 <code>AuditLog_OriginalValues</code> )唯一且一致,以避免潜在的冲突。这里最好是自己封装一个 class 来提供这些 const ;</li>
<li>如果业务逻辑抽象到了 service 层,则需要注入 <code>IHttpContextAccessor</code> 才能访问 HttpContext ,这个服务可以通过 <code>services.AddHttpContextAccessor()</code> 来注册;</li>
</ul>
<h2 id="日志持久化">日志持久化</h2>
<p>审计日志的有效持久化是确保长期安全和合规性的关键。</p>
<h3 id="选择存储方案">选择存储方案</h3>
<p>在选择最合适的存储方案时,需要考虑数据的重要性、查询的频率、成本以及维护的复杂性等多个因素。</p>
<h4 id="关系型数据库rds">关系型数据库(RDS)</h4>
<p>关系型数据库,如MySQL、PostgreSQL等,以其稳定性和成熟性受到广泛认可。它们提供了严格的数据完整性保障和复杂查询的强大能力,适合需要执行复杂分析和报告的审计日志。</p>
<ul>
<li><em>优点</em>:数据结构化、支持复杂查询、成熟的管理工具。</li>
<li><em>缺点</em>:相对较高的成本、可能需要复杂的架构来支持大规模数据。</li>
</ul>
<h4 id="nosql数据库">NoSQL数据库</h4>
<p>NoSQL数据库,如MongoDB、Cassandra等,提供了灵活的数据模型和良好的横向扩展能力,适合于结构多变或数据量巨大的审计日志。</p>
<ul>
<li><em>优点</em>:高可扩展性、灵活的数据模型、快速的写入速度。</li>
<li><em>缺点</em>:查询功能相对有限、数据一致性模型较弱。</li>
</ul>
<h4 id="文件系统">文件系统</h4>
<p>直接将审计日志写入文件系统是最直接的存储方式,适用于日志量不是特别大或对查询需求不高的场景。</p>
<ul>
<li><em>优点</em>:实现简单、成本低廉、易于迁移;</li>
<li><em>缺点</em>:查询和分析不便、难以管理大量日志文件、扩展性有限。</li>
</ul>
<p>每种存储方案都有其适用场景,因此选择哪一种方案应根据具体需求和资源情况综合考虑。对于需要快速写入和高度可扩展的审计日志系统,NoSQL数据库是一个不错的选择。</p>
<p>因此本文选择了 MongoDB 来记录日志。</p>
<p>选择MongoDB作为审计日志的存储方案,不仅因为它的高性能和可扩展性,还因为它支持灵活的文档数据模型,使得存储非结构化或半结构化的审计数据变得简单。</p>
<h3 id="实现-auditlogmongoservice">实现 AuditLogMongoService</h3>
<p>在 C# 中使用 MongoDB 非常简单。</p>
<p>需要先添加 MongoDB.Driver 的 nuget 包</p>
<pre><code class="language-bash">dotnet add MongoDB.Driver
</code></pre>
<p>直接上代码吧,</p>
<pre><code class="language-c#">public class AuditLogMongoService : IAuditLogService {
private readonly IMongoCollection&lt;AuditLog&gt; _auditLogs;

public AuditLogMongoService(string connectionString, string databaseName) {
    var client = new MongoClient(connectionString);
    var database = client.GetDatabase(databaseName);
    _auditLogs = database.GetCollection&lt;AuditLog&gt;("audit_logs");
}

public async Task LogAsync(AuditLog auditLog) {
    await _auditLogs.InsertOneAsync(auditLog);
}
}
</code></pre>
<h3 id="准备连接字符串注册服务">准备连接字符串&amp;注册服务</h3>
<p>为了避免硬编码,将连接字符串放在配置文件(<code>appsettings.json</code>)里</p>
<pre><code class="language-json">"ConnectionStrings": {
"Redis": "redis:6379",
"MongoDB": "mongodb://username:password@path-to-mongo:27017"
}
</code></pre>
<p>注册服务</p>
<pre><code class="language-c#">builder.Services.AddSingleton&lt;IAuditLogService&gt;(sp =&gt; new AuditLogMongoService(builder.Configuration.GetConnectionString("MongoDB"), "db_name"));
</code></pre>
<p>搞定~</p>
<h3 id="部署-mongodb">部署 MongoDB</h3>
<p>附上 MongoDB 的部署方法吧,我这里使用 docker ,很方便</p>
<pre><code class="language-yaml">version: '3.1'

services:

mongo:
    image: mongo:4.4.6
    restart: always
    volumes:
      - ./data:/data/db
    environment:
      MONGO_INITDB_ROOT_USERNAME: username
      MONGO_INITDB_ROOT_PASSWORD: password
    ports:
      - 27017:27017

mongo-express:
    image: mongo-express
    restart: always
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: username
      ME_CONFIG_MONGODB_ADMINPASSWORD: password
      ME_CONFIG_MONGODB_URL: mongodb://username:password@mongo:27017/
    ports:
      - 8081:8081
</code></pre>
<p>使用 docker-compose 来编排,映射了 27017 和 8081 端口</p>
<p>可以使用 8081 端口访问 mongo-express 网页服务</p>
<h3 id="如何查看日志">如何查看日志</h3>
<ul>
<li>使用 MongoDB Compass 这个软件来查看数据</li>
<li>使用 mongo-express 服务可以在网页上查看数据</li>
</ul>
<h2 id="小结">小结</h2>
<p>虽然是比较简单的功能,不过使用 AOP 来实现用起来感觉还是蛮爽的,不得不说 AspNetCore 的功能确实丰富~</p>


</div>
<div id="MySignature" role="contentinfo">
    微信公众号:「程序设计实验室」
专注于互联网热门新技术探索与团队敏捷开发实践,包括架构设计、机器学习与数据分析算法、移动端开发、Linux、Web前后端开发等,欢迎一起探讨技术,分享学习实践经验。<br><br>
来源:https://www.cnblogs.com/deali/p/18086834
頁: [1]
查看完整版本: Asp-Net-Core开发笔记:实现动态审计日志功能