Add local auth bypass and make 2FA optional (#461)

This commit is contained in:
Flaminel
2026-02-23 11:49:55 +02:00
committed by GitHub
parent e1cc0dba28
commit 8a2aca79f7
28 changed files with 2243 additions and 65 deletions

View File

@@ -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);
}
}

View File

@@ -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 =>
{

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -4,4 +4,5 @@ public sealed record AuthStatusResponse
{
public required bool SetupCompleted { get; init; }
public bool PlexLinked { get; init; }
public bool AuthBypassActive { get; init; }
}

View File

@@ -4,4 +4,5 @@ public sealed record LoginResponse
{
public required bool RequiresTwoFactor { get; init; }
public string? LoginToken { get; init; }
public TokenResponse? Tokens { get; init; }
}

View File

@@ -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()
{

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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 =>
{

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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();

View File

@@ -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 _);
}
}

View File

@@ -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();
}
}

View File

@@ -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');
}

View File

@@ -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> {

View File

@@ -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',

View File

@@ -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);
},

View File

@@ -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">

View File

@@ -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);
}
}

View File

@@ -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('');

View File

@@ -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>

View File

@@ -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()) {

View File

@@ -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">

View File

@@ -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(),

View File

@@ -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[];
}

View File

@@ -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