RO EN

Audit Logging și Security Events în ASP.NET Core

Audit Logging și Security Events în ASP.NET Core
Doru Bulubașa
29 mai 2026

Toate articolele anterioare din această serie s-au concentrat pe prevenție: autentificare solidă, autorizare corectă, protecție împotriva atacurilor cunoscute. Dar prevenția nu e niciodată 100%. Întrebarea nu e dacă vei avea un incident de securitate, ci când — și cât de repede îl vei detecta și înțelege.

Audit logging înregistrează ce s-a întâmplat în sistem: cine a făcut ce, când, de unde, și cu ce rezultat. Security events sunt un subset special — evenimentele cu implicații de securitate: autentificări eșuate, acces la resurse sensibile, modificări de permisiuni, operații administrative. Împreună formează baza pentru detecție, investigație și conformitate.


1. Ce trebuie logat — și ce nu

Primul instinct e să loghezi totul. Greșeală — un volum uriaș de log-uri fără structură e la fel de inutil ca niciun log. Trebuie să loghezi evenimentele cu valoare de investigație.

Trebuie logat obligatoriu

  • Autentificări reușite și eșuate (cu IP, user agent, timestamp)
  • Logout explicit și expirare sesiune
  • Modificări de parolă și email
  • Activare / dezactivare MFA
  • Creare, modificare și ștergere de conturi
  • Modificări de roluri și permisiuni
  • Acces la date sensibile (date financiare, date personale, documente confidențiale)
  • Operații administrative (configurare sistem, export date, ștergeri în masă)
  • Erori de autorizare (403 Forbidden) — indică tentative de acces neautorizat
  • Rate limit depășit (429) — poate indica atac automatizat
  • Modificări la entități critice din baza de date

Nu loga niciodată

  • Parole sau hash-uri de parole
  • Token-uri de autentificare (JWT, refresh tokens, API keys)
  • Date de card bancar sau CVV
  • Conținut complet al request-urilor cu date sensibile (body-uri cu parole, PII)
  • Cookie-uri de sesiune

Regula de aur: un log de audit trebuie să poată răspunde la întrebarea „cine a făcut X la momentul T?” — fără să expună date care pot fi exploatate dacă log-urile sunt compromise.


2. Structura unui audit log

Un audit log util are o structură consistentă care permite filtrare, corelare și alertare automată. Câmpurile esențiale:

public record AuditEvent
{
    // Identitate eveniment
    public Guid   EventId        { get; init; } = Guid.NewGuid();
    public string EventType      { get; init; } = default!; // "auth.login.success"
    public string Category       { get; init; } = default!; // "Authentication"
    public string Severity       { get; init; } = default!; // "Info", "Warning", "Critical"

    // Cine
    public string? UserId        { get; init; }
    public string? UserName      { get; init; }
    public string? UserEmail     { get; init; }
    public string? IpAddress     { get; init; }
    public string? UserAgent     { get; init; }

    // Ce
    public string  Action        { get; init; } = default!; // "Login"
    public string? ResourceType  { get; init; }             // "Invoice"
    public string? ResourceId    { get; init; }             // "INV-2025-001"
    public bool    Succeeded     { get; init; }
    public string? FailureReason { get; init; }

    // Context
    public string? RequestPath   { get; init; }
    public string? HttpMethod    { get; init; }
    public string? CorrelationId { get; init; } // leagă mai multe event-uri dintr-un request
    public string? TenantId      { get; init; } // pentru arhitecturi multi-tenant

    // Când
    public DateTime Timestamp    { get; init; } = DateTime.UtcNow;

    // Date adiționale structurate
    public Dictionary<string, object>? AdditionalData { get; init; }
}

Convenție pentru EventType

Folosește un naming convention ierarhic, consistent — facilitează filtrarea și alertarea:

// Format: <domeniu>.<actiune>.<rezultat>
"auth.login.success"
"auth.login.failed"
"auth.logout"
"auth.password.changed"
"auth.mfa.enabled"
"auth.mfa.disabled"

"account.created"
"account.deleted"
"account.role.assigned"
"account.role.revoked"

"data.invoice.viewed"
"data.invoice.exported"
"data.user.personal.accessed"

"admin.config.changed"
"admin.bulk.delete"
"admin.impersonation.started"

3. Serviciu de audit logging

3.1 Interfață și implementare

public interface IAuditLogger
{
    Task LogAsync(AuditEvent auditEvent);
    Task LogAsync(string eventType, string action, bool succeeded,
        string? resourceType = null, string? resourceId = null,
        string? failureReason = null,
        Dictionary<string, object>? additionalData = null);
}

