From b5ef0cda1eab3df0c6a481446b86c159bf927afc Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 23 May 2022 14:00:26 -0700 Subject: [PATCH] New: Setting to disable authentication for local addresses (cherry picked from commit b154b00c6156512e7fbd0a2c4833c116a74f23ca) Closes #1804 Closes #2077 --- .../src/Settings/General/SecuritySettings.js | 118 ++++++++++++++---- .../AutomationTest.cs | 2 +- .../AuthenticationRequiredType.cs | 8 ++ .../Authentication/AuthenticationType.cs | 5 +- .../Configuration/ConfigFileProvider.cs | 3 + src/NzbDrone.Core/Localization/Core/en.json | 13 +- src/NzbDrone.Host/Startup.cs | 2 + src/NzbDrone.Test.Common/NzbDroneRunner.cs | 8 +- .../Config/HostConfigResource.cs | 2 + .../ApiKeyAuthenticationHandler.cs | 1 + .../AuthenticationBuilderExtensions.cs | 6 + ...leDenyAnonymousAuthorizationRequirement.cs | 8 ++ .../Authentication/UiAuthorizationHandler.cs | 45 +++++++ .../UiAuthorizationPolicyProvider.cs | 3 +- 14 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs create mode 100644 src/Readarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs create mode 100644 src/Readarr.Http/Authentication/UiAuthorizationHandler.cs diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index bb20a9305..a743e953b 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -11,16 +11,69 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -const authenticationMethodOptions = [ - { key: 'none', value: 'None' }, - { key: 'basic', value: 'Basic (Browser Popup)' }, - { key: 'forms', value: 'Forms (Login Page)' } +export const authenticationMethodOptions = [ + { + key: 'none', + get value() { + return translate('None'); + }, + isDisabled: true + }, + { + key: 'external', + get value() { + return translate('External'); + }, + isHidden: true + }, + { + key: 'basic', + get value() { + return translate('AuthBasic'); + } + }, + { + key: 'forms', + get value() { + return translate('AuthForm'); + } + } +]; + +export const authenticationRequiredOptions = [ + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + } ]; const certificateValidationOptions = [ - { key: 'enabled', value: 'Enabled' }, - { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }, - { key: 'disabled', value: 'Disabled' } + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + } + } ]; class SecuritySettings extends Component { @@ -68,6 +121,7 @@ class SecuritySettings extends Component { const { authenticationMethod, + authenticationRequired, username, password, apiKey, @@ -79,26 +133,40 @@ class SecuritySettings extends Component { return (
- - {translate('Authentication')} - + {translate('Authentication')} { - authenticationEnabled && + authenticationEnabled ? + + {translate('AuthenticationRequired')} + + + : + null + } + + { + authenticationEnabled ? - - {translate('Username')} - + {translate('Username')} - + : + null } { - authenticationEnabled && + authenticationEnabled ? - - {translate('Password')} - + {translate('Password')} - + : + null } - - {translate('APIKey')} - + {translate('ApiKey')} - - {translate('CertificateValidation')} - + {translate('CertificateValidation')} GetValueEnum("AuthenticationRequired", AuthenticationRequiredType.Enabled); + public bool AnalyticsEnabled => GetValueBoolean("AnalyticsEnabled", true, persist: false); // TODO: Change back to "master" for the first stable release diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 586985ab2..1321845db 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -2,7 +2,6 @@ "20MinutesTwenty": "20 Minutes: {0}", "45MinutesFourtyFive": "45 Minutes: {0}", "60MinutesSixty": "60 Minutes: {0}", - "APIKey": "API Key", "ASIN": "ASIN", "About": "About", "Actions": "Actions", @@ -33,7 +32,7 @@ "AnalyticsEnabledHelpText": "Send anonymous usage and error information to Readarr's servers. This includes information on your browser, which Readarr WebUI pages you use, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes.", "AnalyticsEnabledHelpTextWarning": "Requires restart to take effect", "AnyEditionOkHelpText": "Readarr will automatically switch to the edition best matching downloaded files", - "ApiKeyHelpTextWarning": "Requires restart to take effect", + "ApiKey": "API Key", "ApiKeyValidationHealthCheckMessage": "Please update your API key to be at least {0} characters long. You can do this via settings or the config file", "AppDataDirectory": "AppData Directory", "AppDataLocationHealthCheckMessage": "Updating will not be possible to prevent deleting AppData on Update", @@ -51,8 +50,13 @@ "ApplyTagsHelpTextRemove": "Remove: Remove the entered tags", "ApplyTagsHelpTextReplace": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)", "AudioFileMetadata": "Write Metadata to Audio Files", + "AuthBasic": "Basic (Browser Popup)", + "AuthForm": "Forms (Login Page)", "Authentication": "Authentication", - "AuthenticationMethodHelpText": "Require Username and Password to access Readarr", + "AuthenticationMethodHelpText": "Require Username and Password to access {appName}", + "AuthenticationRequired": "Authentication Required", + "AuthenticationRequiredHelpText": "Change which requests authentication is required for. Do not change unless you understand the risks.", + "AuthenticationRequiredWarning": "To prevent remote access without authentication, {appName} now requires authentication to be enabled. You can optionally disable authentication from local addresses.", "Author": "Author", "AuthorClickToChangeBook": "Click to change book", "AuthorEditor": "Author Editor", @@ -277,6 +281,7 @@ "DetailedProgressBarHelpText": "Show text on progress bar", "Development": "Development", "Disabled": "Disabled", + "DisabledForLocalAddresses": "Disabled for Local Addresses", "DiscCount": "Disc Count", "DiscNumber": "Disc Number", "DiskSpace": "Disk Space", @@ -331,6 +336,7 @@ "EnableRssHelpText": "Will be used when Readarr periodically looks for releases via RSS Sync", "EnableSSL": "Enable SSL", "EnableSslHelpText": " Requires restart running as administrator to take effect", + "Enabled": "Enabled", "EnabledHelpText": "Check to enable release profile", "Ended": "Ended", "EndedAllBooksDownloaded": "Ended (All books downloaded)", @@ -345,6 +351,7 @@ "ExistingTag": "Existing tag", "ExistingTagsScrubbed": "Existing tags scrubbed", "ExportCustomFormat": "Export Custom Format", + "External": "External", "ExtraFileExtensionsHelpText": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)", "ExtraFileExtensionsHelpTextsExamples": "Examples: '.sub, .nfo' or 'sub,nfo'", "FailedDownloadHandling": "Failed Download Handling", diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs index a667e3774..144e8a188 100644 --- a/src/NzbDrone.Host/Startup.cs +++ b/src/NzbDrone.Host/Startup.cs @@ -175,6 +175,8 @@ namespace NzbDrone.Host .PersistKeysToFileSystem(new DirectoryInfo(Configuration["dataProtectionFolder"])); services.AddSingleton(); + services.AddSingleton(); + services.AddAuthorization(options => { options.AddPolicy("SignalR", policy => diff --git a/src/NzbDrone.Test.Common/NzbDroneRunner.cs b/src/NzbDrone.Test.Common/NzbDroneRunner.cs index 0ca90177c..da893427b 100644 --- a/src/NzbDrone.Test.Common/NzbDroneRunner.cs +++ b/src/NzbDrone.Test.Common/NzbDroneRunner.cs @@ -38,12 +38,12 @@ namespace NzbDrone.Test.Common Port = port; } - public void Start() + public void Start(bool enableAuth = false) { AppData = Path.Combine(TestContext.CurrentContext.TestDirectory, "_intg_" + TestBase.GetUID()); Directory.CreateDirectory(AppData); - GenerateConfigFile(); + GenerateConfigFile(enableAuth); string readarrConsoleExe; if (OsInfo.IsWindows) @@ -178,7 +178,7 @@ namespace NzbDrone.Test.Common } } - private void GenerateConfigFile() + private void GenerateConfigFile(bool enableAuth) { var configFile = Path.Combine(AppData, "config.xml"); @@ -191,6 +191,8 @@ namespace NzbDrone.Test.Common new XElement(nameof(ConfigFileProvider.ApiKey), apiKey), new XElement(nameof(ConfigFileProvider.LogLevel), "trace"), new XElement(nameof(ConfigFileProvider.AnalyticsEnabled), false), + new XElement(nameof(ConfigFileProvider.AuthenticationMethod), enableAuth ? "Forms" : "None"), + new XElement(nameof(ConfigFileProvider.AuthenticationRequired), "DisabledForLocalAddresses"), new XElement(nameof(ConfigFileProvider.Port), Port))); var data = xDoc.ToString(); diff --git a/src/Readarr.Api.V1/Config/HostConfigResource.cs b/src/Readarr.Api.V1/Config/HostConfigResource.cs index 7ca7393ea..ecd4b8ee0 100644 --- a/src/Readarr.Api.V1/Config/HostConfigResource.cs +++ b/src/Readarr.Api.V1/Config/HostConfigResource.cs @@ -15,6 +15,7 @@ namespace Readarr.Api.V1.Config public bool EnableSsl { get; set; } public bool LaunchBrowser { get; set; } public AuthenticationType AuthenticationMethod { get; set; } + public AuthenticationRequiredType AuthenticationRequired { get; set; } public bool AnalyticsEnabled { get; set; } public string Username { get; set; } public string Password { get; set; } @@ -57,6 +58,7 @@ namespace Readarr.Api.V1.Config EnableSsl = model.EnableSsl, LaunchBrowser = model.LaunchBrowser, AuthenticationMethod = model.AuthenticationMethod, + AuthenticationRequired = model.AuthenticationRequired, AnalyticsEnabled = model.AnalyticsEnabled, //Username diff --git a/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs b/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs index f0b35afdc..c41eb55be 100644 --- a/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs +++ b/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs @@ -13,6 +13,7 @@ namespace Readarr.Http.Authentication public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { public const string DefaultScheme = "API Key"; + public string Scheme => DefaultScheme; public string AuthenticationType = DefaultScheme; diff --git a/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs index 9ad906795..3f7887a54 100644 --- a/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs +++ b/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -22,10 +22,16 @@ namespace Readarr.Http.Authentication return authenticationBuilder.AddScheme(name, options => { }); } + public static AuthenticationBuilder AddExternal(this AuthenticationBuilder authenticationBuilder, string name) + { + return authenticationBuilder.AddScheme(name, options => { }); + } + public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection services) { return services.AddAuthentication() .AddNone(AuthenticationType.None.ToString()) + .AddExternal(AuthenticationType.External.ToString()) .AddBasic(AuthenticationType.Basic.ToString()) .AddCookie(AuthenticationType.Forms.ToString(), options => { diff --git a/src/Readarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs b/src/Readarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs new file mode 100644 index 000000000..3ad4edcba --- /dev/null +++ b/src/Readarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace NzbDrone.Http.Authentication +{ + public class BypassableDenyAnonymousAuthorizationRequirement : DenyAnonymousAuthorizationRequirement + { + } +} diff --git a/src/Readarr.Http/Authentication/UiAuthorizationHandler.cs b/src/Readarr.Http/Authentication/UiAuthorizationHandler.cs new file mode 100644 index 000000000..d8f08f3ea --- /dev/null +++ b/src/Readarr.Http/Authentication/UiAuthorizationHandler.cs @@ -0,0 +1,45 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; +using NzbDrone.Core.Messaging.Events; +using Readarr.Http.Extensions; + +namespace NzbDrone.Http.Authentication +{ + public class UiAuthorizationHandler : AuthorizationHandler, IAuthorizationRequirement, IHandle + { + private readonly IConfigFileProvider _configService; + private static AuthenticationRequiredType _authenticationRequired; + + public UiAuthorizationHandler(IConfigFileProvider configService) + { + _configService = configService; + _authenticationRequired = configService.AuthenticationRequired; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, BypassableDenyAnonymousAuthorizationRequirement requirement) + { + if (_authenticationRequired == AuthenticationRequiredType.DisabledForLocalAddresses) + { + if (context.Resource is HttpContext httpContext && + IPAddress.TryParse(httpContext.GetRemoteIP(), out var ipAddress) && + ipAddress.IsLocalAddress()) + { + context.Succeed(requirement); + } + } + + return Task.CompletedTask; + } + + public void Handle(ConfigSavedEvent message) + { + _authenticationRequired = _configService.AuthenticationRequired; + } + } +} diff --git a/src/Readarr.Http/Authentication/UiAuthorizationPolicyProvider.cs b/src/Readarr.Http/Authentication/UiAuthorizationPolicyProvider.cs index a5295a99f..50f1c3ada 100644 --- a/src/Readarr.Http/Authentication/UiAuthorizationPolicyProvider.cs +++ b/src/Readarr.Http/Authentication/UiAuthorizationPolicyProvider.cs @@ -29,7 +29,8 @@ namespace NzbDrone.Http.Authentication if (policyName.Equals(POLICY_NAME, StringComparison.OrdinalIgnoreCase)) { var policy = new AuthorizationPolicyBuilder(_config.AuthenticationMethod.ToString()) - .RequireAuthenticatedUser(); + .AddRequirements(new BypassableDenyAnonymousAuthorizationRequirement()); + return Task.FromResult(policy.Build()); }