diff --git a/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs b/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs new file mode 100644 index 00000000..9febbf0b --- /dev/null +++ b/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs @@ -0,0 +1,192 @@ +using System.Net; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Cleanuparr.Infrastructure.Extensions; +using Cleanuparr.Persistence; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Cleanuparr.Api.Auth; + +public static class TrustedNetworkAuthenticationDefaults +{ + public const string AuthenticationScheme = "TrustedNetwork"; +} + +public class TrustedNetworkAuthenticationHandler : AuthenticationHandler +{ + public TrustedNetworkAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override async Task HandleAuthenticateAsync() + { + // Load auth config from database + await using var dataContext = DataContext.CreateStaticInstance(); + var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync(); + + if (config is null || !config.Auth.DisableAuthForLocalAddresses) + { + return AuthenticateResult.NoResult(); + } + + // Determine client IP + var clientIp = GetClientIp(config.Auth.TrustForwardedHeaders); + if (clientIp is null) + { + return AuthenticateResult.NoResult(); + } + + // Check if the client IP is trusted + if (!IsTrustedAddress(clientIp, config.Auth.TrustedNetworks)) + { + return AuthenticateResult.NoResult(); + } + + // Load the admin user + await using var usersContext = UsersContext.CreateStaticInstance(); + var user = await usersContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.SetupCompleted); + + if (user is null) + { + return AuthenticateResult.NoResult(); + } + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim("auth_method", "trusted_network") + }; + + var identity = new ClaimsIdentity(claims, TrustedNetworkAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, TrustedNetworkAuthenticationDefaults.AuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } + + private IPAddress? GetClientIp(bool trustForwardedHeaders) => + ResolveClientIp(Context, trustForwardedHeaders); + + public static IPAddress? ResolveClientIp(HttpContext httpContext, bool trustForwardedHeaders) + { + var remoteIp = httpContext.Connection.RemoteIpAddress; + if (remoteIp is null) + { + return null; + } + + // Only trust forwarded headers if the direct connection is from a local address + if (trustForwardedHeaders && remoteIp.IsLocalAddress()) + { + // Check X-Forwarded-For first, then X-Real-IP + var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrEmpty(forwardedFor)) + { + // X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2 + // The first one is the original client + var firstIp = forwardedFor.Split(',')[0].Trim(); + if (IPAddress.TryParse(firstIp, out var parsedIp)) + { + return parsedIp; + } + } + + var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out var realParsedIp)) + { + return realParsedIp; + } + } + + return remoteIp; + } + + public static bool IsTrustedAddress(IPAddress clientIp, List trustedNetworks) + { + // Normalize IPv4-mapped IPv6 addresses + if (clientIp.IsIPv4MappedToIPv6) + { + clientIp = clientIp.MapToIPv4(); + } + + // Check if it's a local address (built-in ranges) + if (clientIp.IsLocalAddress()) + { + return true; + } + + // Check against custom trusted networks + foreach (var network in trustedNetworks) + { + if (MatchesCidr(clientIp, network)) + { + return true; + } + } + + return false; + } + + public static bool MatchesCidr(IPAddress address, string cidr) + { + if (cidr.Contains('/')) + { + var parts = cidr.Split('/'); + if (!IPAddress.TryParse(parts[0], out var networkAddress) || + !int.TryParse(parts[1], out var prefixLength)) + { + return false; + } + + // Normalize both addresses + if (networkAddress.IsIPv4MappedToIPv6) + networkAddress = networkAddress.MapToIPv4(); + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + + // Must be same address family + if (address.AddressFamily != networkAddress.AddressFamily) + return false; + + var addressBytes = address.GetAddressBytes(); + var networkBytes = networkAddress.GetAddressBytes(); + + // Compare bytes up to prefix length + var fullBytes = prefixLength / 8; + var remainingBits = prefixLength % 8; + + for (var i = 0; i < fullBytes && i < addressBytes.Length; i++) + { + if (addressBytes[i] != networkBytes[i]) + return false; + } + + if (remainingBits > 0 && fullBytes < addressBytes.Length) + { + var mask = (byte)(0xFF << (8 - remainingBits)); + if ((addressBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask)) + return false; + } + + return true; + } + + // Plain IP match + if (!IPAddress.TryParse(cidr, out var singleIp)) + return false; + + if (singleIp.IsIPv4MappedToIPv6) + singleIp = singleIp.MapToIPv4(); + + return address.Equals(singleIp); + } +} diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs index 6e8aaece..2308ce7c 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs @@ -29,7 +29,16 @@ public static class AuthDI return ApiKeyAuthenticationDefaults.AuthenticationScheme; } - return JwtBearerDefaults.AuthenticationScheme; + // Check for Bearer token or SignalR access_token + if (context.Request.Headers.ContainsKey("Authorization") || + (context.Request.Path.StartsWithSegments("/api/hubs") && + context.Request.Query.ContainsKey("access_token"))) + { + return JwtBearerDefaults.AuthenticationScheme; + } + + // Fall through to trusted network handler (returns NoResult if disabled) + return TrustedNetworkAuthenticationDefaults.AuthenticationScheme; }; }) .AddJwtBearer(options => @@ -64,7 +73,9 @@ public static class AuthDI }; }) .AddScheme( - ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { }); + ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { }) + .AddScheme( + TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { }); services.AddAuthorization(options => { diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/Disable2faRequest.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/Disable2faRequest.cs new file mode 100644 index 00000000..fceb157d --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/Disable2faRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Cleanuparr.Api.Features.Auth.Contracts.Requests; + +public sealed record Disable2faRequest +{ + [Required] + public required string Password { get; init; } + + [Required] + [StringLength(6, MinimumLength = 6)] + public required string TotpCode { get; init; } +} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/Enable2faRequest.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/Enable2faRequest.cs new file mode 100644 index 00000000..95d349b8 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Requests/Enable2faRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Cleanuparr.Api.Features.Auth.Contracts.Requests; + +public sealed record Enable2faRequest +{ + [Required] + public required string Password { get; init; } +} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs index 4a461630..d8e75d84 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/AuthStatusResponse.cs @@ -4,4 +4,5 @@ public sealed record AuthStatusResponse { public required bool SetupCompleted { get; init; } public bool PlexLinked { get; init; } + public bool AuthBypassActive { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/LoginResponse.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/LoginResponse.cs index 736f486e..737db7fd 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/LoginResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/LoginResponse.cs @@ -4,4 +4,5 @@ public sealed record LoginResponse { public required bool RequiresTwoFactor { get; init; } public string? LoginToken { get; init; } + public TokenResponse? Tokens { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs index c83f8a38..dd9ec8a3 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AccountController.cs @@ -139,6 +139,145 @@ public sealed class AccountController : ControllerBase } } + [HttpPost("2fa/enable")] + public async Task Enable2fa([FromBody] Enable2faRequest request) + { + await UsersContext.Lock.WaitAsync(); + try + { + var user = await GetCurrentUser(includeRecoveryCodes: true); + if (user is null) return Unauthorized(); + + if (user.TotpEnabled) + { + return Conflict(new { error = "2FA is already enabled" }); + } + + if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash)) + { + return BadRequest(new { error = "Incorrect password" }); + } + + // Generate new TOTP + var secret = _totpService.GenerateSecret(); + var qrUri = _totpService.GetQrCodeUri(secret, user.Username); + var recoveryCodes = _totpService.GenerateRecoveryCodes(); + + user.TotpSecret = secret; + user.UpdatedAt = DateTime.UtcNow; + + // Replace any existing recovery codes + _usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes); + + foreach (var code in recoveryCodes) + { + _usersContext.RecoveryCodes.Add(new RecoveryCode + { + Id = Guid.NewGuid(), + UserId = user.Id, + CodeHash = _totpService.HashRecoveryCode(code), + IsUsed = false + }); + } + + await _usersContext.SaveChangesAsync(); + + _logger.LogInformation("2FA setup generated for user {Username}", user.Username); + + return Ok(new TotpSetupResponse + { + Secret = secret, + QrCodeUri = qrUri, + RecoveryCodes = recoveryCodes + }); + } + finally + { + UsersContext.Lock.Release(); + } + } + + [HttpPost("2fa/enable/verify")] + public async Task VerifyEnable2fa([FromBody] VerifyTotpRequest request) + { + await UsersContext.Lock.WaitAsync(); + try + { + var user = await GetCurrentUser(); + if (user is null) return Unauthorized(); + + if (user.TotpEnabled) + { + return Conflict(new { error = "2FA is already enabled" }); + } + + if (string.IsNullOrEmpty(user.TotpSecret)) + { + return BadRequest(new { error = "Generate 2FA setup first" }); + } + + if (!_totpService.ValidateCode(user.TotpSecret, request.Code)) + { + return BadRequest(new { error = "Invalid verification code" }); + } + + user.TotpEnabled = true; + user.UpdatedAt = DateTime.UtcNow; + await _usersContext.SaveChangesAsync(); + + _logger.LogInformation("2FA enabled for user {Username}", user.Username); + + return Ok(new { message = "2FA enabled" }); + } + finally + { + UsersContext.Lock.Release(); + } + } + + [HttpPost("2fa/disable")] + public async Task Disable2fa([FromBody] Disable2faRequest request) + { + await UsersContext.Lock.WaitAsync(); + try + { + var user = await GetCurrentUser(includeRecoveryCodes: true); + if (user is null) return Unauthorized(); + + if (!user.TotpEnabled) + { + return BadRequest(new { error = "2FA is not enabled" }); + } + + if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash)) + { + return BadRequest(new { error = "Incorrect password" }); + } + + if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode)) + { + return BadRequest(new { error = "Invalid 2FA code" }); + } + + user.TotpEnabled = false; + user.TotpSecret = string.Empty; + user.UpdatedAt = DateTime.UtcNow; + + // Remove all recovery codes + _usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes); + + await _usersContext.SaveChangesAsync(); + + _logger.LogInformation("2FA disabled for user {Username}", user.Username); + + return Ok(new { message = "2FA disabled" }); + } + finally + { + UsersContext.Lock.Release(); + } + } + [HttpGet("api-key")] public async Task GetApiKey() { diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs index 6ec1f9c5..02eaa9f4 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Controllers/AuthController.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using Cleanuparr.Api.Auth; using Cleanuparr.Api.Features.Auth.Contracts.Requests; using Cleanuparr.Api.Features.Auth.Contracts.Responses; using Cleanuparr.Infrastructure.Features.Auth; @@ -43,10 +44,25 @@ public sealed class AuthController : ControllerBase { var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync(); + var authBypass = false; + await using var dataContext = DataContext.CreateStaticInstance(); + var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync(); + if (generalConfig is { Auth.DisableAuthForLocalAddresses: true }) + { + var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp( + HttpContext, generalConfig.Auth.TrustForwardedHeaders); + if (clientIp is not null) + { + authBypass = TrustedNetworkAuthenticationHandler.IsTrustedAddress( + clientIp, generalConfig.Auth.TrustedNetworks); + } + } + return Ok(new AuthStatusResponse { SetupCompleted = user is { SetupCompleted: true }, - PlexLinked = user?.PlexAccountId is not null + PlexLinked = user?.PlexAccountId is not null, + AuthBypassActive = authBypass }); } @@ -196,11 +212,6 @@ public sealed class AuthController : ControllerBase return BadRequest(new { error = "Create an account first" }); } - if (!user.TotpEnabled) - { - return BadRequest(new { error = "2FA must be configured before completing setup" }); - } - user.SetupCompleted = true; user.UpdatedAt = DateTime.UtcNow; await _usersContext.SaveChangesAsync(); @@ -242,6 +253,22 @@ public sealed class AuthController : ControllerBase // Reset failed attempts on successful password verification await ResetFailedAttempts(user.Id); + // If 2FA is not enabled, issue tokens directly + if (!user.TotpEnabled) + { + // Re-fetch with tracking since the query above used AsNoTracking + var trackedUser = await _usersContext.Users.FirstAsync(u => u.Id == user.Id); + var tokenResponse = await GenerateTokenResponse(trackedUser); + + _logger.LogInformation("User {Username} logged in (2FA disabled)", user.Username); + + return Ok(new LoginResponse + { + RequiresTwoFactor = false, + Tokens = tokenResponse + }); + } + // Password valid - require 2FA var loginToken = _jwtService.GenerateLoginToken(user.Id); diff --git a/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs index 6c5570f7..d89d992b 100644 --- a/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/General/Contracts/Requests/UpdateGeneralConfigRequest.cs @@ -34,6 +34,8 @@ public sealed record UpdateGeneralConfigRequest public UpdateLoggingConfigRequest Log { get; init; } = new(); + public UpdateAuthConfigRequest Auth { get; init; } = new(); + public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger) { existingConfig.DisplaySupportBanner = DisplaySupportBanner; @@ -49,6 +51,7 @@ public sealed record UpdateGeneralConfigRequest existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours; bool loggingChanged = Log.ApplyTo(existingConfig.Log); + Auth.ApplyTo(existingConfig.Auth); Validate(existingConfig); @@ -75,6 +78,7 @@ public sealed record UpdateGeneralConfigRequest } config.Log.Validate(); + config.Auth.Validate(); } private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged) @@ -145,3 +149,19 @@ public sealed record UpdateLoggingConfigRequest public bool LevelOnlyChange { get; private set; } } + +public sealed record UpdateAuthConfigRequest +{ + public bool DisableAuthForLocalAddresses { get; init; } + + public bool TrustForwardedHeaders { get; init; } + + public List TrustedNetworks { get; init; } = []; + + public void ApplyTo(AuthConfig existingConfig) + { + existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses; + existingConfig.TrustForwardedHeaders = TrustForwardedHeaders; + existingConfig.TrustedNetworks = TrustedNetworks; + } +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 37905e15..90e78561 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -88,11 +88,20 @@ public class DataContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => + { entity.ComplexProperty(e => e.Log, cp => { cp.Property(l => l.Level).HasConversion>(); - }) - ); + }); + + entity.ComplexProperty(e => e.Auth, cp => + { + cp.Property(a => a.TrustedNetworks) + .HasConversion( + v => string.Join(',', v), + v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); + }); + }); modelBuilder.Entity(entity => { diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.Designer.cs new file mode 100644 index 00000000..30203502 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.Designer.cs @@ -0,0 +1,1304 @@ +ο»Ώ// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260223085409_AddAuthConfig")] + partial class AddAuthConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.PrimitiveCollection("UnlinkedIgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dirs"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.HasKey("Id") + .HasName("pk_seeding_rules"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_seeding_rules_download_cleaner_config_id"); + + b.ToTable("seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.cs new file mode 100644 index 00000000..500e8e2c --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.cs @@ -0,0 +1,51 @@ +ο»Ώusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddAuthConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "auth_disable_auth_for_local_addresses", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "auth_trust_forwarded_headers", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "auth_trusted_networks", + table: "general_configs", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "auth_disable_auth_for_local_addresses", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "auth_trust_forwarded_headers", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "auth_trusted_networks", + table: "general_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 28896e1a..a9fa7afe 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -319,6 +319,24 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("INTEGER") .HasColumnName("strike_inactivity_window_hours"); + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => { b1.IsRequired(); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/AuthConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/AuthConfig.cs new file mode 100644 index 00000000..9a82efa1 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/AuthConfig.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Net; +using Cleanuparr.Domain.Exceptions; + +namespace Cleanuparr.Persistence.Models.Configuration.General; + +[ComplexType] +public sealed record AuthConfig : IConfig +{ + public bool DisableAuthForLocalAddresses { get; set; } + + public bool TrustForwardedHeaders { get; set; } + + public List TrustedNetworks { get; set; } = []; + + public void Validate() + { + foreach (var entry in TrustedNetworks) + { + if (!IsValidIpOrCidr(entry)) + { + throw new ValidationException($"Invalid IP address or CIDR range: {entry}"); + } + } + } + + private static bool IsValidIpOrCidr(string value) + { + // CIDR notation: 192.168.1.0/24 + if (value.Contains('/')) + { + var parts = value.Split('/'); + if (parts.Length != 2) return false; + + if (!IPAddress.TryParse(parts[0], out _)) return false; + if (!int.TryParse(parts[1], out var prefix)) return false; + + // IPv4: 0-32, IPv6: 0-128 + var maxPrefix = parts[0].Contains(':') ? 128 : 32; + return prefix >= 0 && prefix <= maxPrefix; + } + + // Plain IP address + return IPAddress.TryParse(value, out _); + } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs index 3278f846..9cc966cd 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs @@ -36,6 +36,8 @@ public sealed record GeneralConfig : IConfig public LoggingConfig Log { get; set; } = new(); + public AuthConfig Auth { get; set; } = new(); + public void Validate() { if (HttpTimeout is 0) @@ -49,5 +51,6 @@ public sealed record GeneralConfig : IConfig } Log.Validate(); + Auth.Validate(); } } \ No newline at end of file diff --git a/code/frontend/src/app/core/api/account.api.ts b/code/frontend/src/app/core/api/account.api.ts index 9b7d786e..44714a9e 100644 --- a/code/frontend/src/app/core/api/account.api.ts +++ b/code/frontend/src/app/core/api/account.api.ts @@ -52,6 +52,18 @@ export class AccountApi { return this.http.post('/api/account/2fa/regenerate', request); } + enable2fa(password: string): Observable { + return this.http.post('/api/account/2fa/enable', { password }); + } + + verifyEnable2fa(code: string): Observable { + return this.http.post('/api/account/2fa/enable/verify', { code }); + } + + disable2fa(password: string, totpCode: string): Observable { + return this.http.post('/api/account/2fa/disable', { password, totpCode }); + } + getApiKey(): Observable<{ apiKey: string }> { return this.http.get<{ apiKey: string }>('/api/account/api-key'); } diff --git a/code/frontend/src/app/core/auth/auth.service.ts b/code/frontend/src/app/core/auth/auth.service.ts index 1ac2850c..1b5c1075 100644 --- a/code/frontend/src/app/core/auth/auth.service.ts +++ b/code/frontend/src/app/core/auth/auth.service.ts @@ -6,11 +6,13 @@ import { Router } from '@angular/router'; export interface AuthStatus { setupCompleted: boolean; plexLinked: boolean; + authBypassActive?: boolean; } export interface LoginResponse { requiresTwoFactor: boolean; loginToken?: string; + tokens?: TokenResponse; } export interface TokenResponse { @@ -60,6 +62,13 @@ export class AuthService { this._isSetupComplete.set(status.setupCompleted); this._plexLinked.set(status.plexLinked); + // Trusted network bypass β€” no tokens needed + if (status.authBypassActive && status.setupCompleted) { + this._isAuthenticated.set(true); + this._isLoading.set(false); + return; + } + const token = localStorage.getItem('access_token'); if (token && status.setupCompleted) { if (this.isTokenExpired(60)) { @@ -112,7 +121,13 @@ export class AuthService { // Login flow login(username: string, password: string): Observable { - return this.http.post('/api/auth/login', { username, password }); + return this.http.post('/api/auth/login', { username, password }).pipe( + tap((response) => { + if (!response.requiresTwoFactor && response.tokens) { + this.handleTokens(response.tokens); + } + }), + ); } verify2fa(loginToken: string, code: string, isRecoveryCode = false): Observable { diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 74bcc08c..05fc630e 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -59,6 +59,9 @@ export class DocumentationService { 'log.archiveTimeLimitHours': 'archive-time-limit', 'ignoredDownloads': 'ignored-downloads', 'strikeInactivityWindowHours': 'strike-inactivity-window', + 'auth.disableLocalAuth': 'disable-auth-for-local-addresses', + 'auth.trustForwardedHeaders': 'trust-forwarded-headers', + 'auth.trustedNetworks': 'additional-trusted-networks', }, 'download-cleaner': { 'enabled': 'enable-download-cleaner', diff --git a/code/frontend/src/app/features/auth/login/login.component.ts b/code/frontend/src/app/features/auth/login/login.component.ts index 0627fd27..e1f20e52 100644 --- a/code/frontend/src/app/features/auth/login/login.component.ts +++ b/code/frontend/src/app/features/auth/login/login.component.ts @@ -83,6 +83,9 @@ export class LoginComponent implements OnInit, OnDestroy { if (result.requiresTwoFactor && result.loginToken) { this.loginToken.set(result.loginToken); this.view.set('2fa'); + } else if (!result.requiresTwoFactor) { + // 2FA not enabled β€” tokens already handled by AuthService + this.router.navigate(['/dashboard']); } this.loading.set(false); }, diff --git a/code/frontend/src/app/features/auth/setup/setup.component.html b/code/frontend/src/app/features/auth/setup/setup.component.html index b6baab54..3ade4d4d 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.html +++ b/code/frontend/src/app/features/auth/setup/setup.component.html @@ -144,6 +144,8 @@ Verify Code + + } } @else {

diff --git a/code/frontend/src/app/features/auth/setup/setup.component.scss b/code/frontend/src/app/features/auth/setup/setup.component.scss index 6b1f948e..b79d9352 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.scss +++ b/code/frontend/src/app/features/auth/setup/setup.component.scss @@ -372,3 +372,20 @@ justify-content: flex-end; margin-top: var(--space-4); } + +.skip-link { + display: block; + background: none; + border: none; + color: var(--text-tertiary); + font-family: var(--font-family); + font-size: var(--font-size-sm); + cursor: pointer; + text-align: center; + margin-top: var(--space-2); + padding: var(--space-1); + + &:hover { + color: var(--text-secondary); + } +} diff --git a/code/frontend/src/app/features/auth/setup/setup.component.ts b/code/frontend/src/app/features/auth/setup/setup.component.ts index 8a582f63..eb0dfeda 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.ts +++ b/code/frontend/src/app/features/auth/setup/setup.component.ts @@ -139,6 +139,11 @@ export class SetupComponent implements OnDestroy { }); } + skip2fa(): void { + this.currentStep.set(3); + this.error.set(''); + } + goToStep3(): void { this.currentStep.set(3); this.error.set(''); diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.html b/code/frontend/src/app/features/settings/account/account-settings.component.html index ee916274..812beaa5 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.html +++ b/code/frontend/src/app/features/settings/account/account-settings.component.html @@ -73,68 +73,167 @@

Status - Active + @if (account()!.twoFactorEnabled) { + Active + } @else { + Disabled + }
- @if (newRecoveryCodes().length > 0) { -
-

New Authenticator Setup

-

Scan this QR code with your authenticator app to complete the setup.

-
-
- -
-
- Can't scan? Enter manually -
-

Secret key:

- {{ newTotpSecret() }} + @if (account()!.twoFactorEnabled) { + + @if (newRecoveryCodes().length > 0) { +
+

New Authenticator Setup

+

Scan this QR code with your authenticator app to complete the setup.

+
+
+
-
+
+ Can't scan? Enter manually +
+

Secret key:

+ {{ newTotpSecret() }} +
+
+
+
+

New Recovery Codes

+

Save these codes in a secure location. Each code can only be used once.

+
+ @for (code of newRecoveryCodes(); track code) { +
{{ code }}
+ } +
+
+ Copy Codes + Dismiss +
+ } @else {
-

New Recovery Codes

-

Save these codes in a secure location. Each code can only be used once.

-
- @for (code of newRecoveryCodes(); track code) { -
{{ code }}
- } +

To regenerate your 2FA, enter your current password and a valid authenticator code.

+ + +
+ + @if (regenerating2fa()) { + Regenerating... + } @else { + Regenerate 2FA + } + + + @if (disabling2fa()) { + Disabling... + } @else { + Disable 2FA + } +
-
- Copy Codes - Dismiss -
-
+ } } @else { -
-

To regenerate your 2FA, enter your current password and a valid authenticator code.

- - -
- - @if (regenerating2fa()) { - Regenerating... - } @else { - Regenerate 2FA + + @if (enableSetup()) { + +
+

Set Up Authenticator

+

Scan this QR code with your authenticator app.

+
+
+ +
+
+ Can't scan? Enter manually +
+

Secret key:

+ {{ newTotpSecret() }} +
+
+
+ + @if (newRecoveryCodes().length > 0) { +
+

Recovery Codes

+

Save these codes in a secure location. Each code can only be used once.

+
+ @for (code of newRecoveryCodes(); track code) { +
{{ code }}
+ } +
+
+ Copy Codes +
} - -
+ +
+ +
+ + @if (enabling2fa()) { + Verifying... + } @else { + Verify & Enable 2FA + } + + Cancel +
+
+ } @else { +
+

Two-factor authentication adds an extra layer of security to your account.

+ +
+ + @if (enabling2fa()) { + Setting up... + } @else { + Enable 2FA + } + +
+ } }
diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.ts b/code/frontend/src/app/features/settings/account/account-settings.component.ts index 79a51779..c4b9c674 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.ts +++ b/code/frontend/src/app/features/settings/account/account-settings.component.ts @@ -59,6 +59,15 @@ export class AccountSettingsComponent implements OnInit, OnDestroy { readonly newQrCodeUri = signal(''); readonly newTotpSecret = signal(''); + // 2FA enable + readonly enablePassword = signal(''); + readonly enableVerificationCode = signal(''); + readonly enabling2fa = signal(false); + readonly enableSetup = signal(false); + + // 2FA disable + readonly disabling2fa = signal(false); + // API key readonly apiKey = signal(''); readonly apiKeyRevealed = signal(false); @@ -172,6 +181,75 @@ export class AccountSettingsComponent implements OnInit, OnDestroy { this.newTotpSecret.set(''); } + // 2FA enable flow + startEnable2fa(): void { + this.enabling2fa.set(true); + this.api.enable2fa(this.enablePassword()).subscribe({ + next: (result) => { + this.newQrCodeUri.set(result.qrCodeUri); + this.newTotpSecret.set(result.secret); + this.newRecoveryCodes.set(result.recoveryCodes); + this.enableSetup.set(true); + this.enabling2fa.set(false); + }, + error: () => { + this.toast.error('Failed to start 2FA setup. Check your password.'); + this.enabling2fa.set(false); + }, + }); + } + + verifyEnable2fa(): void { + this.enabling2fa.set(true); + this.api.verifyEnable2fa(this.enableVerificationCode()).subscribe({ + next: () => { + this.toast.success('Two-factor authentication enabled'); + this.cancelEnable2fa(); + this.enabling2fa.set(false); + this.loadAccount(); + }, + error: () => { + this.toast.error('Invalid verification code'); + this.enabling2fa.set(false); + }, + }); + } + + cancelEnable2fa(): void { + this.enableSetup.set(false); + this.enablePassword.set(''); + this.enableVerificationCode.set(''); + this.newRecoveryCodes.set([]); + this.newQrCodeUri.set(''); + this.newTotpSecret.set(''); + } + + // 2FA disable flow + async confirmDisable2fa(): Promise { + const confirmed = await this.confirmService.confirm({ + title: 'Disable 2FA', + message: 'This will remove two-factor authentication from your account. Your recovery codes will be deleted.', + confirmLabel: 'Disable', + destructive: true, + }); + if (!confirmed) return; + + this.disabling2fa.set(true); + this.api.disable2fa(this.twoFaPassword(), this.twoFaCode()).subscribe({ + next: () => { + this.toast.success('Two-factor authentication disabled'); + this.twoFaPassword.set(''); + this.twoFaCode.set(''); + this.disabling2fa.set(false); + this.loadAccount(); + }, + error: () => { + this.toast.error('Failed to disable 2FA. Check your password and code.'); + this.disabling2fa.set(false); + }, + }); + } + // API key revealApiKey(): void { if (this.apiKeyRevealed()) { diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.html b/code/frontend/src/app/features/settings/general/general-settings.component.html index 88a16b07..e048d3af 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.html +++ b/code/frontend/src/app/features/settings/general/general-settings.component.html @@ -41,6 +41,26 @@ + +
+ + @if (authDisableLocalAuth()) { + + + } +
+
+
diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.ts b/code/frontend/src/app/features/settings/general/general-settings.component.ts index dff26bc2..d5a5c179 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.ts +++ b/code/frontend/src/app/features/settings/general/general-settings.component.ts @@ -69,6 +69,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { readonly strikeInactivityWindowHours = signal(24); readonly purgingStrikes = signal(false); + // Auth + readonly authDisableLocalAuth = signal(false); + readonly authTrustForwardedHeaders = signal(false); + readonly authTrustedNetworks = signal([]); + // Logging readonly logLevel = signal(LogEventLevel.Information); readonly logRollingSizeMB = signal(10); @@ -182,6 +187,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { this.statusCheckEnabled.set(config.statusCheckEnabled); this.ignoredDownloads.set(config.ignoredDownloads ?? []); this.strikeInactivityWindowHours.set(config.strikeInactivityWindowHours); + if (config.auth) { + this.authDisableLocalAuth.set(config.auth.disableAuthForLocalAddresses); + this.authTrustForwardedHeaders.set(config.auth.trustForwardedHeaders); + this.authTrustedNetworks.set(config.auth.trustedNetworks ?? []); + } if (config.log) { this.logLevel.set(config.log.level); this.logRollingSizeMB.set(config.log.rollingSizeMB); @@ -219,6 +229,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { statusCheckEnabled: this.statusCheckEnabled(), strikeInactivityWindowHours: this.strikeInactivityWindowHours() ?? 24, ignoredDownloads: this.ignoredDownloads(), + auth: { + disableAuthForLocalAddresses: this.authDisableLocalAuth(), + trustForwardedHeaders: this.authTrustForwardedHeaders(), + trustedNetworks: this.authTrustedNetworks(), + }, log: { level: this.logLevel() as LogEventLevel, rollingSizeMB: this.logRollingSizeMB() ?? 10, @@ -258,6 +273,9 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { statusCheckEnabled: this.statusCheckEnabled(), strikeInactivityWindowHours: this.strikeInactivityWindowHours(), ignoredDownloads: this.ignoredDownloads(), + authDisableLocalAuth: this.authDisableLocalAuth(), + authTrustForwardedHeaders: this.authTrustForwardedHeaders(), + authTrustedNetworks: this.authTrustedNetworks(), logLevel: this.logLevel(), logRollingSizeMB: this.logRollingSizeMB(), logRetainedFileCount: this.logRetainedFileCount(), diff --git a/code/frontend/src/app/shared/models/general-config.model.ts b/code/frontend/src/app/shared/models/general-config.model.ts index 3a4e1e77..84058078 100644 --- a/code/frontend/src/app/shared/models/general-config.model.ts +++ b/code/frontend/src/app/shared/models/general-config.model.ts @@ -10,6 +10,12 @@ export interface LoggingConfig { archiveTimeLimitHours: number; } +export interface AuthConfig { + disableAuthForLocalAddresses: boolean; + trustForwardedHeaders: boolean; + trustedNetworks: string[]; +} + export interface GeneralConfig { displaySupportBanner: boolean; dryRun: boolean; @@ -21,5 +27,6 @@ export interface GeneralConfig { statusCheckEnabled: boolean; strikeInactivityWindowHours: number; log?: LoggingConfig; + auth?: AuthConfig; ignoredDownloads: string[]; } diff --git a/docs/docs/configuration/general/index.mdx b/docs/docs/configuration/general/index.mdx index 03ee1ab6..919935c2 100644 --- a/docs/docs/configuration/general/index.mdx +++ b/docs/docs/configuration/general/index.mdx @@ -58,6 +58,61 @@ When disabled, the version check and "update available" notification in the side
+Authentication + + + +Bypass authentication for requests originating from local network addresses. When enabled, users on the local network can access Cleanuparr without logging in. + +**Built-in trusted ranges:** +- `127.0.0.0/8` / `::1` (localhost) +- `10.0.0.0/8` (Class A private) +- `172.16.0.0/12` (Class B private) +- `192.168.0.0/16` (Class C private) +- IPv6 link-local (`fe80::`) and unique-local (`fd00::`) addresses + + +Only enable this if your Cleanuparr instance is not exposed to the internet. Anyone on your local network will have full access without authentication. + + + + + + +When running behind a reverse proxy (e.g. Nginx, Caddy, Traefik), enable this to use `X-Forwarded-For` and `X-Real-IP` headers to determine the client's real IP address instead of the proxy's address. + + +Only enable this if you are using a reverse proxy. Forwarded headers are only trusted when the direct connection comes from a local address, preventing spoofing from remote attackers. + + + + + + +Add custom IP addresses or CIDR ranges that should bypass authentication, in addition to the built-in local address ranges. + +**Examples:** +``` +192.168.1.0/24 +10.0.50.0/24 +172.20.0.5 +``` + + + +
+ +
+ HTTP Configuration