mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-28 11:01:28 -04:00
Add local auth bypass and make 2FA optional (#461)
This commit is contained in:
@@ -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<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TrustedNetworkAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> 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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, TrustedNetworkAuthenticationHandler>(
|
||||
TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public sealed record AuthStatusResponse
|
||||
{
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
public bool AuthBypassActive { get; init; }
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public sealed record LoginResponse
|
||||
{
|
||||
public required bool RequiresTwoFactor { get; init; }
|
||||
public string? LoginToken { get; init; }
|
||||
public TokenResponse? Tokens { get; init; }
|
||||
}
|
||||
|
||||
@@ -139,6 +139,145 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetApiKey()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string> TrustedNetworks { get; init; } = [];
|
||||
|
||||
public void ApplyTo(AuthConfig existingConfig)
|
||||
{
|
||||
existingConfig.DisableAuthForLocalAddresses = DisableAuthForLocalAddresses;
|
||||
existingConfig.TrustForwardedHeaders = TrustForwardedHeaders;
|
||||
existingConfig.TrustedNetworks = TrustedNetworks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +88,20 @@ public class DataContext : DbContext
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<GeneralConfig>(entity =>
|
||||
{
|
||||
entity.ComplexProperty(e => e.Log, cp =>
|
||||
{
|
||||
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
entity.ComplexProperty(e => e.Auth, cp =>
|
||||
{
|
||||
cp.Property(a => a.TrustedNetworks)
|
||||
.HasConversion(
|
||||
v => string.Join(',', v),
|
||||
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity<QueueCleanerConfig>(entity =>
|
||||
{
|
||||
|
||||
1304
code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.Designer.cs
generated
Normal file
1304
code/backend/Cleanuparr.Persistence/Migrations/Data/20260223085409_AddAuthConfig.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "auth_disable_auth_for_local_addresses",
|
||||
table: "general_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "auth_trust_forwarded_headers",
|
||||
table: "general_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "auth_trusted_networks",
|
||||
table: "general_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,24 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("strike_inactivity_window_hours");
|
||||
|
||||
b.ComplexProperty(typeof(Dictionary<string, object>), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<bool>("DisableAuthForLocalAddresses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("auth_disable_auth_for_local_addresses");
|
||||
|
||||
b1.Property<bool>("TrustForwardedHeaders")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("auth_trust_forwarded_headers");
|
||||
|
||||
b1.Property<string>("TrustedNetworks")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("auth_trusted_networks");
|
||||
});
|
||||
|
||||
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
@@ -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<string> 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 _);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,18 @@ export class AccountApi {
|
||||
return this.http.post<TotpSetupResponse>('/api/account/2fa/regenerate', request);
|
||||
}
|
||||
|
||||
enable2fa(password: string): Observable<TotpSetupResponse> {
|
||||
return this.http.post<TotpSetupResponse>('/api/account/2fa/enable', { password });
|
||||
}
|
||||
|
||||
verifyEnable2fa(code: string): Observable<void> {
|
||||
return this.http.post<void>('/api/account/2fa/enable/verify', { code });
|
||||
}
|
||||
|
||||
disable2fa(password: string, totpCode: string): Observable<void> {
|
||||
return this.http.post<void>('/api/account/2fa/disable', { password, totpCode });
|
||||
}
|
||||
|
||||
getApiKey(): Observable<{ apiKey: string }> {
|
||||
return this.http.get<{ apiKey: string }>('/api/account/api-key');
|
||||
}
|
||||
|
||||
@@ -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<LoginResponse> {
|
||||
return this.http.post<LoginResponse>('/api/auth/login', { username, password });
|
||||
return this.http.post<LoginResponse>('/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<TokenResponse> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
Verify Code
|
||||
</app-button>
|
||||
</form>
|
||||
|
||||
<button class="skip-link" (click)="skip2fa()">Skip for now</button>
|
||||
}
|
||||
} @else {
|
||||
<p class="step-subtitle success-text">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -73,68 +73,167 @@
|
||||
<div class="form-stack">
|
||||
<div class="status-row">
|
||||
<span class="status-label">Status</span>
|
||||
<span class="status-value status-value--active">Active</span>
|
||||
@if (account()!.twoFactorEnabled) {
|
||||
<span class="status-value status-value--active">Active</span>
|
||||
} @else {
|
||||
<span class="status-value status-value--inactive">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">New Authenticator Setup</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
@if (account()!.twoFactorEnabled) {
|
||||
<!-- 2FA Enabled: Regenerate or Disable -->
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">New Authenticator Setup</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
</details>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">New Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">New Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
[value]="twoFaPassword()"
|
||||
(valueChange)="twoFaPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Authenticator Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
[value]="twoFaCode()"
|
||||
(valueChange)="twoFaCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
|
||||
(clicked)="confirmRegenerate2fa()"
|
||||
>
|
||||
@if (regenerating2fa()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate 2FA
|
||||
}
|
||||
</app-button>
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || disabling2fa()"
|
||||
(clicked)="confirmDisable2fa()"
|
||||
>
|
||||
@if (disabling2fa()) {
|
||||
<app-spinner size="sm" /> Disabling...
|
||||
} @else {
|
||||
Disable 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
[value]="twoFaPassword()"
|
||||
(valueChange)="twoFaPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Authenticator Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
[value]="twoFaCode()"
|
||||
(valueChange)="twoFaCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
|
||||
(clicked)="confirmRegenerate2fa()"
|
||||
>
|
||||
@if (regenerating2fa()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate 2FA
|
||||
<!-- 2FA Disabled: Enable flow -->
|
||||
@if (enableSetup()) {
|
||||
<!-- QR code + verify flow -->
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">Set Up Authenticator</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
</div>
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
<div class="form-divider"></div>
|
||||
<app-input
|
||||
label="Verification Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code from your app"
|
||||
[value]="enableVerificationCode()"
|
||||
(valueChange)="enableVerificationCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="enableVerificationCode().length !== 6 || enabling2fa()"
|
||||
(clicked)="verifyEnable2fa()"
|
||||
>
|
||||
@if (enabling2fa()) {
|
||||
<app-spinner size="sm" /> Verifying...
|
||||
} @else {
|
||||
Verify & Enable 2FA
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" (clicked)="cancelEnable2fa()">Cancel</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">Two-factor authentication adds an extra layer of security to your account.</p>
|
||||
<app-input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password to enable 2FA"
|
||||
[value]="enablePassword()"
|
||||
(valueChange)="enablePassword.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!enablePassword() || enabling2fa()"
|
||||
(clicked)="startEnable2fa()"
|
||||
>
|
||||
@if (enabling2fa()) {
|
||||
<app-spinner size="sm" /> Setting up...
|
||||
} @else {
|
||||
Enable 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
@@ -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<void> {
|
||||
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()) {
|
||||
|
||||
@@ -41,6 +41,26 @@
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card header="Authentication">
|
||||
<div class="form-stack">
|
||||
<app-toggle label="Disable Authentication for Local Addresses" [(checked)]="authDisableLocalAuth"
|
||||
hint="When enabled, requests from local network addresses (localhost, 192.168.x.x, 10.x.x.x, 172.16-31.x.x) will bypass authentication"
|
||||
helpKey="general:auth.disableLocalAuth" />
|
||||
@if (authDisableLocalAuth()) {
|
||||
<app-toggle label="Trust Forwarded Headers" [(checked)]="authTrustForwardedHeaders"
|
||||
hint="When behind a reverse proxy, trust X-Forwarded-For and X-Real-IP headers to determine the client's real IP address. Only enable this if you are using a reverse proxy."
|
||||
helpKey="general:auth.trustForwardedHeaders" />
|
||||
<app-chip-input
|
||||
label="Additional Trusted Networks"
|
||||
placeholder="e.g. 192.168.1.0/24"
|
||||
hint="Add custom IP addresses or CIDR ranges that should bypass authentication"
|
||||
[(items)]="authTrustedNetworks"
|
||||
helpKey="general:auth.trustedNetworks"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card header="HTTP Settings">
|
||||
<div class="form-stack">
|
||||
<div class="form-row">
|
||||
|
||||
@@ -69,6 +69,11 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
|
||||
readonly strikeInactivityWindowHours = signal<number | null>(24);
|
||||
readonly purgingStrikes = signal(false);
|
||||
|
||||
// Auth
|
||||
readonly authDisableLocalAuth = signal(false);
|
||||
readonly authTrustForwardedHeaders = signal(false);
|
||||
readonly authTrustedNetworks = signal<string[]>([]);
|
||||
|
||||
// Logging
|
||||
readonly logLevel = signal<unknown>(LogEventLevel.Information);
|
||||
readonly logRollingSizeMB = signal<number | null>(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(),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -58,6 +58,61 @@ When disabled, the version check and "update available" notification in the side
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle icon="🔐">Authentication</SectionTitle>
|
||||
|
||||
<ConfigSection
|
||||
title="Disable Auth for Local Addresses"
|
||||
icon="🏠"
|
||||
>
|
||||
|
||||
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
|
||||
|
||||
<Warning>
|
||||
Only enable this if your Cleanuparr instance is not exposed to the internet. Anyone on your local network will have full access without authentication.
|
||||
</Warning>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Trust Forwarded Headers"
|
||||
icon="🔀"
|
||||
>
|
||||
|
||||
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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Additional Trusted Networks"
|
||||
icon="🌐"
|
||||
>
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle icon="🌐">HTTP Configuration</SectionTitle>
|
||||
|
||||
<ConfigSection
|
||||
|
||||
Reference in New Issue
Block a user