diff --git a/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js b/frontend/src/AddMovie/ImportMovie/Import/SelectMovie/ImportMovieSelectMovie.js index 6ec718b03..3a41cb6e1 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 a7145363a..fe16fae6a 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 ca408774b..0e8f5c49b 100644 --- a/src/NzbDrone.Core/Authentication/AuthenticationType.cs +++ b/src/NzbDrone.Core/Authentication/AuthenticationType.cs @@ -5,6 +5,8 @@ namespace NzbDrone.Core.Authentication 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 524fd5a41..78ddc65e5 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -409,6 +409,16 @@ namespace NzbDrone.Core.Configuration 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 41d4245d9..2b17e7930 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -89,6 +89,16 @@ namespace NzbDrone.Core.Configuration 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 a8e2960ce..8a4b82187 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -79,6 +79,8 @@ "AudioLanguages": "Audio Languages", "AuthBasic": "Basic (Browser Popup)", "AuthForm": "Forms (Login Page)", + "AuthOidc": "OpenID Connect", + "AuthPlex": "Plex", "Authentication": "Authentication", "AuthenticationMethod": "Authentication Method", "AuthenticationMethodHelpText": "Require Username and Password to access {appName}", @@ -88,6 +90,7 @@ "AuthenticationRequiredPasswordHelpTextWarning": "Enter a new password", "AuthenticationRequiredUsernameHelpTextWarning": "Enter a new username", "AuthenticationRequiredWarning": "To prevent remote access without authentication, {appName} now requires authentication to be enabled. You can optionally disable authentication from local addresses.", + "Authority": "Authority", "Auto": "Auto", "AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release", "AutoTagging": "Auto Tagging", @@ -157,7 +160,9 @@ "ClickToChangeMovie": "Click to change movie", "ClickToChangeQuality": "Click to change quality", "ClickToChangeReleaseGroup": "Click to change release group", + "ClientId": "ClientId", "ClientPriority": "Client Priority", + "ClientSecret": "Client Secret", "CloneAutoTag": "Clone Auto Tag", "CloneCondition": "Clone Condition", "CloneCustomFormat": "Clone Custom Format", @@ -826,6 +831,7 @@ "Permissions": "Permissions", "PhysicalRelease": "Physical Release", "PhysicalReleaseDate": "Physical Release Date", + "PlexServer": "Plex Server", "Popularity": "Popularity", "PopularityIndex": "Current Popularity Index", "Port": "Port", @@ -1014,6 +1020,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 39432f793..68a41bb91 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 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv { string GetAuthToken(string clientIdentifier, int pinId); bool Ping(string clientIdentifier, string authToken); + List GetResources(string clientIdentifier, string token); } public class PlexTvProxy : IPlexTvProxy @@ -61,6 +63,20 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv 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 000000000..3bd20c4f0 --- /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 404cfef0a..a8138e1d7 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 NzbDrone.Common.Cache; @@ -14,6 +15,8 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); string GetAuthToken(int pinId); void Ping(string authToken); + List GetResources(string token); + HttpRequest GetWatchlist(string authToken, int pageSize, int pageOffset); } @@ -93,6 +96,11 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv _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, int pageSize, int pageOffset) { Ping(authToken); diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs index acd429ac7..36feff362 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 Radarr.Api.V3.System; using Radarr.Http; @@ -176,20 +175,17 @@ namespace NzbDrone.Host services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Configuration["dataProtectionFolder"])); - services.AddSingleton(); - services.AddSingleton(); - services.AddAuthorization(options => { options.AddPolicy("SignalR", policy => { policy.AuthenticationSchemes.Add("SignalR"); - policy.RequireAuthenticatedUser(); + policy.Requirements.Add(new ApiKeyRequirement()); }); // Require auth on everything except those marked [AllowAnonymous] options.FallbackPolicy = new AuthorizationPolicyBuilder("API") - .RequireAuthenticatedUser() + .AddRequirements(new ApiKeyRequirement()) .Build(); }); diff --git a/src/Radarr.Api.V3/Authentication/AuthenticationController.cs b/src/Radarr.Api.V3/Authentication/AuthenticationController.cs new file mode 100644 index 000000000..bfc060882 --- /dev/null +++ b/src/Radarr.Api.V3/Authentication/AuthenticationController.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Notifications.Plex.PlexTv; +using Radarr.Http; + +namespace Radarr.Api.V3.Authentication +{ + [V3ApiController] + 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.V3/Config/HostConfigController.cs b/src/Radarr.Api.V3/Config/HostConfigController.cs index 23fb54b60..059419136 100644 --- a/src/Radarr.Api.V3/Config/HostConfigController.cs +++ b/src/Radarr.Api.V3/Config/HostConfigController.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -23,6 +24,8 @@ namespace Radarr.Api.V3.Config 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,10 +45,12 @@ namespace Radarr.Api.V3.Config 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.Basic || - c.AuthenticationMethod == AuthenticationType.Forms); - SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Basic || - c.AuthenticationMethod == AuthenticationType.Forms); + 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.V3/Config/HostConfigResource.cs b/src/Radarr.Api.V3/Config/HostConfigResource.cs index 7d4aed6f3..47917609b 100644 --- a/src/Radarr.Api.V3/Config/HostConfigResource.cs +++ b/src/Radarr.Api.V3/Config/HostConfigResource.cs @@ -31,6 +31,11 @@ namespace Radarr.Api.V3.Config 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 @@ namespace Radarr.Api.V3.Config 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 new file mode 100644 index 000000000..e916740b0 --- /dev/null +++ b/src/Radarr.Http/Authentication/ApiKeyRequirement.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Radarr.Http.Authentication +{ + public class ApiKeyRequirement : AuthorizationHandler, IAuthorizationRequirement + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ApiKeyRequirement requirement) + { + var apiKeyClaim = context.User.FindFirst(c => c.Type == "ApiKey"); + + if (apiKeyClaim != null) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Radarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Radarr.Http/Authentication/AuthenticationBuilderExtensions.cs index 6bc763be4..0b7e12048 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 @@ namespace Radarr.Http.Authentication 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 @@ namespace Radarr.Http.Authentication 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 e796f92f4..6de214c76 100644 --- a/src/Radarr.Http/Authentication/AuthenticationController.cs +++ b/src/Radarr.Http/Authentication/AuthenticationController.cs @@ -23,13 +23,24 @@ namespace Radarr.Http.Authentication } [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 @@ namespace Radarr.Http.Authentication 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); + } + + private async Task LoginSso(LoginResource resource, string returnUrl = "/") + { + var authProperties = new AuthenticationProperties + { + IsPersistent = resource.RememberMe == "on", + RedirectUri = returnUrl + }; - return Redirect(_configFileProvider.UrlBase + "/"); + 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 3ad4edcba..5dc31d8fc 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 000000000..2c0b4099b --- /dev/null +++ b/src/Radarr.Http/Authentication/OpenIdConnect/ConfigureOidcOptions.cs @@ -0,0 +1,33 @@ +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 000000000..7e34de12b --- /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 000000000..bcfb6d636 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexDefaults.cs @@ -0,0 +1,11 @@ +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 000000000..6b3a2fe71 --- /dev/null +++ b/src/Radarr.Http/Authentication/Plex/PlexExtensions.cs @@ -0,0 +1,12 @@ +using System; +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 000000000..4d811f272 --- /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 000000000..5fe17d5e0 --- /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 000000000..0855d8358 --- /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 fa77127ab..51dfd2b4a 100644 --- a/src/Radarr.Http/Authentication/UiAuthorizationHandler.cs +++ b/src/Radarr.Http/Authentication/UiAuthorizationHandler.cs @@ -9,7 +9,7 @@ using NzbDrone.Core.Configuration.Events; 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 50f1c3ada..ab9df5c3f 100644 --- a/src/Radarr.Http/Authentication/UiAuthorizationPolicyProvider.cs +++ b/src/Radarr.Http/Authentication/UiAuthorizationPolicyProvider.cs @@ -2,9 +2,11 @@ using System; 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 @@ namespace NzbDrone.Http.Authentication { 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 184c826cf..3493717f7 100644 --- a/src/Radarr.Http/Frontend/Mappers/LoginHtmlMapper.cs +++ b/src/Radarr.Http/Frontend/Mappers/LoginHtmlMapper.cs @@ -3,12 +3,15 @@ using System.IO; 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 @@ namespace Radarr.Http.Frontend.Mappers Logger logger) : base(diskProvider, cacheBreakProviderFactory, logger) { + _configFileProvider = configFileProvider; + HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); UrlBase = configFileProvider.UrlBase; } @@ -25,6 +30,34 @@ namespace Radarr.Http.Frontend.Mappers 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 f4158e40f..d4b7149a7 100644 --- a/src/Radarr.Http/Frontend/StaticResourceController.cs +++ b/src/Radarr.Http/Frontend/StaticResourceController.cs @@ -26,6 +26,9 @@ namespace Radarr.Http.Frontend [AllowAnonymous] [HttpGet("login")] + [HttpGet("login/failed")] + [HttpGet("login/sso")] + [HttpGet("login/sso/failed")] public async Task LoginPage() { return await MapResource("login"); diff --git a/src/Radarr.Http/Radarr.Http.csproj b/src/Radarr.Http/Radarr.Http.csproj index 68f543b14..b221adcfd 100644 --- a/src/Radarr.Http/Radarr.Http.csproj +++ b/src/Radarr.Http/Radarr.Http.csproj @@ -3,6 +3,7 @@ net6.0 +