(cherry picked from commit 3ff3de6b90704fba266833115cd9d03ace99aae9)zeus
parent
775b1ba9cf
commit
18fc1413c3
@ -0,0 +1,44 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
function PlexMachineInput(props) {
|
||||
const {
|
||||
isFetching,
|
||||
isDisabled,
|
||||
value,
|
||||
values,
|
||||
onChange,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const helpText = 'Authenticate with plex.tv to show servers to use for authentication';
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
isFetching ?
|
||||
<LoadingIndicator /> :
|
||||
<SelectInput
|
||||
value={value}
|
||||
values={values}
|
||||
isDisabled={isDisabled}
|
||||
onChange={onChange}
|
||||
helpText={helpText}
|
||||
{...otherProps}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PlexMachineInput.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
value: PropTypes.string,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PlexMachineInput;
|
@ -0,0 +1,115 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchPlexResources } from 'Store/Actions/settingsActions';
|
||||
import PlexMachineInput from './PlexMachineInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { value }) => value,
|
||||
(state) => state.oAuth,
|
||||
(state) => state.settings.plex,
|
||||
(value, oAuth, plex) => {
|
||||
|
||||
let values = [{ key: value, value }];
|
||||
let isDisabled = true;
|
||||
|
||||
if (plex.isPopulated) {
|
||||
const serverValues = plex.items.filter((item) => item.provides.includes('server')).map((item) => {
|
||||
return ({
|
||||
key: item.clientIdentifier,
|
||||
value: `${item.name} / ${item.owned ? 'Owner' : 'User'} / ${item.clientIdentifier}`
|
||||
});
|
||||
});
|
||||
|
||||
if (serverValues.find((item) => item.key === value)) {
|
||||
values = serverValues;
|
||||
} else {
|
||||
values = values.concat(serverValues);
|
||||
}
|
||||
|
||||
isDisabled = false;
|
||||
}
|
||||
|
||||
return ({
|
||||
accessToken: oAuth.result?.accessToken,
|
||||
values,
|
||||
isDisabled,
|
||||
...plex
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchPlexResources: fetchPlexResources
|
||||
};
|
||||
|
||||
class PlexMachineInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
componentDidMount = () => {
|
||||
const {
|
||||
accessToken,
|
||||
dispatchFetchPlexResources
|
||||
} = this.props;
|
||||
|
||||
if (accessToken) {
|
||||
dispatchFetchPlexResources({ accessToken });
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
accessToken,
|
||||
dispatchFetchPlexResources
|
||||
} = this.props;
|
||||
|
||||
const oldToken = prevProps.accessToken;
|
||||
if (accessToken && accessToken !== oldToken) {
|
||||
dispatchFetchPlexResources({ accessToken });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
isDisabled,
|
||||
value,
|
||||
values,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<PlexMachineInput
|
||||
isFetching={isFetching}
|
||||
isPopulated={isPopulated}
|
||||
isDisabled={isDisabled}
|
||||
value={value}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PlexMachineInputConnector.propTypes = {
|
||||
dispatchFetchPlexResources: PropTypes.func.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
oAuth: PropTypes.object,
|
||||
accessToken: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(PlexMachineInputConnector);
|
@ -0,0 +1,48 @@
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
const section = 'settings.plex';
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_PLEX_RESOURCES = 'settings/plex/fetchResources';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchPlexResources = createThunk(FETCH_PLEX_RESOURCES);
|
||||
|
||||
//
|
||||
// Details
|
||||
|
||||
export default {
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
defaultState: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
pendingChanges: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
items: []
|
||||
},
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
actionHandlers: {
|
||||
[FETCH_PLEX_RESOURCES]: createFetchHandler(section, '/authentication/plex/resources')
|
||||
},
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
reducers: { }
|
||||
};
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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<PlexTvResource> GetResources(string accessToken)
|
||||
{
|
||||
return _plex.GetResources(accessToken);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Radarr.Http.Authentication
|
||||
{
|
||||
public class ApiKeyRequirement : AuthorizationHandler<ApiKeyRequirement>, 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<OpenIdConnectOptions>
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -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<PlexOptions> configureOptions)
|
||||
=> builder.AddOAuth<PlexOptions, PlexHandler>(authenticationScheme, PlexDefaults.DisplayName, configureOptions);
|
||||
}
|
||||
}
|
@ -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<PlexOptions>
|
||||
{
|
||||
private readonly IPlexTvService _plexTvService;
|
||||
|
||||
public PlexHandler(IOptionsMonitor<PlexOptions> 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<PlexPinResponse>(response.Content.ReadAsStream());
|
||||
|
||||
properties.Items.Add(PlexConstants.PinId, pin.id.ToString());
|
||||
|
||||
var state = Options.StateDataFormat.Protect(properties);
|
||||
|
||||
var plexRedirectUrl = QueryHelpers.AddQueryString(redirectUri, new Dictionary<string, string> { { "state", state } });
|
||||
|
||||
return _plexTvService.GetSignInUrl(plexRedirectUrl, pin.id, pin.code).OauthUrl;
|
||||
}
|
||||
|
||||
protected override async Task<HandleRequestResult> 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<OAuthTokenResponse> 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; }
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@ -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<PlexServerRequirement>, IHandle<ConfigSavedEvent>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue