From 44bede1a61c7d176a45095cd5e63db7407a1d7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:46:39 +0100 Subject: [PATCH 1/2] [GEN-1859] Add audit logging for authentication events Add comprehensive audit logging to login and logout operations to track user authentication activities. Implements logging for successful logins, failed login attempts, account lockouts, and logout events with user details and IP addresses for security monitoring. stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/473, branch: Jossec101/stack/16 --- .../Identity/Pages/Account/LogOut.cshtml | 44 +++++++++++++++---- .../Identity/Pages/Account/Login.cshtml.cs | 36 ++++++++++++++- .../Pages/Account/LoginWith2fa.cshtml.cs | 34 +++++++++++++- .../Pages/Account/Manage/Disable2fa.cshtml.cs | 14 +++++- .../Manage/EnableAuthenticator.cshtml.cs | 13 +++++- 5 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/Areas/Identity/Pages/Account/LogOut.cshtml b/src/Areas/Identity/Pages/Account/LogOut.cshtml index b3b66748..ed279e7c 100644 --- a/src/Areas/Identity/Pages/Account/LogOut.cshtml +++ b/src/Areas/Identity/Pages/Account/LogOut.cshtml @@ -1,27 +1,53 @@ @page @using NodeGuard.Data.Models @using Microsoft.AspNetCore.Identity +@using NodeGuard.Services +@using NodeGuard.Helpers +@using System.Security.Claims @attribute [IgnoreAntiforgeryToken] @inject SignInManager SignInManager +@inject IAuditService AuditService +@inject IHttpContextAccessor HttpContextAccessor @functions { public async Task OnPost() { if (SignInManager.IsSignedIn(User)) { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var username = User.Identity?.Name; await SignInManager.SignOutAsync(); + await AuditService.LogAsync( + AuditActionType.Logout, + AuditEventType.Success, + AuditObjectType.User, + userId, + userId, + username, + HttpContextAccessor.HttpContext.GetClientIpAddress(), + new { Username = username }); } return Redirect("~/"); } - public async Task OnGet() - { - if (SignInManager.IsSignedIn(User)) - { - await SignInManager.SignOutAsync(); - } - - return Redirect("~/"); - } + public async Task OnGet() + { + if (SignInManager.IsSignedIn(User)) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var username = User.Identity?.Name; + await SignInManager.SignOutAsync(); + await AuditService.LogAsync( + AuditActionType.Logout, + AuditEventType.Success, + AuditObjectType.User, + userId, + userId, + username, + HttpContextAccessor.HttpContext.GetClientIpAddress(), + new { Username = username }); + } + return Redirect("~/"); + } } diff --git a/src/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/Areas/Identity/Pages/Account/Login.cshtml.cs index 35ab49a2..0b2a1236 100644 --- a/src/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -15,6 +15,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; +using NodeGuard.Services; +using NodeGuard.Helpers; namespace NodeGuard.Areas.Identity.Pages.Account { @@ -22,11 +24,15 @@ public class LoginModel : PageModel { private readonly SignInManager _signInManager; private readonly ILogger _logger; + private readonly IAuditService _auditService; + private readonly UserManager _userManager; - public LoginModel(SignInManager signInManager, ILogger logger) + public LoginModel(SignInManager signInManager, ILogger logger, IAuditService auditService, UserManager userManager) { _signInManager = signInManager; _logger = logger; + _auditService = auditService; + _userManager = userManager; } /// @@ -128,6 +134,16 @@ public async Task OnPostAsync(string returnUrl = null) if (result.Succeeded) { _logger.LogInformation("User logged in."); + var user = await _userManager.FindByNameAsync(Input.Username); + await _auditService.LogAsync( + AuditActionType.Login, + AuditEventType.Success, + AuditObjectType.User, + user?.Id, + user?.Id, + Input.Username, + HttpContext.GetClientIpAddress(), + new { Username = Input.Username }); return LocalRedirect(returnUrl); } if (result.RequiresTwoFactor) @@ -137,10 +153,28 @@ public async Task OnPostAsync(string returnUrl = null) if (result.IsLockedOut) { _logger.LogWarning("User account locked out."); + await _auditService.LogAsync( + AuditActionType.Login, + AuditEventType.Failure, + AuditObjectType.User, + null, + null, + Input.Username, + HttpContext.GetClientIpAddress(), + new { Username = Input.Username, Reason = "Account locked out" }); return RedirectToPage("./Lockout"); } else { + await _auditService.LogAsync( + AuditActionType.Login, + AuditEventType.Failure, + AuditObjectType.User, + null, + null, + Input.Username, + HttpContext.GetClientIpAddress(), + new { Username = Input.Username, Reason = "Invalid credentials" }); ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); } diff --git a/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs b/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs index 048cb491..a8838f55 100644 --- a/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Identity; +using NodeGuard.Services; +using NodeGuard.Helpers; namespace NodeGuard.Areas.Identity.Pages.Account { @@ -15,15 +17,18 @@ public class LoginWith2faModel : PageModel private readonly SignInManager _signInManager; private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly IAuditService _auditService; public LoginWith2faModel( SignInManager signInManager, UserManager userManager, - ILogger logger) + ILogger logger, + IAuditService auditService) { _signInManager = signInManager; _userManager = userManager; _logger = logger; + _auditService = auditService; } /// @@ -109,16 +114,43 @@ public async Task OnPostAsync(bool rememberMe, string returnUrl = if (result.Succeeded) { _logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id); + await _auditService.LogAsync( + AuditActionType.TwoFactorLogin, + AuditEventType.Success, + AuditObjectType.User, + userId, + userId, + user.UserName, + HttpContext.GetClientIpAddress(), + new { Username = user.UserName }); return LocalRedirect(returnUrl); } else if (result.IsLockedOut) { _logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id); + await _auditService.LogAsync( + AuditActionType.TwoFactorLogin, + AuditEventType.Failure, + AuditObjectType.User, + userId, + userId, + user.UserName, + HttpContext.GetClientIpAddress(), + new { Username = user.UserName, Reason = "Account locked out" }); return RedirectToPage("./Lockout"); } else { _logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id); + await _auditService.LogAsync( + AuditActionType.TwoFactorLogin, + AuditEventType.Failure, + AuditObjectType.User, + userId, + userId, + user.UserName, + HttpContext.GetClientIpAddress(), + new { Username = user.UserName, Reason = "Invalid authenticator code" }); ModelState.AddModelError(string.Empty, "Invalid authenticator code."); return Page(); } diff --git a/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs b/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs index 6a7c8df9..1006e6fd 100644 --- a/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; +using NodeGuard.Services; namespace NodeGuard.Areas.Identity.Pages.Account.Manage { @@ -16,13 +17,16 @@ public class Disable2faModel : PageModel { private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly IAuditService _auditService; public Disable2faModel( UserManager userManager, - ILogger logger) + ILogger logger, + IAuditService auditService) { _userManager = userManager; _logger = logger; + _auditService = auditService; } /// @@ -63,6 +67,14 @@ public async Task OnPostAsync() } _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User)); + + await _auditService.LogAsync( + AuditActionType.TwoFactorDisabled, + AuditEventType.Success, + AuditObjectType.User, + user.Id, + new { Username = user.UserName }); + StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app"; return RedirectToPage("./TwoFactorAuthentication"); } diff --git a/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs b/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs index dff5b05f..7de753b0 100644 --- a/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; +using NodeGuard.Services; namespace NodeGuard.Areas.Identity.Pages.Account.Manage { @@ -22,17 +23,20 @@ public class EnableAuthenticatorModel : PageModel private readonly UserManager _userManager; private readonly ILogger _logger; private readonly UrlEncoder _urlEncoder; + private readonly IAuditService _auditService; private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; public EnableAuthenticatorModel( UserManager userManager, ILogger logger, - UrlEncoder urlEncoder) + UrlEncoder urlEncoder, + IAuditService auditService) { _userManager = userManager; _logger = logger; _urlEncoder = urlEncoder; + _auditService = auditService; } /// @@ -129,6 +133,13 @@ public async Task OnPostAsync() var userId = await _userManager.GetUserIdAsync(user); _logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId); + await _auditService.LogAsync( + AuditActionType.TwoFactorEnabled, + AuditEventType.Success, + AuditObjectType.User, + userId, + new { Username = user.UserName }); + StatusMessage = "Your authenticator app has been verified."; if (await _userManager.CountRecoveryCodesAsync(user) == 0) From c020fa70439ec96e2fb24c891eb5f320a455eb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:21:53 +0100 Subject: [PATCH 2/2] =?UTF-8?q?[GEN-1859]=C2=A0Json=20Log=20Formatter=20to?= =?UTF-8?q?=20not=20be=20lowercase=20and=20add=20AuditService=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark LowerCaseJsonFormatter as obsolete to a new Json Formatter with caps. Logs were altered when caps are in, this is a legacy decision we are reverting now. Add comprehensive unit tests for AuditService covering various scenarios including successful/failed repository operations, different HTTP contexts, and edge cases. stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/474, branch: Jossec101/stack/17 --- src/Helpers/JobTypes.cs | 34 +-- src/Helpers/LowerCaseJsonFormatter.cs | 27 -- src/Program.cs | 5 +- .../Services/AuditServiceTests.cs | 263 ++++++++++++++++++ 4 files changed, 275 insertions(+), 54 deletions(-) delete mode 100644 src/Helpers/LowerCaseJsonFormatter.cs create mode 100644 test/NodeGuard.Tests/Services/AuditServiceTests.cs diff --git a/src/Helpers/JobTypes.cs b/src/Helpers/JobTypes.cs index ebe93881..71bfecdc 100644 --- a/src/Helpers/JobTypes.cs +++ b/src/Helpers/JobTypes.cs @@ -49,35 +49,20 @@ public static JobAndTrigger Create(JobDataMap data, string identitySuffix) wh public static async Task Reschedule(IScheduler scheduler, string identitySuffix) where T : IJob { - try - { - var triggerKey = new TriggerKey($"{typeof(T).Name}-{identitySuffix}"); - - var trigger = TriggerBuilder.Create() - .WithIdentity($"{typeof(T).Name}Trigger-{identitySuffix}") - .StartNow() - .Build(); - - await scheduler.RescheduleJob(triggerKey, trigger); + var triggerKey = new TriggerKey($"{typeof(T).Name}-{identitySuffix}"); - } - catch (Exception ex) - { - Console.WriteLine($"Unexpected error rescheduling job of type {typeof(T).Name} with identity suffix {identitySuffix}: {ex.Message}"); - } + var trigger = TriggerBuilder.Create() + .WithIdentity($"{typeof(T).Name}Trigger-{identitySuffix}") + .StartNow() + .Build(); + + await scheduler.RescheduleJob(triggerKey, trigger); } public static async Task DeleteJob(IScheduler scheduler, string identitySuffix) where T : IJob { - try - { - JobKey jobKey = new JobKey($"{typeof(T).Name}-{identitySuffix}"); - await scheduler.DeleteJob(jobKey); - } - catch (Exception ex) - { - Console.WriteLine($"Unexpected error occurred while removing the job of type {typeof(T).Name} with identity suffix {identitySuffix}: {ex.Message}"); - } + JobKey jobKey = new JobKey($"{typeof(T).Name}-{identitySuffix}"); + await scheduler.DeleteJob(jobKey); } public static async Task IsJobExists(IScheduler scheduler, string identitySuffix) where T : IJob @@ -181,7 +166,6 @@ public static async Task OnFail(IJobExecutionContext context, Func callbac var trigger = context.Trigger as SimpleTriggerImpl; - Console.WriteLine($"ChannelOpenJob-{trigger!.TimesTriggered} {intervals.Length}"); if (trigger!.TimesTriggered > intervals.Length) { await callback(); diff --git a/src/Helpers/LowerCaseJsonFormatter.cs b/src/Helpers/LowerCaseJsonFormatter.cs deleted file mode 100644 index 41a41fa1..00000000 --- a/src/Helpers/LowerCaseJsonFormatter.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Blazorise; -using Serilog.Events; -using Serilog.Formatting; -using Serilog.Formatting.Json; - -namespace NodeGuard.Helpers; - -/// -/// Custom formatter to lowercase the JSON output -/// -public class LowerCaseJsonFormatter : ITextFormatter -{ - private readonly JsonFormatter _formatter; - - public LowerCaseJsonFormatter() - { - _formatter = new JsonFormatter(); - } - - public void Format(LogEvent logEvent, TextWriter output) - { - using var sw = new StringWriter(); - _formatter.Format(logEvent, sw); - var json = sw.ToString().ToLower(); - output.WriteLine(json); - } -} diff --git a/src/Program.cs b/src/Program.cs index 039b4c94..c148ca9f 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -45,6 +45,8 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; using Npgsql; using Quartz.AspNetCore; +using Serilog.Formatting.Compact; +using Serilog.Formatting.Json; namespace NodeGuard { @@ -64,14 +66,13 @@ public static async Task Main(string[] args) var builder = WebApplication.CreateBuilder(args); - var jsonFormatter = new LowerCaseJsonFormatter(); Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("System", LogEventLevel.Warning) .Enrich.With(new DatadogLogEnricher()) - .WriteTo.Console(jsonFormatter) + .WriteTo.Console(new JsonFormatter()) .CreateLogger(); builder.Host.UseSerilog(); diff --git a/test/NodeGuard.Tests/Services/AuditServiceTests.cs b/test/NodeGuard.Tests/Services/AuditServiceTests.cs new file mode 100644 index 00000000..53238834 --- /dev/null +++ b/test/NodeGuard.Tests/Services/AuditServiceTests.cs @@ -0,0 +1,263 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Security.Claims; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Helpers; +using NodeGuard.Services; +using Xunit; + +namespace NodeGuard.Tests.Services; + +public class AuditServiceTests +{ + private readonly Mock _auditLogRepositoryMock; + private readonly Mock _httpContextAccessorMock; + private readonly Mock> _loggerMock; + + public AuditServiceTests() + { + _auditLogRepositoryMock = new Mock(); + _httpContextAccessorMock = new Mock(); + _loggerMock = new Mock>(); + } + + private AuditService CreateAuditService() + { + return new AuditService( + _auditLogRepositoryMock.Object, + _httpContextAccessorMock.Object, + _loggerMock.Object); + } + + private void SetupSuccessfulRepository() + { + _auditLogRepositoryMock + .Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((true, (string?)null)); + } + + private void SetupFailedRepository(string errorMessage = "Database error") + { + _auditLogRepositoryMock + .Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((false, errorMessage)); + } + + private DefaultHttpContext CreateHttpContextWithUser( + string userId = "user-123", + string? userName = "testuser", + string? email = null, + string? identityName = null, + string ipAddress = "192.168.1.100") + { + var httpContext = new DefaultHttpContext(); + + var claims = new List(); + if (userId != null) + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + if (userName != null) + claims.Add(new Claim(ClaimTypes.Name, userName)); + if (email != null) + claims.Add(new Claim(ClaimTypes.Email, email)); + + var identity = identityName != null + ? new ClaimsIdentity(claims, "TestAuth", identityName, null) + : new ClaimsIdentity(claims, "TestAuth"); + + httpContext.User = new ClaimsPrincipal(identity); + httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(ipAddress); + + return httpContext; + } + + #region LogAsync_FullParameters + + [Fact] + public async Task LogAsync_FullParameters_SuccessfulLogging_CallsRepositoryAndLogger() + { + // Arrange + SetupSuccessfulRepository(); + var service = CreateAuditService(); + var details = new { Operation = "Test", Value = 123 }; + + // Act + await service.LogAsync( + AuditActionType.Create, + AuditEventType.Success, + AuditObjectType.User, + "object-123", + "user-456", + "johndoe", + "10.0.0.1", + details); + + // Assert + _auditLogRepositoryMock.Verify( + x => x.AddAsync(It.Is(log => + log.ActionType == AuditActionType.Create && + log.EventType == AuditEventType.Success && + log.ObjectAffected == AuditObjectType.User && + log.ObjectId == "object-123" && + log.UserId == "user-456" && + log.Username == "johndoe" && + log.IpAddress == "10.0.0.1" && + log.Details != null)), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("AUDIT:")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task LogAsync_FullParameters_RepositoryFails_LogsErrorButDoesNotThrow() + { + // Arrange + SetupFailedRepository("Database connection failed"); + var service = CreateAuditService(); + + // Act + var act = async () => await service.LogAsync( + AuditActionType.Create, + AuditEventType.Failure, + AuditObjectType.User, + "user-123", + null); + + // Assert + await act.Should().NotThrowAsync(); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to persist audit log")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task LogAsync_FullParameters_ExceptionDuringLogging_CatchesAndLogsError() + { + // Arrange + var service = CreateAuditService(); + _auditLogRepositoryMock + .Setup(x => x.AddAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Database error")); + + // Act + var act = async () => await service.LogAsync( + AuditActionType.Create, + AuditEventType.Success, + AuditObjectType.User, + "user-123", + "user-456", + "johndoe", + "10.0.0.1", + new { Test = "data" }); + + // Assert + await act.Should().NotThrowAsync(); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Error logging audit event")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region LogAsync_AutoContext + + [Fact] + public async Task LogAsync_AutoContext_WithAuthenticatedUser_ExtractsUserInfoFromClaims() + { + // Arrange + SetupSuccessfulRepository(); + var httpContext = CreateHttpContextWithUser("user-123", "johndoe"); + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + var service = CreateAuditService(); + + // Act + await service.LogAsync( + AuditActionType.Create, + AuditEventType.Success, + AuditObjectType.Wallet, + "wallet-456", + new { Amount = 50000 }); + + // Assert + _auditLogRepositoryMock.Verify( + x => x.AddAsync(It.Is(log => + log.UserId == "user-123" && + log.Username == "johndoe" && + log.IpAddress == "192.168.1.100")), + Times.Once); + } + + #endregion + + #region LogSystemAsync + + [Fact] + public async Task LogSystemAsync_RepositoryFails_LogsErrorButDoesNotThrow() + { + // Arrange + SetupFailedRepository("Database timeout"); + var service = CreateAuditService(); + + // Act + var act = async () => await service.LogSystemAsync( + AuditActionType.Create, + AuditEventType.Success, + AuditObjectType.Wallet, + "wallet-123"); + + // Assert + await act.Should().NotThrowAsync(); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to persist audit log")), + null, + It.IsAny>()), + Times.Once); + } + + #endregion +}