public class AuditLogger(IHttpContextAccessor httpContextAccessor,
                         ILogger<AuditLogger> logger) : IAuditLogger
{
    public Task LogAsync(AuditEvent auditEvent)
    {
        // Logăm ca structured log — Application Insights îl indexează automat
        logger.LogInformation(
            "[AUDIT] {EventType} | User: {UserId} | IP: {IpAddress} | " +
            "Action: {Action} | Resource: {ResourceType}/{ResourceId} | " +
            "Succeeded: {Succeeded} | Reason: {FailureReason}",
            auditEvent.EventType,
            auditEvent.UserId ?? "anonymous",
            auditEvent.IpAddress,
            auditEvent.Action,
            auditEvent.ResourceType,
            auditEvent.ResourceId,
            auditEvent.Succeeded,
            auditEvent.FailureReason);

        return Task.CompletedTask;
    }

    public Task LogAsync(string eventType, string action, bool succeeded,
        string? resourceType = null, string? resourceId = null,
        string? failureReason = null,
        Dictionary<string, object>? additionalData = null)
    {
        var context = httpContextAccessor.HttpContext;
        var user    = context?.User;

        var auditEvent = new AuditEvent
        {
            EventType      = eventType,
            Action         = action,
            Category       = eventType.Split('.')[0],
            Severity       = succeeded ? "Info" : "Warning",
            UserId         = user?.FindFirstValue(ClaimTypes.NameIdentifier),
            UserName       = user?.FindFirstValue(ClaimTypes.Name),
            UserEmail      = user?.FindFirstValue(ClaimTypes.Email),
            IpAddress      = GetClientIp(context),
            UserAgent      = context?.Request.Headers.UserAgent.ToString(),
            ResourceType   = resourceType,
            ResourceId     = resourceId,
            Succeeded      = succeeded,
            FailureReason  = failureReason,
            RequestPath    = context?.Request.Path.ToString(),
            HttpMethod     = context?.Request.Method,
            CorrelationId  = context?.TraceIdentifier,
            AdditionalData = additionalData
        };

        return LogAsync(auditEvent);
    }

    private static string? GetClientIp(HttpContext? context)
    {
        if (context is null) return null;

        // Citim IP-ul real dacă suntem în spatele unui proxy
        var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
        if (!string.IsNullOrEmpty(forwardedFor))
            return forwardedFor.Split(',')[0].Trim();

        return context.Connection.RemoteIpAddress?.ToString();
    }
}

3.2 Înregistrare în DI

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IAuditLogger, AuditLogger>();

3.3 Utilizare în controllers

[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(
    [FromBody] LoginDto dto,
    [FromServices] IAuditLogger auditLogger)
{
    var result = await _signInManager
        .PasswordSignInAsync(dto.Email, dto.Password,
            isPersistent: false, lockoutOnFailure: true);

    if (result.Succeeded)
    {
        await auditLogger.LogAsync(
            eventType:    "auth.login.success",
            action:       "Login",
            succeeded:    true,
            additionalData: new()
            {
                ["email"] = dto.Email  // email, nu parolă!
            });

        return Ok();
    }

    await auditLogger.LogAsync(
        eventType:     "auth.login.failed",
        action:        "Login",
        succeeded:     false,
        failureReason: result.IsLockedOut ? "Account locked" : "Invalid credentials",
        additionalData: new() { ["email"] = dto.Email });

    return Unauthorized();
}

4. Audit logging automat cu middleware și Action Filters

Pentru operații CRUD pe entități, poți automatiza audit logging-ul cu un Action Filter — fără să adaugi cod în fiecare controller.

public class AuditActionFilter(IAuditLogger auditLogger) : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var executedContext = await next();

        // Logăm doar dacă există atribut [Audit] pe action/controller
        var auditAttr = context.ActionDescriptor
            .EndpointMetadata
            .OfType<AuditAttribute>()
            .FirstOrDefault();

        if (auditAttr is null) return;

        var succeeded = executedContext.Exception is null
            && executedContext.HttpContext.Response.StatusCode < 400;

        await auditLogger.LogAsync(
            eventType:    auditAttr.EventType,
            action:       auditAttr.Action,
            succeeded:    succeeded,
            resourceType: auditAttr.ResourceType,
            failureReason: succeeded ? null :
                $"HTTP {executedContext.HttpContext.Response.StatusCode}");
    }
}

// Atribut custom
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class AuditAttribute(
    string eventType,
    string action,
    string? resourceType = null) : Attribute
{
    public string  EventType    { get; } = eventType;
    public string  Action       { get; } = action;
    public string? ResourceType { get; } = resourceType;
}

