From 733b9cf0d94d487bcc2af4d09a714971013f5f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:49:36 +0100 Subject: [PATCH 1/2] [GEN-1859] Audit logging system with enums and db migrations - Add AuditLog model with tracking for user actions and system events - Define AuditActionType, AuditEventType, and AuditObjectType enums - Include Entity Framework configuration and database migrations - Support for user identification, IP tracking, and detailed event logging TO MERGE Implement AuditService with structured logging, HTTP context extraction, and database persistence for tracking user actions, system events, and security-related operations across the NodeGuard application. stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/465, branch: Jossec101/stack/9 --- src/Data/ApplicationDbContext.cs | 2 + src/Data/Models/AuditLog.cs | 187 ++ src/Data/Repositories/AuditLogRepository.cs | 110 ++ .../Interfaces/IAuditLogRepository.cs | 48 + .../20260115151544_AuditLog.Designer.cs | 1524 +++++++++++++++++ src/Migrations/20260115151544_AuditLog.cs | 44 + .../ApplicationDbContextModelSnapshot.cs | 48 +- src/Program.cs | 18 + src/Services/AuditService.cs | 166 ++ src/Services/IAuditService.cs | 58 + 10 files changed, 2203 insertions(+), 2 deletions(-) create mode 100644 src/Data/Models/AuditLog.cs create mode 100644 src/Data/Repositories/AuditLogRepository.cs create mode 100644 src/Data/Repositories/Interfaces/IAuditLogRepository.cs create mode 100644 src/Migrations/20260115151544_AuditLog.Designer.cs create mode 100644 src/Migrations/20260115151544_AuditLog.cs create mode 100644 src/Services/AuditService.cs create mode 100644 src/Services/IAuditService.cs diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index f3171e4a..48366aef 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -121,5 +121,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ApiTokens { get; set; } public DbSet SwapOuts { get; set; } + + public DbSet AuditLogs { get; set; } } } diff --git a/src/Data/Models/AuditLog.cs b/src/Data/Models/AuditLog.cs new file mode 100644 index 00000000..8e85acb2 --- /dev/null +++ b/src/Data/Models/AuditLog.cs @@ -0,0 +1,187 @@ +/* + * 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.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NodeGuard.Data.Models; + +/// +/// Represents an audit log entry for tracking user actions and system events +/// +public class AuditLog +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// + /// Timestamp when the audit event occurred + /// + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + + /// + /// The type of action that was performed + /// + public AuditActionType ActionType { get; set; } + + /// + /// The result/outcome of the action + /// + public AuditEventType EventType { get; set; } + + /// + /// The ID of the user who performed the action (nullable for system actions) + /// + [MaxLength(450)] + public string? UserId { get; set; } + + /// + /// The username of the user who performed the action + /// + [MaxLength(256)] + public string? Username { get; set; } + + /// + /// The IP address from which the action was performed + /// + [MaxLength(45)] + public string? IpAddress { get; set; } + + /// + /// The type of object that was affected by the action + /// + public AuditObjectType ObjectAffected { get; set; } + + /// + /// The ID of the object that was affected (e.g., wallet ID, channel ID) + /// + [MaxLength(450)] + public string? ObjectId { get; set; } + + /// + /// Additional details about the action in JSON format + /// + public string? Details { get; set; } +} + +/// +/// Types of actions that can be audited +/// +public enum AuditActionType +{ + // CRUD Operations + Create, + Update, + Delete, + + // Approval/Rejection + Approve, + Reject, + Cancel, + + // Authentication + Login, + Logout, + TwoFactorLogin, + LoginWithRecoveryCode, + + // 2FA Management + TwoFactorEnabled, + TwoFactorDisabled, + TwoFactorReset, + GenerateRecoveryCodes, + + // Password Management + ChangePassword, + SetPassword, + ResetPassword, + + // User Management + LockUser, + UnlockUser, + UpdateRoles, + + // Wallet Operations + Transfer, + Import, + Finalise, + Rescan, + FreezeUTXO, + UnfreezeUTXO, + AddKey, + + // Channel Operations + Close, + ForceClose, + MarkAsClosed, + EnableLiquidityManagement, + DisableLiquidityManagement, + + // API Token Operations + Block, + Unblock, + + // Swap Operations + SwapOut, + SwapIn, + + // Node Operations + AddNode, + UpdateNode, + DeleteNode, + + // Internal Wallet + GenerateInternalWallet, + + // Withdrawal Operations + BumpFee, + + // Signing + Sign +} + +/// +/// Types of event outcomes +/// +public enum AuditEventType +{ + Success, + Failure, + Attempt +} + +/// +/// Types of objects that can be affected by audited actions +/// +public enum AuditObjectType +{ + User, + Wallet, + Channel, + ChannelOperationRequest, + WalletWithdrawalRequest, + Node, + APIToken, + SwapOut, + LiquidityRule, + Key, + UTXO, + InternalWallet, + Session +} diff --git a/src/Data/Repositories/AuditLogRepository.cs b/src/Data/Repositories/AuditLogRepository.cs new file mode 100644 index 00000000..103b9e19 --- /dev/null +++ b/src/Data/Repositories/AuditLogRepository.cs @@ -0,0 +1,110 @@ +/* + * 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 Microsoft.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class AuditLogRepository : IAuditLogRepository +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly ILogger _logger; + + public AuditLogRepository(IDbContextFactory dbContextFactory, ILogger logger) + { + _dbContextFactory = dbContextFactory; + _logger = logger; + } + + public async Task<(bool, string?)> AddAsync(AuditLog auditLog) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + try + { + auditLog.Timestamp = DateTimeOffset.UtcNow; + await dbContext.AuditLogs.AddAsync(auditLog); + await dbContext.SaveChangesAsync(); + return (true, null); + } + catch (Exception e) + { + _logger.LogError(e, "Error adding audit log entry"); + return (false, e.Message); + } + } + + public async Task<(List, int)> GetPaginatedAsync( + int page, + int pageSize, + AuditActionType? actionType = null, + AuditEventType? eventType = null, + AuditObjectType? objectType = null, + string? userId = null, + DateTimeOffset? fromDate = null, + DateTimeOffset? toDate = null) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + var query = dbContext.AuditLogs.AsQueryable(); + + if (actionType.HasValue) + query = query.Where(a => a.ActionType == actionType.Value); + + if (eventType.HasValue) + query = query.Where(a => a.EventType == eventType.Value); + + if (objectType.HasValue) + query = query.Where(a => a.ObjectAffected == objectType.Value); + + if (!string.IsNullOrEmpty(userId)) + query = query.Where(a => a.UserId == userId); + + if (fromDate.HasValue) + query = query.Where(a => a.Timestamp >= fromDate.Value); + + if (toDate.HasValue) + query = query.Where(a => a.Timestamp <= toDate.Value); + + var totalCount = await query.CountAsync(); + + var results = await query + .OrderByDescending(a => a.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (results, totalCount); + } + + public async Task DeleteOlderThanAsync(DateTimeOffset cutoffDate) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + var count = await dbContext.AuditLogs + .Where(a => a.Timestamp < cutoffDate) + .ExecuteDeleteAsync(); + + _logger.LogInformation("Deleted {Count} audit log entries older than {CutoffDate}", count, cutoffDate); + + return count; + } +} diff --git a/src/Data/Repositories/Interfaces/IAuditLogRepository.cs b/src/Data/Repositories/Interfaces/IAuditLogRepository.cs new file mode 100644 index 00000000..a9f1dc70 --- /dev/null +++ b/src/Data/Repositories/Interfaces/IAuditLogRepository.cs @@ -0,0 +1,48 @@ +/* + * 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 NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories.Interfaces; + +public interface IAuditLogRepository +{ + /// + /// Add a new audit log entry + /// + Task<(bool, string?)> AddAsync(AuditLog auditLog); + + /// + /// Get paginated audit logs with optional filtering + /// + Task<(List, int)> GetPaginatedAsync( + int page, + int pageSize, + AuditActionType? actionType = null, + AuditEventType? eventType = null, + AuditObjectType? objectType = null, + string? userId = null, + DateTimeOffset? fromDate = null, + DateTimeOffset? toDate = null); + + /// + /// Delete audit logs older than the specified date + /// + Task DeleteOlderThanAsync(DateTimeOffset cutoffDate); +} diff --git a/src/Migrations/20260115151544_AuditLog.Designer.cs b/src/Migrations/20260115151544_AuditLog.Designer.cs new file mode 100644 index 00000000..1b53e0ec --- /dev/null +++ b/src/Migrations/20260115151544_AuditLog.Designer.cs @@ -0,0 +1,1524 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260115151544_AuditLog")] + partial class AuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260115151544_AuditLog.cs b/src/Migrations/20260115151544_AuditLog.cs new file mode 100644 index 00000000..a2cac265 --- /dev/null +++ b/src/Migrations/20260115151544_AuditLog.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + ActionType = table.Column(type: "integer", nullable: false), + EventType = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + Username = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + IpAddress = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + ObjectAffected = table.Column(type: "integer", nullable: false), + ObjectId = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + Details = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index af708653..22278ade 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -203,7 +203,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); - b.HasDiscriminator("Discriminator").HasValue("IdentityUser"); + b.HasDiscriminator().HasValue("IdentityUser"); b.UseTphMappingStrategy(); }); @@ -332,6 +332,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApiTokens"); }); + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => { b.Property("Id") diff --git a/src/Program.cs b/src/Program.cs index 5873d7a9..cf4eaf77 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -95,6 +95,7 @@ public static async Task Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor().AddCircuitOptions(options => { options.DetailedErrors = true; }); + builder.Services.AddHttpContextAccessor(); //Dependency Injection builder.Services @@ -121,6 +122,7 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); @@ -139,6 +141,7 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddScoped(); //DbContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(Constants.POSTGRES_CONNECTIONSTRING); @@ -329,6 +332,21 @@ public static async Task Main(string[] args) } }); }); + + // Audit Log Cleanup Job + q.AddJob(opts => + { + opts.DisallowConcurrentExecution(); + opts.WithIdentity(nameof(AuditLogCleanupJob)); + }); + + q.AddTrigger(opts => + { + opts.ForJob(nameof(AuditLogCleanupJob)) + .WithIdentity($"{nameof(AuditLogCleanupJob)}Trigger") + .StartNow() + .WithCronSchedule(Constants.AUDIT_LOG_CLEANUP_CRON); + }); }); // ASP.NET Core hosting diff --git a/src/Services/AuditService.cs b/src/Services/AuditService.cs new file mode 100644 index 00000000..4a083d77 --- /dev/null +++ b/src/Services/AuditService.cs @@ -0,0 +1,166 @@ +/* + * 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 NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Helpers; + +namespace NodeGuard.Services; + +public class AuditService : IAuditService +{ + private readonly IAuditLogRepository _auditLogRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public AuditService( + IAuditLogRepository auditLogRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _auditLogRepository = auditLogRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + string? userId = null, + string? username = null, + string? ipAddress = null, + object? details = null) + { + try + { + var detailsJson = details != null ? JsonSerializer.Serialize(details) : null; + + // Log to system logger + _logger.LogInformation( + "AUDIT: Action={ActionType} Event={EventType} Object={ObjectType} ObjectId={ObjectId} User={Username} UserId={UserId} IP={IpAddress} Details={Details}", + actionType, + eventType, + objectAffected, + objectId ?? "N/A", + username ?? "N/A", + userId ?? "N/A", + ipAddress ?? "N/A", + detailsJson ?? "N/A"); + + var auditLog = new AuditLog + { + ActionType = actionType, + EventType = eventType, + ObjectAffected = objectAffected, + ObjectId = objectId, + UserId = userId, + Username = username, + IpAddress = ipAddress, + Details = detailsJson + }; + + var (success, error) = await _auditLogRepository.AddAsync(auditLog); + + if (!success) + { + _logger.LogError("Failed to persist audit log to database: {Error}", error); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error logging audit event"); + } + } + + public async Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null) + { + var (userId, username, ipAddress) = ExtractContextInfo(); + + await LogAsync( + actionType, + eventType, + objectAffected, + objectId, + userId, + username, + ipAddress, + details); + } + + public async Task LogSystemAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null) + { + await LogAsync( + actionType, + eventType, + objectAffected, + objectId, + null, // No user ID for system operations + "SYSTEM", + null, // No IP address for system operations + details); + } + + private (string? UserId, string? Username, string? IpAddress) ExtractContextInfo() + { + string? userId = null; + string? username = null; + string? ipAddress = null; + + try + { + var httpContext = _httpContextAccessor.HttpContext; + + if (httpContext != null) + { + // Extract user info from claims + var user = httpContext.User; + if (user?.Identity?.IsAuthenticated == true) + { + userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + username = user.FindFirst(ClaimTypes.Name)?.Value + ?? user.FindFirst(ClaimTypes.Email)?.Value + ?? user.Identity.Name; + } + + // Extract IP address (considering proxies) + ipAddress = httpContext.GetClientIpAddress(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error extracting context info for audit log"); + } + + return (userId, username, ipAddress); + } +} diff --git a/src/Services/IAuditService.cs b/src/Services/IAuditService.cs new file mode 100644 index 00000000..abd9879d --- /dev/null +++ b/src/Services/IAuditService.cs @@ -0,0 +1,58 @@ +/* + * 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 NodeGuard.Data.Models; + +namespace NodeGuard.Services; + +public interface IAuditService +{ + /// + /// Log an audit event with all required fields + /// + Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + string? userId = null, + string? username = null, + string? ipAddress = null, + object? details = null); + + /// + /// Log an audit event using HttpContext for user and IP extraction + /// + Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null); + + /// + /// Log an audit event from a system process (e.g., cron job) without HTTP context + /// + Task LogSystemAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null); +} From ae3b43ff80ba95a7d248ca02c46a6019248088c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:16:54 +0100 Subject: [PATCH 2/2] [GEN-1859] Add HttpContext extension for retrieving client IP address and create Audit Trail page with filtering options stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/466, branch: Jossec101/stack/10 --- .../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 +- src/Helpers/HttpContextExtensions.cs | 60 ++++++ .../Helpers/HttpContextExtensionsTests.cs | 184 ++++++++++++++++++ 7 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 src/Helpers/HttpContextExtensions.cs create mode 100644 test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs 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/HttpContextExtensions.cs b/src/Helpers/HttpContextExtensions.cs new file mode 100644 index 00000000..6331d5de --- /dev/null +++ b/src/Helpers/HttpContextExtensions.cs @@ -0,0 +1,60 @@ +/* + * 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/. + * + */ + +namespace NodeGuard.Helpers; + +/// +/// Extension methods for HttpContext +/// +public static class HttpContextExtensions +{ + /// + /// Gets the client IP address from the HTTP context, considering proxies and load balancers. + /// Checks X-Forwarded-For and X-Real-IP headers before falling back to RemoteIpAddress. + /// + /// The HTTP context + /// The client IP address or null if not available + public static string? GetClientIpAddress(this HttpContext? httpContext) + { + if (httpContext == null) return null; + + // Check for forwarded IP (when behind a proxy/load balancer) + var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + + if (!string.IsNullOrEmpty(forwardedFor)) + { + // X-Forwarded-For can contain multiple IPs, take the first one (client IP) + var ip = forwardedFor.Split(',').FirstOrDefault()?.Trim(); + if (!string.IsNullOrEmpty(ip)) + { + return ip; + } + } + + // Check X-Real-IP header + var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(realIp)) + { + return realIp; + } + + // Fall back to remote IP address + return httpContext.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs b/test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs new file mode 100644 index 00000000..e640d7d4 --- /dev/null +++ b/test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs @@ -0,0 +1,184 @@ +/* + * 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.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NodeGuard.Helpers; + +namespace NodeGuard.Tests.Helpers; + +public class HttpContextExtensionsTests +{ + [Fact] + public void GetClientIpAddress_NullHttpContext_ReturnsNull() + { + // Arrange + HttpContext? httpContext = null; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetClientIpAddress_XForwardedForHeader_ReturnsFirstIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "192.168.1.100"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_XForwardedForHeaderWithMultipleIps_ReturnsFirstIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "192.168.1.100, 10.0.0.1, 172.16.0.1"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_XForwardedForHeaderWithSpaces_ReturnsTrimmedIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = " 192.168.1.100 , 10.0.0.1"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_XRealIpHeader_ReturnsIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Real-IP"] = "10.0.0.50"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("10.0.0.50"); + } + + [Fact] + public void GetClientIpAddress_BothHeaders_PrefersXForwardedFor() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "192.168.1.100"; + httpContext.Request.Headers["X-Real-IP"] = "10.0.0.50"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_RemoteIpAddressFallback_ReturnsRemoteIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.50"); + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("203.0.113.50"); + } + + [Fact] + public void GetClientIpAddress_EmptyXForwardedFor_FallsBackToXRealIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = ""; + httpContext.Request.Headers["X-Real-IP"] = "10.0.0.50"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("10.0.0.50"); + } + + [Fact] + public void GetClientIpAddress_EmptyHeaders_FallsBackToRemoteIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = ""; + httpContext.Request.Headers["X-Real-IP"] = ""; + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("172.16.0.100"); + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("172.16.0.100"); + } + + [Fact] + public void GetClientIpAddress_NoHeadersNoRemoteIp_ReturnsNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetClientIpAddress_IPv6Address_ReturnsIpv6() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + } +}