Compare commits

...
2 Commits
6 changed files with 136 additions and 48 deletions

No files matched your search

-8
View File
@@ -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">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</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 = "";
@@ -119,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
@@ -176,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" });
@@ -212,6 +217,11 @@ public sealed class AuthController : ControllerBase
return BadRequest(new { error = "Create an account first" });
}
if (user.SetupCompleted)
{
return Conflict(new { error = "Setup already completed" });
}
user.SetupCompleted = true;
user.UpdatedAt = DateTime.UtcNow;
await _usersContext.SaveChangesAsync();
@@ -382,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
@@ -412,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;
@@ -472,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);
@@ -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";
}
}
@@ -75,7 +75,6 @@ export class NavSidebarComponent {
];
suggestedApps: ExternalLink[] = [
{ label: 'Huntarr', icon: 'tablerExternalLink', href: 'https://github.com/plexguide/Huntarr.io' },
];
onNavItemClick(): void {
@@ -160,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