// Înregistrare globală
builder.Services.AddControllers(options =>
{
    options.Filters.Add<AuditActionFilter>();
});
// Utilizare
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
[Audit("admin.invoice.deleted", "DeleteInvoice", "Invoice")]
public async Task<IActionResult> Delete(int id)
{
    await _invoiceService.DeleteAsync(id);
    return NoContent();
}

5. Serilog — structured logging pentru audit

Serilog este cel mai popular logger în ecosistemul .NET pentru structured logging. Îl configurezi să trimită audit log-urile în mai multe destinații simultan.

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.ApplicationInsights
dotnet add package Serilog.Sinks.AzureCosmosDB   // opțional, pentru Cosmos DB
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
// Program.cs
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
    .MinimumLevel.Override("System",    LogEventLevel.Warning)
    .Enrich.FromLogContext()
    .Enrich.WithEnvironmentName()
    .Enrich.WithThreadId()
    .Enrich.WithProperty("Application", "LudoProgramming")
    .WriteTo.Console(outputTemplate:
        "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .WriteTo.ApplicationInsights(
        builder.Configuration["ApplicationInsights:ConnectionString"],
        TelemetryConverter.Traces)
    .CreateLogger();

builder.Host.UseSerilog();

Filtru dedicat pentru audit events

Separă audit log-urile de log-urile operaționale — le vrei în destinații diferite cu retenție diferită:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Logger(lc => lc
        // Log-uri operaționale — retenție 30 zile
        .Filter.ByExcluding(e =>
            e.MessageTemplate.Text.StartsWith("[AUDIT]"))
        .WriteTo.Console()
        .WriteTo.ApplicationInsights(connectionString,
            TelemetryConverter.Traces))
    .WriteTo.Logger(lc => lc
        // Audit log-uri — retenție 1 an (conformitate GDPR)
        .Filter.ByIncludingOnly(e =>
            e.MessageTemplate.Text.StartsWith("[AUDIT]"))
        .WriteTo.ApplicationInsights(connectionString,
            TelemetryConverter.Events)  // ca Events, nu Traces
    )
    .CreateLogger();

6. Audit log în baza de date — trail persistent

Pentru conformitate GDPR și investigații forensice, audit log-urile trebuie stocate persistent, separat de log-urile operaționale. Cosmos DB e o alegere excelentă — append-only natural, scalabil, și deja în infrastructura LudoProgramming.

// Model Cosmos DB pentru audit
public class AuditEventDocument : AuditEvent
{
    [JsonProperty("id")]
    public string Id { get; init; } = Guid.NewGuid().ToString();

    // Partition key: "userId" sau "tenantId" pentru queries eficiente
    [JsonProperty("partitionKey")]
    public string PartitionKey { get; init; } = default!;

    // TTL: Cosmos DB poate șterge automat după perioada de retenție
    [JsonProperty("ttl")]
    public int? Ttl { get; init; }  // secunde; -1 = nu expira
}

// Repository
public class CosmosAuditRepository(CosmosClient cosmosClient)
{
    private readonly Container _container = cosmosClient
        .GetContainer("AuditDb", "AuditEvents");

    public async Task AppendAsync(AuditEvent auditEvent)
    {
        var doc = new AuditEventDocument
        {
            EventType      = auditEvent.EventType,
            Action         = auditEvent.Action,
            Category       = auditEvent.Category,
            Severity       = auditEvent.Severity,
            UserId         = auditEvent.UserId,
            UserName       = auditEvent.UserName,
            IpAddress      = auditEvent.IpAddress,
            ResourceType   = auditEvent.ResourceType,
            ResourceId     = auditEvent.ResourceId,
            Succeeded      = auditEvent.Succeeded,
            FailureReason  = auditEvent.FailureReason,
            CorrelationId  = auditEvent.CorrelationId,
            Timestamp      = auditEvent.Timestamp,
            AdditionalData = auditEvent.AdditionalData,
            PartitionKey   = auditEvent.UserId ?? "anonymous",
            // Retenție: 1 an în secunde (GDPR)
            Ttl            = (int)TimeSpan.FromDays(365).TotalSeconds
        };

        await _container.CreateItemAsync(
            doc, new PartitionKey(doc.PartitionKey));
    }
}

Audit log-urile nu se modifică și nu se șterg manual. Append-only este o proprietate esențială pentru integritatea unui audit trail. Dacă ai nevoie să anonimizezi date din motive GDPR, suprascrie câmpurile UserId, UserName, UserEmail cu un hash sau cu „[anonymized]” — dar păstrează evenimentul.


