From 4154414adf2137eb8b6b1d23817da3278014ecbb Mon Sep 17 00:00:00 2001 From: ta264 Date: Sun, 19 Jun 2022 10:29:29 +0100 Subject: [PATCH] Add OIDC and Plex authentication methods (cherry picked from commit 3ff3de6b90704fba266833115cd9d03ace99aae9) --- .../SelectMovie/ImportMovieSelectMovie.js | 3 +- .../src/Components/Form/FormInputButton.js | 26 +-- .../src/Components/Form/FormInputGroup.css | 1 - .../src/Components/Form/FormInputGroup.js | 4 + frontend/src/Components/Form/OAuthInput.js | 23 +-- .../src/Components/Form/PlexMachineInput.js | 44 +++++ .../Form/PlexMachineInputConnector.js | 115 +++++++++++++ frontend/src/Components/Form/SelectInput.css | 4 + frontend/src/Components/Form/SelectInput.js | 3 + .../Page/Header/PageHeaderActionsMenu.js | 34 ++-- .../Header/PageHeaderActionsMenuConnector.js | 2 +- .../AuthenticationRequiredModalContent.js | 153 +++++++++++++++--- ...enticationRequiredModalContentConnector.js | 6 +- frontend/src/Helpers/Props/inputTypes.js | 2 + .../src/Settings/General/GeneralSettings.js | 3 + .../General/GeneralSettingsConnector.js | 4 +- .../src/Settings/General/SecuritySettings.js | 153 +++++++++++++++--- frontend/src/Store/Actions/Settings/plex.js | 48 ++++++ frontend/src/Store/Actions/settingsActions.js | 5 + frontend/src/login.html | 60 ++++--- .../Authentication/AuthenticationType.cs | 4 +- .../Configuration/ConfigService.cs | 10 ++ .../Configuration/IConfigService.cs | 10 ++ src/NzbDrone.Core/Localization/Core/en.json | 5 + .../Notifications/Plex/PlexTv/PlexTvProxy.cs | 20 ++- .../Plex/PlexTv/PlexTvResource.cs | 13 ++ .../Plex/PlexTv/PlexTvService.cs | 8 + src/NzbDrone.Host/Startup.cs | 1 - .../AuthenticationController.cs | 28 ++++ .../Config/HostConfigController.cs | 11 +- .../Config/HostConfigResource.cs | 10 ++ .../Authentication/ApiKeyRequirement.cs | 2 +- .../AuthenticationBuilderExtensions.cs | 41 ++++- .../AuthenticationController.cs | 41 ++++- ...leDenyAnonymousAuthorizationRequirement.cs | 2 +- .../OpenIdConnect/ConfigureOidcOptions.cs | 34 ++++ .../Authentication/Plex/PlexConstants.cs | 9 ++ .../Authentication/Plex/PlexDefaults.cs | 17 ++ .../Authentication/Plex/PlexExtensions.cs | 16 ++ .../Authentication/Plex/PlexHandler.cs | 135 ++++++++++++++++ .../Authentication/Plex/PlexOptions.cs | 20 +++ .../Plex/PlexServerRequirement.cs | 52 ++++++ .../Authentication/UiAuthorizationHandler.cs | 2 +- .../UiAuthorizationPolicyProvider.cs | 13 +- .../Frontend/Mappers/LoginHtmlMapper.cs | 33 ++++ .../Frontend/StaticResourceController.cs | 5 +- src/Radarr.Http/Radarr.Http.csproj | 1 + 47 files changed, 1077 insertions(+), 159 deletions(-) create mode 100644 frontend/src/Components/Form/PlexMachineInput.js create mode 100644 frontend/src/Components/Form/PlexMachineInputConnector.js create mode 100644 frontend/src/Store/Actions/Settings/plex.js create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs create mode 100644 src/Radarr.Api.V4/Authentication/AuthenticationController.cs create mode 100644 src/Radarr.Http/Authentication/OpenIdConnect/ConfigureOidcOptions.cs create mode 100644 src/Radarr.Http/Authentication/Plex/PlexConstants.cs create mode 100644 src/Radarr.Http/Authentication/Plex/PlexDefaults.cs create mode 100644 src/Radarr.Http/Authentication/Plex/PlexExtensions.cs create mode 100644 src/Radarr.Http/Authentication/Plex/PlexHandler.cs create mode 100644 src/Radarr.Http/Authentication/Plex/PlexOptions.cs create mode 100644 src/Radarr.Http/Authentication/Plex/PlexServerRequirement.cs diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js index 6ec718b034..3a41cb6e12 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js +++ b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js @@ -5,6 +5,7 @@ import FormInputButton from 'Components/Form/FormInputButton'; import TextInput from 'Components/Form/TextInput'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; +import SpinnerButton from 'Components/Link/SpinnerButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Portal from 'Components/Portal'; import { icons, kinds } from 'Helpers/Props'; @@ -242,7 +243,7 @@ class ImportMovieSelectMovie extends Component { diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js index a7145363af..fe16fae6a6 100644 --- a/frontend/src/Components/Form/FormInputButton.js +++ b/frontend/src/Components/Form/FormInputButton.js @@ -2,33 +2,19 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; import { kinds } from 'Helpers/Props'; import styles from './FormInputButton.css'; function FormInputButton(props) { const { className, - canSpin, + ButtonComponent, isLastButton, ...otherProps } = props; - if (canSpin) { - return ( - - ); - } - return ( - + @@ -283,9 +286,14 @@ var copyDiv = document.getElementById("copy"); copyDiv.classList.remove("hidden"); - if (window.location.search.indexOf("loginFailed=true") > -1) { - var loginFailedDiv = document.getElementById("login-failed"); + var loginFailedDiv = document.getElementById("login-failed"); + if (window.location.pathname.indexOf("/sso") === -1) { + var userPassDiv = document.getElementById("user-pass"); + userPassDiv.classList.remove("hidden"); + } + + if (window.location.pathname.indexOf("/failed") > -1) { loginFailedDiv.classList.remove("hidden"); } diff --git a/src/NzbDrone.Core/Authentication/AuthenticationType.cs b/src/NzbDrone.Core/Authentication/AuthenticationType.cs index ca408774b0..0e8f5c49bc 100644 --- a/src/NzbDrone.Core/Authentication/AuthenticationType.cs +++ b/src/NzbDrone.Core/Authentication/AuthenticationType.cs @@ -5,6 +5,8 @@ public enum AuthenticationType None = 0, Basic = 1, Forms = 2, - External = 3 + External = 3, + Oidc = 4, + Plex = 5, } } diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index b0832d9bf2..7670e24d5e 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -403,6 +403,16 @@ public bool CleanupMetadataImages public string HmacSalt => GetValue("HmacSalt", Guid.NewGuid().ToString(), true); + public string PlexAuthServer => GetValue("PlexAuthServer", string.Empty); + + public bool PlexRequireOwner => GetValueBoolean("PlexRequireOwner", true); + + public string OidcClientId => GetValue("OidcClientId", string.Empty); + + public string OidcClientSecret => GetValue("OidcClientSecret", string.Empty); + + public string OidcAuthority => GetValue("OidcAuthority", string.Empty); + public bool ProxyEnabled => GetValueBoolean("ProxyEnabled", false); public ProxyType ProxyType => GetValueEnum("ProxyType", ProxyType.Http); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 2c5b3576e8..22c4de9eb4 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -88,6 +88,16 @@ public interface IConfigService string RijndaelSalt { get; } string HmacSalt { get; } + // Plex Auth + string PlexAuthServer { get; } + + bool PlexRequireOwner { get; } + + // OIDC Auth + string OidcClientId { get; } + string OidcClientSecret { get; } + string OidcAuthority { get; } + // Proxy bool ProxyEnabled { get; } ProxyType ProxyType { get; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index ed28961e40..ea4114dbbb 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -74,6 +74,7 @@ "Authentication": "Authentication", "AuthenticationMethodHelpText": "Require Username and Password to access Radarr", "AuthForm": "Forms (Login Page)", + "Authority": "Authority", "Auto": "Auto", "Automatic": "Automatic", "AutomaticSearch": "Automatic Search", @@ -133,7 +134,9 @@ "ClickToChangeMovie": "Click to change movie", "ClickToChangeQuality": "Click to change quality", "ClickToChangeReleaseGroup": "Click to change release group", + "ClientId": "ClientId", "ClientPriority": "Client Priority", + "ClientSecret": "ClientSecret", "CloneCustomFormat": "Clone Custom Format", "CloneFormatTag": "Clone Format Tag", "CloneIndexer": "Clone Indexer", @@ -696,6 +699,7 @@ "Permissions": "Permissions", "PhysicalRelease": "Physical Release", "PhysicalReleaseDate": "Physical Release Date", + "PlexServer": "Plex Server", "Port": "Port", "PortNumber": "Port Number", "PosterOptions": "Poster Options", @@ -860,6 +864,7 @@ "RestartRequiredHelpTextWarning": "Requires restart to take effect", "Restore": "Restore", "RestoreBackup": "Restore Backup", + "RestrictAccessToServerOwner": "Restrict Access to Server Owner", "Restrictions": "Restrictions", "Result": "Result", "Retention": "Retention", diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs index 0a299a4ba5..68a41bb918 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using NLog; using NzbDrone.Common.EnvironmentInfo; @@ -12,6 +13,7 @@ public interface IPlexTvProxy { string GetAuthToken(string clientIdentifier, int pinId); bool Ping(string clientIdentifier, string authToken); + List GetResources(string clientIdentifier, string token); } public class PlexTvProxy : IPlexTvProxy @@ -30,9 +32,7 @@ public string GetAuthToken(string clientIdentifier, int pinId) var request = BuildRequest(clientIdentifier); request.ResourceUrl = $"/api/v2/pins/{pinId}"; - PlexTvPinResponse response; - - if (!Json.TryDeserialize(ProcessRequest(request), out response)) + if (!Json.TryDeserialize(ProcessRequest(request), out var response)) { response = new PlexTvPinResponse(); } @@ -63,6 +63,20 @@ public bool Ping(string clientIdentifier, string authToken) return false; } + public List GetResources(string clientIdentifier, string token) + { + var request = BuildRequest(clientIdentifier); + request.AddQueryParam("X-Plex-Token", token); + request.ResourceUrl = "api/v2/resources"; + + if (!Json.TryDeserialize>(ProcessRequest(request), out var response)) + { + response = new List(); + } + + return response; + } + private HttpRequestBuilder BuildRequest(string clientIdentifier) { var requestBuilder = new HttpRequestBuilder("https://plex.tv") diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs new file mode 100644 index 0000000000..3bd20c4f05 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvResource.cs @@ -0,0 +1,13 @@ +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvResource + { + public string Name { get; set; } + public string Product { get; set; } + public string Platform { get; set; } + public string ClientIdentifier { get; set; } + public string Provides { get; set; } + public bool Owned { get; set; } + public bool Home { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs index 8aa324b7e5..08c3f8763d 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text; @@ -15,6 +16,8 @@ public interface IPlexTvService PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); string GetAuthToken(int pinId); void Ping(string authToken); + List GetResources(string token); + HttpRequest GetWatchlist(string authToken); } @@ -94,6 +97,11 @@ public void Ping(string authToken) _cache.Get(authToken, () => _proxy.Ping(_configService.PlexClientIdentifier, authToken), TimeSpan.FromHours(24)); } + public List GetResources(string token) + { + return _proxy.GetResources(_configService.PlexClientIdentifier, token); + } + public HttpRequest GetWatchlist(string authToken) { Ping(authToken); diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs index 5b8aed0587..9be9b778d6 100644 --- a/src/NzbDrone.Host/Startup.cs +++ b/src/NzbDrone.Host/Startup.cs @@ -22,7 +22,6 @@ using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; using NzbDrone.Host.AccessControl; -using NzbDrone.Http.Authentication; using NzbDrone.SignalR; using Radarr.Api.V4.System; using Radarr.Http; diff --git a/src/Radarr.Api.V4/Authentication/AuthenticationController.cs b/src/Radarr.Api.V4/Authentication/AuthenticationController.cs new file mode 100644 index 0000000000..fce1f1ef6b --- /dev/null +++ b/src/Radarr.Api.V4/Authentication/AuthenticationController.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Notifications.Plex.PlexTv; +using Radarr.Http; + +namespace Radarr.Api.V4.Authentication +{ + [V4ApiController] + public class AuthenticationController : Controller + { + private readonly IPlexTvService _plex; + + public AuthenticationController(IPlexTvService plex) + { + _plex = plex; + } + + [HttpGet("plex/resources")] + public List GetResources(string accessToken) + { + return _plex.GetResources(accessToken); + } + } +} diff --git a/src/Radarr.Api.V4/Config/HostConfigController.cs b/src/Radarr.Api.V4/Config/HostConfigController.cs index 3e502d5c6a..556701df21 100644 --- a/src/Radarr.Api.V4/Config/HostConfigController.cs +++ b/src/Radarr.Api.V4/Config/HostConfigController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -23,6 +24,8 @@ public class HostConfigController : RestController private readonly IConfigService _configService; private readonly IUserService _userService; + private static readonly List UserPassAuths = new List { AuthenticationType.Basic, AuthenticationType.Forms }; + public HostConfigController(IConfigFileProvider configFileProvider, IConfigService configService, IUserService userService, @@ -42,8 +45,12 @@ public HostConfigController(IConfigFileProvider configFileProvider, SharedValidator.RuleFor(c => c.UrlBase).ValidUrlBase(); SharedValidator.RuleFor(c => c.InstanceName).ContainsRadarr().When(c => c.InstanceName.IsNotNullOrWhiteSpace()); - SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None); - SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None); + SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => UserPassAuths.Contains(c.AuthenticationMethod)); + SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => UserPassAuths.Contains(c.AuthenticationMethod)); + SharedValidator.RuleFor(c => c.PlexAuthServer).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Plex); + SharedValidator.RuleFor(c => c.OidcAuthority).IsValidUrl().Must(x => x.StartsWith("https://")).WithMessage("Authority must use HTTPS").When(c => c.AuthenticationMethod == AuthenticationType.Oidc); + SharedValidator.RuleFor(c => c.OidcClientId).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Oidc); + SharedValidator.RuleFor(c => c.OidcClientSecret).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Oidc); SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl); SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).When(c => c.EnableSsl); diff --git a/src/Radarr.Api.V4/Config/HostConfigResource.cs b/src/Radarr.Api.V4/Config/HostConfigResource.cs index 8dd3fdfea2..1d1ca3d354 100644 --- a/src/Radarr.Api.V4/Config/HostConfigResource.cs +++ b/src/Radarr.Api.V4/Config/HostConfigResource.cs @@ -31,6 +31,11 @@ public class HostConfigResource : RestResource public bool UpdateAutomatically { get; set; } public UpdateMechanism UpdateMechanism { get; set; } public string UpdateScriptPath { get; set; } + public string PlexAuthServer { get; set; } + public bool PlexRequireOwner { get; set; } + public string OidcClientId { get; set; } + public string OidcClientSecret { get; set; } + public string OidcAuthority { get; set; } public bool ProxyEnabled { get; set; } public ProxyType ProxyType { get; set; } public string ProxyHostname { get; set; } @@ -74,6 +79,11 @@ public static HostConfigResource ToResource(this IConfigFileProvider model, ICon UpdateAutomatically = model.UpdateAutomatically, UpdateMechanism = model.UpdateMechanism, UpdateScriptPath = model.UpdateScriptPath, + PlexAuthServer = configService.PlexAuthServer, + PlexRequireOwner = configService.PlexRequireOwner, + OidcClientId = configService.OidcClientId, + OidcClientSecret = configService.OidcClientSecret, + OidcAuthority = configService.OidcAuthority, ProxyEnabled = configService.ProxyEnabled, ProxyType = configService.ProxyType, ProxyHostname = configService.ProxyHostname, diff --git a/src/Radarr.Http/Authentication/ApiKeyRequirement.cs b/src/Radarr.Http/Authentication/ApiKeyRequirement.cs index abe096ce93..e916740b07 100644 --- a/src/Radarr.Http/Authentication/ApiKeyRequirement.cs +++ b/src/Radarr.Http/Authentication/ApiKeyRequirement.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -namespace NzbDrone.Http.Authentication +namespace Radarr.Http.Authentication { public class ApiKeyRequirement : AuthorizationHandler, IAuthorizationRequirement { diff --git a/src/Radarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Radarr.Http/Authentication/AuthenticationBuilderExtensions.cs index 6bc763be4e..0b7e12048f 100644 --- a/src/Radarr.Http/Authentication/AuthenticationBuilderExtensions.cs +++ b/src/Radarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -1,7 +1,9 @@ using System; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Authentication; +using Radarr.Http.Authentication.Plex; namespace Radarr.Http.Authentication { @@ -22,25 +24,50 @@ public static AuthenticationBuilder AddNone(this AuthenticationBuilder authentic return authenticationBuilder.AddScheme(name, options => { }); } - public static AuthenticationBuilder AddExternal(this AuthenticationBuilder authenticationBuilder, string name) + public static string GetChallengeScheme(this AuthenticationType scheme) { - return authenticationBuilder.AddScheme(name, options => { }); + return scheme.ToString() + "Remote"; } public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection services) { - return services.AddAuthentication() + var builder = services.AddAuthentication() .AddNone(AuthenticationType.None.ToString()) - .AddExternal(AuthenticationType.External.ToString()) + .AddNone(AuthenticationType.External.ToString()) .AddBasic(AuthenticationType.Basic.ToString()) .AddCookie(AuthenticationType.Forms.ToString(), options => { - options.Cookie.Name = "RadarrAuth"; - options.AccessDeniedPath = "/login?loginFailed=true"; + options.Cookie.Name = BuildInfo.AppName + "Auth"; options.LoginPath = "/login"; + options.AccessDeniedPath = "/login/failed"; + options.LogoutPath = "/logout"; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; }) + .AddCookie(AuthenticationType.Plex.ToString(), options => + { + options.Cookie.Name = BuildInfo.AppName + "PlexAuth"; + options.LoginPath = "/login/sso"; + options.AccessDeniedPath = "/login/sso/failed"; + options.LogoutPath = "/logout"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.SlidingExpiration = true; + }) + .AddPlex(AuthenticationType.Plex.GetChallengeScheme(), options => + { + options.SignInScheme = AuthenticationType.Plex.ToString(); + options.CorrelationCookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; + }) + .AddCookie(AuthenticationType.Oidc.ToString(), options => + { + options.Cookie.Name = BuildInfo.AppName + "OidcAuth"; + options.LoginPath = "/login/sso"; + options.AccessDeniedPath = "/login/sso/failed"; + options.LogoutPath = "/logout"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.SlidingExpiration = true; + }) + .AddOpenIdConnect(AuthenticationType.Oidc.GetChallengeScheme(), _ => { } /* See ConfigureOidcOptions.cs */) .AddApiKey("API", options => { options.HeaderName = "X-Api-Key"; @@ -51,6 +78,8 @@ public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection options.HeaderName = "X-Api-Key"; options.QueryName = "access_token"; }); + + return builder; } } } diff --git a/src/Radarr.Http/Authentication/AuthenticationController.cs b/src/Radarr.Http/Authentication/AuthenticationController.cs index e796f92f4e..6de214c760 100644 --- a/src/Radarr.Http/Authentication/AuthenticationController.cs +++ b/src/Radarr.Http/Authentication/AuthenticationController.cs @@ -23,13 +23,24 @@ public AuthenticationController(IAuthenticationService authService, IConfigFileP } [HttpPost("login")] - public async Task Login([FromForm] LoginResource resource, [FromQuery] string returnUrl = null) + public Task LoginLogin([FromForm] LoginResource resource, [FromQuery] string returnUrl = "/") + { + if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms) + { + return LoginForms(resource, returnUrl); + } + + return LoginSso(resource, returnUrl); + } + + private async Task LoginForms(LoginResource resource, string returnUrl) { var user = _authService.Login(HttpContext.Request, resource.Username, resource.Password); if (user == null) { - return Redirect($"~/login?returnUrl={returnUrl}&loginFailed=true"); + await HttpContext.ForbidAsync(AuthenticationType.Forms.ToString()); + return; } var claims = new List @@ -41,20 +52,36 @@ public async Task Login([FromForm] LoginResource resource, [FromQ var authProperties = new AuthenticationProperties { - IsPersistent = resource.RememberMe == "on" + IsPersistent = resource.RememberMe == "on", + RedirectUri = returnUrl }; await HttpContext.SignInAsync(AuthenticationType.Forms.ToString(), new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties); + } - return Redirect(_configFileProvider.UrlBase + "/"); + private async Task LoginSso(LoginResource resource, string returnUrl = "/") + { + var authProperties = new AuthenticationProperties + { + IsPersistent = resource.RememberMe == "on", + RedirectUri = returnUrl + }; + + await HttpContext.ChallengeAsync(_configFileProvider.AuthenticationMethod.GetChallengeScheme(), authProperties); } [HttpGet("logout")] - public async Task Logout() + public async Task Logout() { _authService.Logout(HttpContext); - await HttpContext.SignOutAsync(AuthenticationType.Forms.ToString()); - return Redirect(_configFileProvider.UrlBase + "/"); + + var authType = _configFileProvider.AuthenticationMethod; + await HttpContext.SignOutAsync(authType.ToString()); + + if (authType == AuthenticationType.Oidc) + { + await HttpContext.SignOutAsync(authType.GetChallengeScheme()); + } } } } diff --git a/src/Radarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs b/src/Radarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs index 3ad4edcba4..5dc31d8fc5 100644 --- a/src/Radarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs +++ b/src/Radarr.Http/Authentication/BypassableDenyAnonymousAuthorizationRequirement.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization.Infrastructure; -namespace NzbDrone.Http.Authentication +namespace Radarr.Http.Authentication { public class BypassableDenyAnonymousAuthorizationRequirement : DenyAnonymousAuthorizationRequirement { diff --git a/src/Radarr.Http/Authentication/OpenIdConnect/ConfigureOidcOptions.cs b/src/Radarr.Http/Authentication/OpenIdConnect/ConfigureOidcOptions.cs new file mode 100644 index 0000000000..099b652bbf --- /dev/null +++ b/src/Radarr.Http/Authentication/OpenIdConnect/ConfigureOidcOptions.cs @@ -0,0 +1,34 @@ +using System; +using System.Diagnostics; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; + +namespace Radarr.Http.Authentication.OpenIdConnect +{ + public class ConfigureOidcOptions : IConfigureNamedOptions + { + private readonly IConfigService _configService; + + public ConfigureOidcOptions(IConfigService configService) + { + _configService = configService; + } + + public void Configure(string name, OpenIdConnectOptions options) + { + options.ClientId = _configService.OidcClientId.IsNullOrWhiteSpace() ? "dummy" : _configService.OidcClientId; + options.ClientSecret = _configService.OidcClientSecret.IsNullOrWhiteSpace() ? "dummy" : _configService.OidcClientSecret; + options.Authority = _configService.OidcAuthority.IsNullOrWhiteSpace() ? "https://dummy.com" : _configService.OidcAuthority; + options.SignedOutRedirectUri = "/login/sso"; + options.SignInScheme = AuthenticationType.Oidc.ToString(); + options.NonceCookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; + options.CorrelationCookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; + } + + public void Configure(OpenIdConnectOptions options) + => Debug.Fail("This infrastructure method shouldn't be called."); + } +} diff --git a/src/Radarr.Http/Authentication/Plex/PlexConstants.cs b/src/Radarr.Http/Authentication/Plex/PlexConstants.cs new file mode 100644 index 0000000000..7e34de12b9 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexConstants.cs @@ -0,0 +1,9 @@ +namespace Radarr.Http.Authentication.Plex +{ + public static class PlexConstants + { + public static readonly string PinId = "pin_id"; + public static readonly string ServerOwnedClaim = "plex:server:owned"; + public static readonly string ServerAccessClaim = "plex:server:access"; + } +} diff --git a/src/Radarr.Http/Authentication/Plex/PlexDefaults.cs b/src/Radarr.Http/Authentication/Plex/PlexDefaults.cs new file mode 100644 index 0000000000..47f9fe8d52 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexDefaults.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Radarr.Http.Authentication.Plex +{ + public static class PlexDefaults + { + public const string AuthenticationScheme = "Plex"; + public static readonly string DisplayName = "Plex"; + public static readonly string AuthorizationEndpoint = "https://plex.tv/api/v2/pins"; + public static readonly string TokenEndpoint = "https://app.plex.tv/auth/#!"; + public static readonly string UserInformationEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo"; + } +} diff --git a/src/Radarr.Http/Authentication/Plex/PlexExtensions.cs b/src/Radarr.Http/Authentication/Plex/PlexExtensions.cs new file mode 100644 index 0000000000..8829e07503 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexExtensions.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace Radarr.Http.Authentication.Plex +{ + public static class PlexExtensions + { + public static AuthenticationBuilder AddPlex(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) + => builder.AddOAuth(authenticationScheme, PlexDefaults.DisplayName, configureOptions); + } +} diff --git a/src/Radarr.Http/Authentication/Plex/PlexHandler.cs b/src/Radarr.Http/Authentication/Plex/PlexHandler.cs new file mode 100644 index 0000000000..4d811f2723 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexHandler.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using NzbDrone.Core.Notifications.Plex.PlexTv; + +namespace Radarr.Http.Authentication.Plex +{ + public class PlexHandler : OAuthHandler + { + private readonly IPlexTvService _plexTvService; + + public PlexHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IPlexTvService plexTvService) + : base(options, logger, encoder, clock) + { + _plexTvService = plexTvService; + } + + protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri) + { + var pinUrl = _plexTvService.GetPinUrl(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, pinUrl.Url); + requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var response = Backchannel.Send(requestMessage, Context.RequestAborted); + var pin = JsonSerializer.Deserialize(response.Content.ReadAsStream()); + + properties.Items.Add(PlexConstants.PinId, pin.id.ToString()); + + var state = Options.StateDataFormat.Protect(properties); + + var plexRedirectUrl = QueryHelpers.AddQueryString(redirectUri, new Dictionary { { "state", state } }); + + return _plexTvService.GetSignInUrl(plexRedirectUrl, pin.id, pin.code).OauthUrl; + } + + protected override async Task HandleRemoteAuthenticateAsync() + { + var query = Request.Query; + + var state = query["state"]; + var properties = Options.StateDataFormat.Unprotect(state); + + if (properties == null) + { + return HandleRequestResult.Fail("The oauth state was missing or invalid."); + } + + if (!properties.Items.TryGetValue(PlexConstants.PinId, out var code)) + { + return HandleRequestResult.Fail("The pin was missing or invalid."); + } + + if (!int.TryParse(code, out var _)) + { + return HandleRequestResult.Fail("The pin was in the wrong format."); + } + + var codeExchangeContext = new OAuthCodeExchangeContext(properties, code, BuildRedirectUri(Options.CallbackPath)); + using var tokens = await ExchangeCodeAsync(codeExchangeContext); + + if (tokens.Error != null) + { + return HandleRequestResult.Fail(tokens.Error); + } + + if (string.IsNullOrEmpty(tokens.AccessToken)) + { + return HandleRequestResult.Fail("Failed to retrieve access token."); + } + + var resources = _plexTvService.GetResources(tokens.AccessToken); + + var identity = new ClaimsIdentity(ClaimsIssuer); + + foreach (var resource in resources) + { + if (resource.Owned) + { + identity.AddClaim(new Claim(PlexConstants.ServerOwnedClaim, resource.ClientIdentifier)); + } + else + { + identity.AddClaim(new Claim(PlexConstants.ServerAccessClaim, resource.ClientIdentifier)); + } + } + + var ticket = await CreateTicketAsync(identity, properties, tokens); + if (ticket != null) + { + return HandleRequestResult.Success(ticket); + } + else + { + return HandleRequestResult.Fail("Failed to retrieve user information from remote server."); + } + } + + protected override Task ExchangeCodeAsync(OAuthCodeExchangeContext context) + { + var token = _plexTvService.GetAuthToken(int.Parse(context.Code)); + + var result = !StringValues.IsNullOrEmpty(token) switch + { + true => OAuthTokenResponse.Success(JsonDocument.Parse(string.Format("{{\"access_token\": \"{0}\"}}", token))), + false => OAuthTokenResponse.Failed(new Exception("No token returned")) + }; + + return Task.FromResult(result); + } + + private static OAuthTokenResponse PrepareFailedOAuthTokenReponse(HttpResponseMessage response, string body) + { + var errorMessage = $"OAuth token endpoint failure: Status: {response.StatusCode};Headers: {response.Headers};Body: {body};"; + return OAuthTokenResponse.Failed(new Exception(errorMessage)); + } + + private class PlexPinResponse + { + public int id { get; set; } + public string code { get; set; } + } + } +} diff --git a/src/Radarr.Http/Authentication/Plex/PlexOptions.cs b/src/Radarr.Http/Authentication/Plex/PlexOptions.cs new file mode 100644 index 0000000000..5fe17d5e06 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexOptions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Http; + +namespace Radarr.Http.Authentication.Plex +{ + public class PlexOptions : OAuthOptions + { + public PlexOptions() + { + CallbackPath = new PathString("/signin-plex"); + AuthorizationEndpoint = PlexDefaults.AuthorizationEndpoint; + TokenEndpoint = PlexDefaults.TokenEndpoint; + UserInformationEndpoint = PlexDefaults.UserInformationEndpoint; + } + + public override void Validate() + { + } + } +} diff --git a/src/Radarr.Http/Authentication/Plex/PlexServerRequirement.cs b/src/Radarr.Http/Authentication/Plex/PlexServerRequirement.cs new file mode 100644 index 0000000000..0855d83580 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexServerRequirement.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; +using NzbDrone.Core.Messaging.Events; + +namespace Radarr.Http.Authentication.Plex +{ + public class PlexServerRequirement : IAuthorizationRequirement + { + } + + public class PlexServerHandler : AuthorizationHandler, IHandle + { + private readonly IConfigService _configService; + private string _requiredServer; + private bool _requireOwner; + + public PlexServerHandler(IConfigService configService) + { + _configService = configService; + _requiredServer = configService.PlexAuthServer; + _requireOwner = configService.PlexRequireOwner; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PlexServerRequirement requirement) + { + var serverClaim = context.User.FindFirst(c => c.Type == PlexConstants.ServerOwnedClaim && c.Value == _requiredServer); + if (serverClaim != null) + { + context.Succeed(requirement); + } + + if (!_requireOwner) + { + serverClaim = context.User.FindFirst(c => c.Type == PlexConstants.ServerAccessClaim && c.Value == _requiredServer); + if (serverClaim != null) + { + context.Succeed(requirement); + } + } + + return Task.CompletedTask; + } + + public void Handle(ConfigSavedEvent message) + { + _requiredServer = _configService.PlexAuthServer; + _requireOwner = _configService.PlexRequireOwner; + } + } +} diff --git a/src/Radarr.Http/Authentication/UiAuthorizationHandler.cs b/src/Radarr.Http/Authentication/UiAuthorizationHandler.cs index fa77127ab2..51dfd2b4af 100644 --- a/src/Radarr.Http/Authentication/UiAuthorizationHandler.cs +++ b/src/Radarr.Http/Authentication/UiAuthorizationHandler.cs @@ -9,7 +9,7 @@ using NzbDrone.Core.Messaging.Events; using Radarr.Http.Extensions; -namespace NzbDrone.Http.Authentication +namespace Radarr.Http.Authentication { public class UiAuthorizationHandler : AuthorizationHandler, IAuthorizationRequirement, IHandle { diff --git a/src/Radarr.Http/Authentication/UiAuthorizationPolicyProvider.cs b/src/Radarr.Http/Authentication/UiAuthorizationPolicyProvider.cs index 50f1c3adab..ab9df5c3fe 100644 --- a/src/Radarr.Http/Authentication/UiAuthorizationPolicyProvider.cs +++ b/src/Radarr.Http/Authentication/UiAuthorizationPolicyProvider.cs @@ -2,9 +2,11 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; +using Radarr.Http.Authentication.Plex; -namespace NzbDrone.Http.Authentication +namespace Radarr.Http.Authentication { public class UiAuthorizationPolicyProvider : IAuthorizationPolicyProvider { @@ -28,10 +30,15 @@ public Task GetPolicyAsync(string policyName) { if (policyName.Equals(POLICY_NAME, StringComparison.OrdinalIgnoreCase)) { - var policy = new AuthorizationPolicyBuilder(_config.AuthenticationMethod.ToString()) + var builder = new AuthorizationPolicyBuilder(_config.AuthenticationMethod.ToString()) .AddRequirements(new BypassableDenyAnonymousAuthorizationRequirement()); - return Task.FromResult(policy.Build()); + if (_config.AuthenticationMethod == AuthenticationType.Plex) + { + builder.AddRequirements(new PlexServerRequirement()); + } + + return Task.FromResult(builder.Build()); } return FallbackPolicyProvider.GetPolicyAsync(policyName); diff --git a/src/Radarr.Http/Frontend/Mappers/LoginHtmlMapper.cs b/src/Radarr.Http/Frontend/Mappers/LoginHtmlMapper.cs index 184c826cf2..3493717f74 100644 --- a/src/Radarr.Http/Frontend/Mappers/LoginHtmlMapper.cs +++ b/src/Radarr.Http/Frontend/Mappers/LoginHtmlMapper.cs @@ -3,12 +3,15 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; namespace Radarr.Http.Frontend.Mappers { public class LoginHtmlMapper : HtmlMapperBase { + private readonly IConfigFileProvider _configFileProvider; + public LoginHtmlMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Lazy cacheBreakProviderFactory, @@ -16,6 +19,8 @@ public LoginHtmlMapper(IAppFolderInfo appFolderInfo, Logger logger) : base(diskProvider, cacheBreakProviderFactory, logger) { + _configFileProvider = configFileProvider; + HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); UrlBase = configFileProvider.UrlBase; } @@ -25,6 +30,34 @@ public override string Map(string resourceUrl) return HtmlPath; } + protected override Stream GetContentStream(string filePath) + { + var text = GetHtmlText(); + + var loginText = _configFileProvider.AuthenticationMethod switch + { + AuthenticationType.Plex => "Authenticate with Plex", + AuthenticationType.Oidc => "Authenticate with OpenID Connect", + _ => "Login" + }; + + var failedText = _configFileProvider.AuthenticationMethod switch + { + AuthenticationType.Forms => "Incorrect Username or Password", + _ => "Access Denied" + }; + + text = text.Replace("LOGIN_PLACEHOLDER", loginText); + text = text.Replace("FAILED_PLACEHOLDER", failedText); + + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(text); + writer.Flush(); + stream.Position = 0; + return stream; + } + public override bool CanHandle(string resourceUrl) { return resourceUrl.StartsWith("/login"); diff --git a/src/Radarr.Http/Frontend/StaticResourceController.cs b/src/Radarr.Http/Frontend/StaticResourceController.cs index e0eeeefc5e..9707415a40 100644 --- a/src/Radarr.Http/Frontend/StaticResourceController.cs +++ b/src/Radarr.Http/Frontend/StaticResourceController.cs @@ -4,8 +4,6 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using NLog; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Core.Configuration; using Radarr.Http.Extensions; using Radarr.Http.Frontend.Mappers; @@ -27,6 +25,9 @@ public StaticResourceController(IEnumerable requestMappe [AllowAnonymous] [HttpGet("login")] + [HttpGet("login/failed")] + [HttpGet("login/sso")] + [HttpGet("login/sso/failed")] public IActionResult LoginPage() { return MapResource("login"); diff --git a/src/Radarr.Http/Radarr.Http.csproj b/src/Radarr.Http/Radarr.Http.csproj index 098bf3a28b..4f5d01c524 100644 --- a/src/Radarr.Http/Radarr.Http.csproj +++ b/src/Radarr.Http/Radarr.Http.csproj @@ -3,6 +3,7 @@ net6.0 +