mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-10 12:31:24 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 | ||
|
|
df986a2e36 | ||
|
|
8a2aca79f7 |
No files matched your search
@@ -101,14 +101,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
|
||||
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
|
||||
|
||||
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle"></span></span>
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
|
||||
@@ -178,6 +178,61 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task Setup_Complete_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Login endpoint must NOT be blocked by the middleware (403).
|
||||
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,9 +119,9 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted && user.TotpEnabled)
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already configured" });
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
@@ -160,6 +176,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
@@ -196,9 +217,9 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return BadRequest(new { error = "2FA must be configured before completing setup" });
|
||||
return Conflict(new { error = "Setup already completed" });
|
||||
}
|
||||
|
||||
user.SetupCompleted = true;
|
||||
@@ -242,6 +263,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);
|
||||
|
||||
@@ -355,6 +392,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
@@ -385,6 +427,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
@@ -445,7 +492,10 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||
}
|
||||
|
||||
// Plex login bypasses 2FA
|
||||
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
|
||||
// Plex account during setup or via account settings (both require authentication).
|
||||
// Since Plex login verifies the exact same Plex account ID that was linked,
|
||||
// 2FA is not required for Plex login.
|
||||
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -15,52 +15,75 @@ public class SetupGuardMiddleware
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: setup already completed
|
||||
string path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow health checks and non-API paths (static files, SPA, etc.)
|
||||
if (path.StartsWith("/health") || !path.StartsWith("/api/"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup-only paths (/api/auth/setup/*) require setup to NOT be complete
|
||||
if (IsSetupOnlyPath(path))
|
||||
{
|
||||
if (await IsSetupCompleted())
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup already completed" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-setup auth paths (login, refresh, logout, status) are always allowed
|
||||
if (path.StartsWith("/api/auth/") || path == "/api/auth")
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// All other API paths require setup to be complete
|
||||
if (!await IsSetupCompleted())
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private async Task<bool> IsSetupCompleted()
|
||||
{
|
||||
if (_setupCompleted)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow these paths regardless of setup state
|
||||
if (IsAllowedPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check database for setup completion
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is { SetupCompleted: true })
|
||||
{
|
||||
_setupCompleted = true;
|
||||
await _next(context);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Setup not complete - block non-auth requests
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached setup state. Call this if the user database is reset.
|
||||
/// </summary>
|
||||
public void ResetSetupState()
|
||||
private static bool IsSetupOnlyPath(string path)
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private static bool IsAllowedPath(string path)
|
||||
{
|
||||
return path.StartsWith("/api/auth/")
|
||||
|| path == "/api/auth"
|
||||
|| path.StartsWith("/health")
|
||||
|| !path.StartsWith("/api/");
|
||||
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,13 @@ if (basePath is not null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(basePath) && !context.Request.Path.StartsWithSegments(basePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Redirect root to the base path for convenience
|
||||
if (!context.Request.Path.HasValue || context.Request.Path.Value == "/")
|
||||
{
|
||||
context.Response.Redirect(basePath + "/");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
Generated
+1304
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();
|
||||
}
|
||||
}
|
||||
+30
-4
@@ -1,6 +1,34 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Default UMASK if unset to prevent errors with set -e
|
||||
UMASK="${UMASK:-022}"
|
||||
CURRENT_UID=$(id -u)
|
||||
|
||||
# If not running as root, skip all user/permission management.
|
||||
# This supports docker-compose `user: PUID:PGID` (rootless mode).
|
||||
if [ "$CURRENT_UID" != "0" ]; then
|
||||
umask "$UMASK"
|
||||
|
||||
# In rootless mode, the app uses /config for all writable state.
|
||||
# A non-root user cannot create directories under /, so validate early.
|
||||
if [ ! -d /config ]; then
|
||||
echo "ERROR: /config does not exist and the container is running as non-root (UID $CURRENT_UID)." >&2
|
||||
echo "Please mount a writable volume at /config." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -w /config ]; then
|
||||
echo "ERROR: /config is not writable by UID $CURRENT_UID." >&2
|
||||
echo "Please adjust permissions or mount /config as a writable volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Running as root — use PUID/PGID to create user and drop privileges
|
||||
|
||||
# Create group if it doesn't exist
|
||||
if ! getent group "$PGID" > /dev/null 2>&1; then
|
||||
echo "Creating group with GID $PGID"
|
||||
@@ -16,16 +44,14 @@ fi
|
||||
# Set umask
|
||||
umask "$UMASK"
|
||||
|
||||
# Change ownership of app directory if not running as root
|
||||
# Ensure /config is writable by the target user
|
||||
if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
|
||||
mkdir -p /config
|
||||
chown -R "$PUID:$PGID" /app
|
||||
chown -R "$PUID:$PGID" /config
|
||||
fi
|
||||
|
||||
# Execute the main command as the specified user
|
||||
# Execute as the specified user (or root if PUID=0)
|
||||
if [ "$PUID" = "0" ] && [ "$PGID" = "0" ]; then
|
||||
# Running as root, no need for gosu
|
||||
exec "$@"
|
||||
else
|
||||
# Use gosu to drop privileges
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -75,7 +75,6 @@ export class NavSidebarComponent {
|
||||
];
|
||||
|
||||
suggestedApps: ExternalLink[] = [
|
||||
{ label: 'Huntarr', icon: 'tablerExternalLink', href: 'https://github.com/plexguide/Huntarr.io' },
|
||||
];
|
||||
|
||||
onNavItemClick(): void {
|
||||
|
||||
@@ -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
|
||||
@@ -105,10 +160,6 @@ SSL certificate validation for HTTPS connections.
|
||||
|
||||
Automatically search for replacements after removing downloads from *arr apps.
|
||||
|
||||
<Note>
|
||||
If using [Huntarr](https://github.com/plexguide/Huntarr.io), disable this to let Huntarr handle searching.
|
||||
</Note>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
|
||||
Reference in new issue
Block a user