7. Security Events — detecție automată de atacuri

Dincolo de înregistrarea evenimentelor, vrei să detectezi pattern-uri care indică un atac în curs. Câteva scenarii comune:

7.1 Brute-force detection

public class SecurityEventDetector(
    IAuditLogger auditLogger,
    IDistributedCache cache)
{
    // Detectare brute-force: X login-uri eșuate de la același IP în Y minute
    public async Task<bool> IsLoginBruteForceAsync(
        string ipAddress,
        int maxFailures = 10,
        int windowMinutes = 15)
    {
        var key     = $"login-failures:{ipAddress}";
        var cached  = await cache.GetStringAsync(key);
        var count   = cached is null ? 0 : int.Parse(cached);

        if (count >= maxFailures)
        {
            await auditLogger.LogAsync(
                eventType:    "security.bruteforce.detected",
                action:       "BruteForceDetected",
                succeeded:    false,
                failureReason: $"{count} login failures in {windowMinutes} minutes",
                additionalData: new() { ["ip"] = ipAddress, ["count"] = count });

            return true;
        }

        // Incrementăm contorul
        await cache.SetStringAsync(
            key, (count + 1).ToString(),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    TimeSpan.FromMinutes(windowMinutes)
            });

        return false;
    }
}

7.2 Acces neobișnuit — ore sau locații noi

// Loghează și alertează când un utilizator se autentifică
// dintr-o țară nouă sau la o oră neobișnuită
public async Task LogLoginWithContextAsync(
    string userId,
    string ipAddress,
    DateTime loginTime)
{
    var isUnusualHour = loginTime.Hour < 6 || loginTime.Hour > 22;
    var severity      = isUnusualHour ? "Warning" : "Info";

    await auditLogger.LogAsync(new AuditEvent
    {
        EventType  = isUnusualHour
            ? "auth.login.unusual-time"
            : "auth.login.success",
        Action     = "Login",
        Severity   = severity,
        UserId     = userId,
        IpAddress  = ipAddress,
        Succeeded  = true,
        AdditionalData = new()
        {
            ["hour"]         = loginTime.Hour,
            ["isUnusualTime"] = isUnusualHour
        }
    });
}

8. Alertare în Azure Monitor și Application Insights

Log-urile fără alertare sunt ca un sistem de alarmă fără sunet. Configurezi alerte automate în Azure Monitor pe baza query-urilor Kusto (KQL):

8.1 Alertă pentru brute-force

// KQL Query pentru Azure Monitor Alert
// Alertă dacă mai mult de 20 de login-uri eșuate în 5 minute de la același IP
traces
| where message startswith "[AUDIT]"
| where message contains "auth.login.failed"
| extend ip = tostring(customDimensions.IpAddress)
| summarize failureCount = count() by ip, bin(timestamp, 5m)
| where failureCount > 20
| project timestamp, ip, failureCount

8.2 Alertă pentru acces administrativ neobișnuit

// Alertă dacă un non-admin accesează endpoint-uri de admin
traces
| where message startswith "[AUDIT]"
| where customDimensions.EventType startswith "admin."
| where customDimensions.Succeeded == "False"
| where customDimensions.FailureReason contains "403"
| project timestamp,
          userId = customDimensions.UserId,
          path   = customDimensions.RequestPath,
          ip     = customDimensions.IpAddress
| order by timestamp desc

8.3 Configurare alertă în portal Azure

  1. Azure Monitor → Alerts → Create alert rule
  2. Scope: Log Analytics Workspace sau Application Insights
  3. Condition: Custom log search (KQL de mai sus)
  4. Threshold: Greater than 0 results
  5. Action Group: Email / SMS / Teams webhook / PagerDuty
  6. Frequency: Every 5 minutes, Lookback: 15 minutes

9. GDPR și retenția audit log-urilor

Audit log-urile conțin date personale (userId, email, IP) și sunt supuse GDPR. Câteva reguli practice:

Retenție

  • Audit log-uri de securitate: minim 1 an (recomandat 3 ani pentru investigații forensice)
  • Log-uri operaționale: 30-90 zile
  • Log-uri de acces la date medicale/financiare: conform reglementărilor specifice (ex: 10 ani pentru date fiscale în România)

Dreptul la ștergere — cum îl aplici fără a compromite audit trail

