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) 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 +}