diff --git a/README.md b/README.md index e1b9a2f..a8ee706 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ RdtClient also emulates part of the SABnzbd API so Sonarr and Radarr can add NZB 1. Enter `6500` in the `Port` field. 1. Enable `Use SSL` only if you access RdtClient through HTTPS. 1. Leave `URL Base` empty unless RdtClient is configured with a `BasePath`, for example `/rdt`. -1. If RdtClient authentication is enabled, leave `API Key` empty and enter your RdtClient username and password. +1. If RdtClient authentication is enabled, leave `API Key` empty and enter your RdtClient username and password. If your client only supports an API key, enter `{username}:{password}` in `API Key`. 1. If RdtClient authentication is disabled, enter any value in `API Key`, for example `rdtclient`, and leave username/password empty. 1. Set the category to `sonarr` for Sonarr or `radarr` for Radarr. 1. Hit `Test` and then `Save` if all is well. diff --git a/server/RdtClient.Service/Middleware/SabnzbdHandler.cs b/server/RdtClient.Service/Middleware/SabnzbdHandler.cs index 2779faf..6fab68f 100644 --- a/server/RdtClient.Service/Middleware/SabnzbdHandler.cs +++ b/server/RdtClient.Service/Middleware/SabnzbdHandler.cs @@ -65,6 +65,36 @@ public class SabnzbdHandler(Authentication authentication, IHttpContextAccessor return; } + var apiKey = GetParam("apikey"); + + if (!String.IsNullOrWhiteSpace(apiKey)) + { + var separatorIndex = apiKey.IndexOf(':'); + + if (separatorIndex <= 0 || separatorIndex == apiKey.Length - 1) + { + context.Fail(); + + return; + } + + var username = apiKey[..separatorIndex]; + var password = apiKey[(separatorIndex + 1)..]; + + var loginResult = await authentication.Login(username, password); + + if (loginResult.Succeeded) + { + context.Succeed(requirement); + + return; + } + + context.Fail(); + + return; + } + // Authentication required but missing credentials context.Fail(); } diff --git a/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs b/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs index 313427b..efcd52c 100644 --- a/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs +++ b/server/RdtClient.Web.Test/Controllers/SabnzbdHandlerTest.cs @@ -64,6 +64,110 @@ public class SabnzbdHandlerTest Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid credentials"); } + [Fact] + public async Task HandleAsync_ValidApiKeyCredentials_Succeeds() + { + // Arrange + _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext + { + Request = + { + QueryString = new("?apikey=user:pass") + } + }; + + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(SignInResult.Success); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid api key credentials"); + } + + [Fact] + public async Task HandleAsync_ValidApiKeyCredentialsFromForm_Succeeds() + { + // Arrange + _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "application/x-www-form-urlencoded"; + httpContext.Request.Form = new FormCollection(new() + { + { + "apikey", "user:pass" + } + }); + + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(SignInResult.Success); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded, "HasSucceeded should be true for valid form api key credentials"); + } + + [Fact] + public async Task HandleAsync_ApiKeyPasswordContainingColon_Succeeds() + { + // Arrange + _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext + { + Request = + { + QueryString = new("?apikey=user:pass:with:colons") + } + }; + + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "pass:with:colons")).ReturnsAsync(SignInResult.Success); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded, "HasSucceeded should be true when the password contains colons"); + } + + [Fact] + public async Task HandleAsync_MaCredentialsTakePrecedenceOverApiKey() + { + // Arrange + _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext + { + Request = + { + QueryString = new("?ma_username=user&ma_password=wrong&apikey=user:pass") + } + }; + + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "wrong")).ReturnsAsync(SignInResult.Failed); + _authenticationMock.Setup(a => a.Login("user", "pass")).ReturnsAsync(SignInResult.Success); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded, "HasSucceeded should be false because ma_username/ma_password take precedence"); + _authenticationMock.Verify(a => a.Login("user", "wrong"), Times.Once); + _authenticationMock.Verify(a => a.Login("user", "pass"), Times.Never); + } + [Fact] public async Task HandleAsync_AlreadyAuthenticated_Succeeds() { @@ -108,6 +212,59 @@ public class SabnzbdHandlerTest Assert.False(context.HasSucceeded, "HasSucceeded should be false for invalid credentials"); } + [Fact] + public async Task HandleAsync_InvalidApiKeyCredentials_DoesNotSucceed() + { + // Arrange + _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext + { + Request = + { + QueryString = new("?apikey=user:wrong") + } + }; + + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + _authenticationMock.Setup(a => a.Login("user", "wrong")).ReturnsAsync(SignInResult.Failed); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded, "HasSucceeded should be false for invalid api key credentials"); + } + + [Theory] + [InlineData("missingdelimiter")] + [InlineData(":pass")] + [InlineData("user:")] + public async Task HandleAsync_MalformedApiKey_DoesNotSucceed(String apiKey) + { + // Arrange + _settings.Current.General.AuthenticationType = AuthenticationType.UserNamePassword; + var httpContext = new DefaultHttpContext + { + Request = + { + QueryString = new($"?apikey={apiKey}") + } + }; + + _httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var context = CreateContext(httpContext); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded, "HasSucceeded should be false for malformed api keys"); + _authenticationMock.Verify(a => a.Login(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task HandleAsync_MissingCredentials_DoesNotSucceed() {