// La cererea utilizatorului de ștergere a datelor (GDPR Art. 17),
// anonimizezi datele personale din audit log, dar păstrezi evenimentul
public async Task AnonymizeUserAuditDataAsync(string userId)
{
    var anonymizedId = $"[deleted-{Guid.NewGuid():N}]";

    // Patch pe toate documentele din Cosmos DB cu userId-ul dat
    var query = new QueryDefinition(
        "SELECT * FROM c WHERE c.userId = @userId")
        .WithParameter("@userId", userId);

    var iterator = _container.GetItemQueryIterator<AuditEventDocument>(query);

    while (iterator.HasMoreResults)
    {
        var page = await iterator.ReadNextAsync();
        foreach (var doc in page)
        {
            var patches = new[]
            {
                PatchOperation.Set("/UserId",    anonymizedId),
                PatchOperation.Set("/UserName",  "[deleted]"),
                PatchOperation.Set("/UserEmail", "[deleted]")
                // IpAddress și UserAgent pot rămâne — nu identifică persoana singure
            };

            await _container.PatchItemAsync<AuditEventDocument>(
                doc.Id, new PartitionKey(doc.PartitionKey), patches);
        }
    }
}

10. Testare audit logging

[TestFixture]
public class AuditLoggerTests
{
    private IAuditLogger _auditLogger = default!;
    private ILogger<AuditLogger> _mockLogger = default!;

    [SetUp]
    public void Setup()
    {
        _mockLogger  = Substitute.For<ILogger<AuditLogger>>();
        var accessor = Substitute.For<IHttpContextAccessor>();
        _auditLogger = new AuditLogger(accessor, _mockLogger);
    }

    [Test]
    public async Task LogAsync_FailedLogin_LogsWarningWithDetails()
    {
        await _auditLogger.LogAsync(
            eventType:     "auth.login.failed",
            action:        "Login",
            succeeded:     false,
            failureReason: "Invalid credentials");

        _mockLogger.Received(1).Log(
            LogLevel.Information,
            Arg.Any<EventId>(),
            Arg.Is<object>(o => o.ToString()!.Contains("auth.login.failed")),
            null,
            Arg.Any<Func<object, Exception?, string>>());
    }

    [Test]
    public async Task LogAsync_NeverLogs_SensitiveData()
    {
        await _auditLogger.LogAsync(
            eventType: "auth.login.failed",
            action:    "Login",
            succeeded: false,
            additionalData: new()
            {
                // Simulăm un bug unde s-ar putea loga parola accidental
                ["email"]    = "user@test.com",
                ["password"] = "SHOULD-NOT-APPEAR"
            });

        // Verificăm că parola nu apare în niciun log message
        _mockLogger.ReceivedCalls()
            .Select(c => c.GetArguments()[2]?.ToString() ?? "")
            .ToList()
            .ForEach(msg =>
                Assert.That(msg,
                    Does.Not.Contain("SHOULD-NOT-APPEAR")));
    }
}

11. Checklist de producție

  • ✅ Audit log-uri separate de log-uri operaționale — retenție diferită
  • ✅ Structura AuditEvent consistentă cu EventType ierarhic
  • ✅ Zero date sensibile în log-uri: fără parole, tokens, carduri
  • ✅ Toate evenimentele de autentificare logate (success + failure)
  • ✅ Toate operațiile administrative logate
  • CorrelationId inclus pentru corelarea evenimentelor dintr-un request
  • ✅ IP real din X-Forwarded-For când aplicația e în spatele unui proxy
  • ✅ Audit log-uri stocate persistent (Cosmos DB, Azure Table Storage) cu TTL configurat
  • ✅ Alerte în Azure Monitor pentru brute-force și acces administrativ neautorizat
  • ✅ Procedură de anonimizare GDPR implementată și testată
  • ✅ Audit log-urile sunt append-only — nicio operație de ștergere sau modificare
  • ✅ Teste care verifică că datele sensibile nu apar în log-uri

Concluzie — finalul seriei

Audit logging este ultimul strat dintr-o strategie de securitate completă — și primul instrument pe care îl folosești când ceva merge prost. Fără el, un incident de securitate e un mister. Cu el, e o investigație cu date concrete: cine, ce, când, de unde.

Aceasta este și concluzia seriei de 15 articole despre securitate în ASP.NET Core. Am parcurs drumul complet: de la principii (Security by Design), prin autentificare și autorizare (JWT, OAuth2, certificate, IdentityServer, Keycloak), la protecție împotriva atacurilor (CSRF, XSS, Injection, Rate Limiting) și, în final, vizibilitate (Audit Logging).

Securitatea nu e o caracteristică adăugată la final — e o proprietate a întregului sistem, construită strat cu strat, din prima zi de development.