diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js index dce638e4a..aa23f4d88 100644 --- a/frontend/src/Components/Page/Page.js +++ b/frontend/src/Components/Page/Page.js @@ -4,6 +4,7 @@ import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; import ColorImpairedContext from 'App/ColorImpairedContext'; import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; import SignalRConnector from 'Components/SignalRConnector'; +import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal'; import locationShape from 'Helpers/Props/Shapes/locationShape'; import PageHeader from './Header/PageHeader'; import PageSidebar from './Sidebar/PageSidebar'; @@ -75,6 +76,7 @@ class Page extends Component { isSmallScreen, isSidebarVisible, enableColorImpairedMode, + authenticationEnabled, onSidebarToggle, onSidebarVisibleChange } = this.props; @@ -109,6 +111,10 @@ class Page extends Component { isOpen={this.state.isConnectionLostModalOpen} onModalClose={this.onConnectionLostModalClose} /> + + ); @@ -124,6 +130,7 @@ Page.propTypes = { isUpdated: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired, enableColorImpairedMode: PropTypes.bool.isRequired, + authenticationEnabled: PropTypes.bool.isRequired, onResize: PropTypes.func.isRequired, onSidebarToggle: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 647fdcf7b..5ac032c0f 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -11,6 +11,7 @@ import { fetchAppProfiles, fetchGeneralSettings, fetchIndexerCategories, fetchUI import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import ErrorPage from './ErrorPage'; import LoadingPage from './LoadingPage'; import Page from './Page'; @@ -133,18 +134,21 @@ function createMapStateToProps() { selectErrors, selectAppProps, createDimensionsSelector(), + createSystemStatusSelector(), ( enableColorImpairedMode, isPopulated, errors, app, - dimensions + dimensions, + systemStatus ) => { return { ...app, ...errors, isPopulated, isSmallScreen: dimensions.isSmallScreen, + authenticationEnabled: systemStatus.authentication !== 'none', enableColorImpairedMode }; } diff --git a/frontend/src/FirstRun/AuthenticationRequiredModal.js b/frontend/src/FirstRun/AuthenticationRequiredModal.js new file mode 100644 index 000000000..caa855cb7 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector'; + +function onModalClose() { + // No-op +} + +function AuthenticationRequiredModal(props) { + const { + isOpen + } = props; + + return ( + + + + ); +} + +AuthenticationRequiredModal.propTypes = { + isOpen: PropTypes.bool.isRequired +}; + +export default AuthenticationRequiredModal; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.css b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css new file mode 100644 index 000000000..bbc6704e6 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css @@ -0,0 +1,5 @@ +.authRequiredAlert { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 20px; +} diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js new file mode 100644 index 000000000..193bdf950 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useRef } from 'react'; +import Alert from 'Components/Alert'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { authenticationMethodOptions, authenticationRequiredOptions, authenticationRequiredWarning } from 'Settings/General/SecuritySettings'; +import styles from './AuthenticationRequiredModalContent.css'; + +function onModalClose() { + // No-op +} + +function AuthenticationRequiredModalContent(props) { + const { + isPopulated, + error, + isSaving, + settings, + onInputChange, + onSavePress, + dispatchFetchStatus + } = props; + + const { + authenticationMethod, + authenticationRequired, + username, + password + } = settings; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + const didMount = useRef(false); + + useEffect(() => { + if (!isSaving && didMount.current) { + dispatchFetchStatus(); + } + + didMount.current = true; + }, [isSaving, dispatchFetchStatus]); + + return ( + + + Authentication Required + + + + + {authenticationRequiredWarning} + + + { + isPopulated && !error ? +
+ + Authentication + + + + + { + authenticationEnabled ? + + Authentication Required + + + : + null + } + + { + authenticationEnabled ? + + Username + + + : + null + } + + { + authenticationEnabled ? + + Password + + + : + null + } +
: + null + } + + { + !isPopulated && !error ? : null + } +
+ + + + Save + + +
+ ); +} + +AuthenticationRequiredModalContent.propTypes = { + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired +}; + +export default AuthenticationRequiredModalContent; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js b/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js new file mode 100644 index 000000000..6653a9d34 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + createSettingsSectionSelector(SECTION), + (sectionSettings) => { + return { + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchClearPendingChanges: clearPendingChanges, + dispatchSetGeneralSettingsValue: setGeneralSettingsValue, + dispatchSaveGeneralSettings: saveGeneralSettings, + dispatchFetchGeneralSettings: fetchGeneralSettings, + dispatchFetchStatus: fetchStatus +}; + +class AuthenticationRequiredModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchGeneralSettings(); + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetGeneralSettingsValue({ name, value }); + }; + + onSavePress = () => { + this.props.dispatchSaveGeneralSettings(); + }; + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchFetchGeneralSettings, + dispatchSetGeneralSettingsValue, + dispatchSaveGeneralSettings, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AuthenticationRequiredModalContentConnector.propTypes = { + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchFetchGeneralSettings: PropTypes.func.isRequired, + dispatchSetGeneralSettingsValue: PropTypes.func.isRequired, + dispatchSaveGeneralSettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector); diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index d2589b6b1..d161667ba 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -11,12 +11,20 @@ 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' }, +export const authenticationRequiredWarning = 'To prevent remote access without authentication, Sonarr now requires authentication to be enabled. You can optionally disable authentication from local addresses.'; + +export const authenticationMethodOptions = [ + { key: 'none', value: 'None', isDisabled: true }, + { key: 'external', value: 'External', isHidden: true }, { key: 'basic', value: 'Basic (Browser Popup)' }, { key: 'forms', value: 'Forms (Login Page)' } ]; +export const authenticationRequiredOptions = [ + { key: 'enabled', value: 'Enabled' }, + { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' } +]; + const certificateValidationOptions = [ { key: 'enabled', value: 'Enabled' }, { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }, @@ -68,6 +76,7 @@ class SecuritySettings extends Component { const { authenticationMethod, + authenticationRequired, username, password, apiKey, @@ -86,13 +95,31 @@ class SecuritySettings extends Component { name="authenticationMethod" values={authenticationMethodOptions} helpText={translate('AuthenticationMethodHelpText')} + helpTextWarning={authenticationRequiredWarning} onChange={onInputChange} {...authenticationMethod} /> { - authenticationEnabled && + authenticationEnabled ? + + Authentication Required + + + : + null + } + + { + authenticationEnabled ? {translate('Username')} @@ -102,11 +129,12 @@ class SecuritySettings extends Component { onChange={onInputChange} {...username} /> - + : + null } { - authenticationEnabled && + authenticationEnabled ? {translate('Password')} @@ -116,7 +144,8 @@ class SecuritySettings extends Component { onChange={onInputChange} {...password} /> - + : + null } diff --git a/src/NzbDrone.Automation.Test/AutomationTest.cs b/src/NzbDrone.Automation.Test/AutomationTest.cs index 840346de8..247f52633 100644 --- a/src/NzbDrone.Automation.Test/AutomationTest.cs +++ b/src/NzbDrone.Automation.Test/AutomationTest.cs @@ -46,7 +46,7 @@ namespace NzbDrone.Automation.Test _runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger(), null); _runner.KillAll(); - _runner.Start(); + _runner.Start(true); driver.Url = "http://localhost:9696"; diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs index 217ee5d72..a56e57376 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs @@ -11,26 +11,41 @@ namespace NzbDrone.Common.Instrumentation.Sentry { try { - sentryEvent.Message = CleanseLogMessage.Cleanse(sentryEvent.Message.Message); + if (sentryEvent.Message is not null) + { + sentryEvent.Message.Formatted = CleanseLogMessage.Cleanse(sentryEvent.Message.Formatted); + sentryEvent.Message.Message = CleanseLogMessage.Cleanse(sentryEvent.Message.Message); + sentryEvent.Message.Params = sentryEvent.Message.Params?.Select(x => CleanseLogMessage.Cleanse(x switch + { + string str => str, + _ => x.ToString() + })).ToList(); + } - if (sentryEvent.Fingerprint != null) + if (sentryEvent.Fingerprint.Any()) { var fingerprint = sentryEvent.Fingerprint.Select(x => CleanseLogMessage.Cleanse(x)).ToList(); sentryEvent.SetFingerprint(fingerprint); } - if (sentryEvent.Extra != null) + if (sentryEvent.Extra.Any()) { - var extras = sentryEvent.Extra.ToDictionary(x => x.Key, y => (object)CleanseLogMessage.Cleanse((string)y.Value)); + var extras = sentryEvent.Extra.ToDictionary(x => x.Key, y => (object)CleanseLogMessage.Cleanse(y.Value as string)); sentryEvent.SetExtras(extras); } - foreach (var exception in sentryEvent.SentryExceptions) + if (sentryEvent.SentryExceptions is not null) { - exception.Value = CleanseLogMessage.Cleanse(exception.Value); - foreach (var frame in exception.Stacktrace.Frames) + foreach (var exception in sentryEvent.SentryExceptions) { - frame.FileName = ShortenPath(frame.FileName); + exception.Value = CleanseLogMessage.Cleanse(exception.Value); + if (exception.Stacktrace is not null) + { + foreach (var frame in exception.Stacktrace.Frames) + { + frame.FileName = ShortenPath(frame.FileName); + } + } } } } diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs index 8832b6251..2ba30e338 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs @@ -42,10 +42,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry "UnauthorizedAccessException", // Filter out people stuck in boot loops - "CorruptDatabaseException", - - // This also filters some people in boot loops - "TinyIoCResolutionException" + "CorruptDatabaseException" }; public static readonly List FilteredExceptionMessages = new List @@ -102,9 +99,6 @@ namespace NzbDrone.Common.Instrumentation.Sentry o.Dsn = dsn; o.AttachStacktrace = true; o.MaxBreadcrumbs = 200; - o.SendDefaultPii = false; - o.Debug = false; - o.DiagnosticLevel = SentryLevel.Debug; o.Release = BuildInfo.Release; o.BeforeSend = x => SentryCleanser.CleanseEvent(x); o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x); @@ -210,7 +204,11 @@ namespace NzbDrone.Common.Instrumentation.Sentry if (ex != null) { fingerPrint.Add(ex.GetType().FullName); - fingerPrint.Add(ex.TargetSite.ToString()); + if (ex.TargetSite != null) + { + fingerPrint.Add(ex.TargetSite.ToString()); + } + if (ex.InnerException != null) { fingerPrint.Add(ex.InnerException.GetType().FullName); diff --git a/src/NzbDrone.Common/Prowlarr.Common.csproj b/src/NzbDrone.Common/Prowlarr.Common.csproj index ff0c30158..66ea583ee 100644 --- a/src/NzbDrone.Common/Prowlarr.Common.csproj +++ b/src/NzbDrone.Common/Prowlarr.Common.csproj @@ -4,13 +4,13 @@ ISMUSL - + - - - - + + + + diff --git a/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj index 6d48fa0a6..47a54f144 100644 --- a/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs b/src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs new file mode 100644 index 000000000..dc3c2c770 --- /dev/null +++ b/src/NzbDrone.Core/Authentication/AuthenticationRequiredType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Authentication +{ + public enum AuthenticationRequiredType + { + Enabled = 0, + DisabledForLocalAddresses = 1 + } +} diff --git a/src/NzbDrone.Core/Authentication/AuthenticationType.cs b/src/NzbDrone.Core/Authentication/AuthenticationType.cs index 9f21b07a7..ca408774b 100644 --- a/src/NzbDrone.Core/Authentication/AuthenticationType.cs +++ b/src/NzbDrone.Core/Authentication/AuthenticationType.cs @@ -1,9 +1,10 @@ -namespace NzbDrone.Core.Authentication +namespace NzbDrone.Core.Authentication { public enum AuthenticationType { None = 0, Basic = 1, - Forms = 2 + Forms = 2, + External = 3 } } diff --git a/src/NzbDrone.Core/Authentication/User.cs b/src/NzbDrone.Core/Authentication/User.cs index 794d4824a..63c67bd5f 100644 --- a/src/NzbDrone.Core/Authentication/User.cs +++ b/src/NzbDrone.Core/Authentication/User.cs @@ -1,4 +1,4 @@ -using System; +using System; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Authentication @@ -8,5 +8,7 @@ namespace NzbDrone.Core.Authentication public Guid Identifier { get; set; } public string Username { get; set; } public string Password { get; set; } + public string Salt { get; set; } + public int Iterations { get; set; } } } diff --git a/src/NzbDrone.Core/Authentication/UserService.cs b/src/NzbDrone.Core/Authentication/UserService.cs index 73f70aa5b..9b4553788 100644 --- a/src/NzbDrone.Core/Authentication/UserService.cs +++ b/src/NzbDrone.Core/Authentication/UserService.cs @@ -1,4 +1,6 @@ using System; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; @@ -21,6 +23,10 @@ namespace NzbDrone.Core.Authentication private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; + private static readonly int ITERATIONS = 10000; + private static readonly int SALT_SIZE = 128 / 8; + private static readonly int NUMBER_OF_BYTES = 256 / 8; + public UserService(IUserRepository repo, IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) { _repo = repo; @@ -30,12 +36,15 @@ namespace NzbDrone.Core.Authentication public User Add(string username, string password) { - return _repo.Insert(new User + var user = new User { Identifier = Guid.NewGuid(), - Username = username.ToLowerInvariant(), - Password = password.SHA256Hash() - }); + Username = username.ToLowerInvariant() + }; + + SetUserHashedPassword(user, password); + + return _repo.Insert(user); } public User Update(User user) @@ -54,7 +63,7 @@ namespace NzbDrone.Core.Authentication if (user.Password != password) { - user.Password = password.SHA256Hash(); + SetUserHashedPassword(user, password); } user.Username = username.ToLowerInvariant(); @@ -81,7 +90,20 @@ namespace NzbDrone.Core.Authentication return null; } - if (user.Password == password.SHA256Hash()) + if (user.Salt.IsNullOrWhiteSpace()) + { + // If password matches stored SHA256 hash, update to salted hash and verify. + if (user.Password == password.SHA256Hash()) + { + SetUserHashedPassword(user, password); + + return Update(user); + } + + return null; + } + + if (VerifyHashedPassword(user, password)) { return user; } @@ -93,5 +115,42 @@ namespace NzbDrone.Core.Authentication { return _repo.FindUser(identifier); } + + private User SetUserHashedPassword(User user, string password) + { + var salt = GenerateSalt(); + + user.Iterations = ITERATIONS; + user.Salt = Convert.ToBase64String(salt); + user.Password = GetHashedPassword(password, salt, ITERATIONS); + + return user; + } + + private byte[] GenerateSalt() + { + var salt = new byte[SALT_SIZE]; + RandomNumberGenerator.Create().GetBytes(salt); + + return salt; + } + + private string GetHashedPassword(string password, byte[] salt, int iterations) + { + return Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA512, + iterationCount: iterations, + numBytesRequested: NUMBER_OF_BYTES)); + } + + private bool VerifyHashedPassword(User user, string password) + { + var salt = Convert.FromBase64String(user.Salt); + var hashedPassword = GetHashedPassword(password, salt, user.Iterations); + + return user.Password == hashedPassword; + } } } diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index ac8a13a4e..6ccc49cc7 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -32,6 +32,7 @@ namespace NzbDrone.Core.Configuration bool EnableSsl { get; } bool LaunchBrowser { get; } AuthenticationType AuthenticationMethod { get; } + AuthenticationRequiredType AuthenticationRequired { get; } bool AnalyticsEnabled { get; } string LogLevel { get; } string ConsoleLogLevel { get; } @@ -193,6 +194,8 @@ namespace NzbDrone.Core.Configuration } } + public AuthenticationRequiredType AuthenticationRequired => 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/Datastore/Migration/024_add_salt_to_users.cs b/src/NzbDrone.Core/Datastore/Migration/024_add_salt_to_users.cs new file mode 100644 index 000000000..e5bb6b083 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/024_add_salt_to_users.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(024)] + public class add_salt_to_users : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Users") + .AddColumn("Salt").AsString().Nullable() + .AddColumn("Iterations").AsInt32().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Prowlarr.Core.csproj b/src/NzbDrone.Core/Prowlarr.Core.csproj index fae8e0d2e..7dc2b8187 100644 --- a/src/NzbDrone.Core/Prowlarr.Core.csproj +++ b/src/NzbDrone.Core/Prowlarr.Core.csproj @@ -6,7 +6,8 @@ - + + @@ -15,13 +16,13 @@ - - + + - + diff --git a/src/NzbDrone.Host/Prowlarr.Host.csproj b/src/NzbDrone.Host/Prowlarr.Host.csproj index 660f10f29..7aa32bf91 100644 --- a/src/NzbDrone.Host/Prowlarr.Host.csproj +++ b/src/NzbDrone.Host/Prowlarr.Host.csproj @@ -4,11 +4,11 @@ Library - + - + diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs index b8461c581..f70d90be0 100644 --- a/src/NzbDrone.Host/Startup.cs +++ b/src/NzbDrone.Host/Startup.cs @@ -22,7 +22,6 @@ using NzbDrone.Core.Instrumentation; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; using NzbDrone.Host.AccessControl; -using NzbDrone.Http.Authentication; using NzbDrone.SignalR; using Prowlarr.Api.V1.System; using Prowlarr.Http; @@ -172,6 +171,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 b4c97847c..e4c66b937 100644 --- a/src/NzbDrone.Test.Common/NzbDroneRunner.cs +++ b/src/NzbDrone.Test.Common/NzbDroneRunner.cs @@ -37,12 +37,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 consoleExe; if (OsInfo.IsWindows) @@ -167,7 +167,7 @@ namespace NzbDrone.Test.Common } } - private void GenerateConfigFile() + private void GenerateConfigFile(bool enableAuth) { var configFile = Path.Combine(AppData, "config.xml"); @@ -180,6 +180,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/NzbDrone.Test.Common/Prowlarr.Test.Common.csproj b/src/NzbDrone.Test.Common/Prowlarr.Test.Common.csproj index 1ca89ee02..1f680a255 100644 --- a/src/NzbDrone.Test.Common/Prowlarr.Test.Common.csproj +++ b/src/NzbDrone.Test.Common/Prowlarr.Test.Common.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/NzbDrone.Update/Prowlarr.Update.csproj b/src/NzbDrone.Update/Prowlarr.Update.csproj index 7af53f202..d34e071f0 100644 --- a/src/NzbDrone.Update/Prowlarr.Update.csproj +++ b/src/NzbDrone.Update/Prowlarr.Update.csproj @@ -4,9 +4,9 @@ net6.0 - + - + diff --git a/src/NzbDrone.Windows/Prowlarr.Windows.csproj b/src/NzbDrone.Windows/Prowlarr.Windows.csproj index 1661cd01f..f690fcf0f 100644 --- a/src/NzbDrone.Windows/Prowlarr.Windows.csproj +++ b/src/NzbDrone.Windows/Prowlarr.Windows.csproj @@ -4,7 +4,7 @@ true - + diff --git a/src/Prowlarr.Api.V1/Config/HostConfigResource.cs b/src/Prowlarr.Api.V1/Config/HostConfigResource.cs index 820552533..38d132615 100644 --- a/src/Prowlarr.Api.V1/Config/HostConfigResource.cs +++ b/src/Prowlarr.Api.V1/Config/HostConfigResource.cs @@ -15,6 +15,7 @@ namespace Prowlarr.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 Prowlarr.Api.V1.Config EnableSsl = model.EnableSsl, LaunchBrowser = model.LaunchBrowser, AuthenticationMethod = model.AuthenticationMethod, + AuthenticationRequired = model.AuthenticationRequired, AnalyticsEnabled = model.AnalyticsEnabled, //Username diff --git a/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj b/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj index 5fa5c9af6..fb87b706f 100644 --- a/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj +++ b/src/Prowlarr.Api.V1/Prowlarr.Api.V1.csproj @@ -4,7 +4,7 @@ - + diff --git a/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs b/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs index 3eed8c06c..eda470c4c 100644 --- a/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs +++ b/src/Prowlarr.Http/Authentication/ApiKeyAuthenticationHandler.cs @@ -13,6 +13,7 @@ namespace Prowlarr.Http.Authentication public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { public const string DefaultScheme = "API Key"; + public string Scheme => DefaultScheme; public string AuthenticationType = DefaultScheme; diff --git a/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs index e672a87e5..cdc024e05 100644 --- a/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs +++ b/src/Prowlarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -22,10 +22,16 @@ namespace Prowlarr.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/Prowlarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs b/src/Prowlarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs new file mode 100644 index 000000000..1853acea6 --- /dev/null +++ b/src/Prowlarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Prowlarr.Http.Authentication +{ + public class BypassableDenyAnonymousAuthorizationRequirement : DenyAnonymousAuthorizationRequirement + { + } +} diff --git a/src/Prowlarr.Http/Authentication/UiAuthorizationHandler.cs b/src/Prowlarr.Http/Authentication/UiAuthorizationHandler.cs new file mode 100644 index 000000000..738364748 --- /dev/null +++ b/src/Prowlarr.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 Prowlarr.Http.Extensions; + +namespace Prowlarr.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/Prowlarr.Http/Authentication/UiAuthorizationPolicyProvider.cs b/src/Prowlarr.Http/Authentication/UiAuthorizationPolicyProvider.cs index a5295a99f..2c1cc208b 100644 --- a/src/Prowlarr.Http/Authentication/UiAuthorizationPolicyProvider.cs +++ b/src/Prowlarr.Http/Authentication/UiAuthorizationPolicyProvider.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; using NzbDrone.Core.Configuration; -namespace NzbDrone.Http.Authentication +namespace Prowlarr.Http.Authentication { public class UiAuthorizationPolicyProvider : IAuthorizationPolicyProvider { @@ -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()); } diff --git a/src/Prowlarr.Http/Prowlarr.Http.csproj b/src/Prowlarr.Http/Prowlarr.Http.csproj index 75751a276..8cfc4f4b2 100644 --- a/src/Prowlarr.Http/Prowlarr.Http.csproj +++ b/src/Prowlarr.Http/Prowlarr.Http.csproj @@ -5,7 +5,7 @@ - +