New: OIDC and Plex authentication methods

(cherry picked from commit 3ff3de6b90704fba266833115cd9d03ace99aae9)
zeus
ta264 2 years ago committed by Qstick
parent 775b1ba9cf
commit 18fc1413c3

@ -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 {
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
ButtonComponent={SpinnerButton}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>

@ -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 (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
<ButtonComponent
className={classNames(
className,
!isLastButton && styles.middleButton
@ -41,14 +27,14 @@ function FormInputButton(props) {
FormInputButton.propTypes = {
className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired,
canSpin: PropTypes.bool.isRequired
ButtonComponent: PropTypes.elementType.isRequired,
isLastButton: PropTypes.bool.isRequired
};
FormInputButton.defaultProps = {
className: styles.button,
isLastButton: true,
canSpin: false
ButtonComponent: Button,
isLastButton: true
};
export default FormInputButton;

@ -6,7 +6,6 @@
.inputGroup {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
}
.inputContainer {

@ -21,6 +21,7 @@ import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput';
import PathInputConnector from './PathInputConnector';
import PlexMachineInputConnector from './PlexMachineInputConnector';
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import TagInputConnector from './TagInputConnector';
@ -63,6 +64,9 @@ function getComponent(type) {
case inputTypes.PATH:
return PathInputConnector;
case inputTypes.PLEX_MACHINE_SELECT:
return PlexMachineInputConnector;
case inputTypes.QUALITY_PROFILE_SELECT:
return QualityProfileSelectInputConnector;

@ -5,6 +5,7 @@ import { kinds } from 'Helpers/Props';
function OAuthInput(props) {
const {
className,
label,
authorizing,
error,
@ -12,21 +13,21 @@ function OAuthInput(props) {
} = props;
return (
<div>
<SpinnerErrorButton
kind={kinds.PRIMARY}
isSpinning={authorizing}
error={error}
onPress={onPress}
>
{label}
</SpinnerErrorButton>
</div>
<SpinnerErrorButton
className={className}
kind={kinds.PRIMARY}
isSpinning={authorizing}
error={error}
onPress={onPress}
>
{label}
</SpinnerErrorButton>
);
}
OAuthInput.propTypes = {
label: PropTypes.string.isRequired,
className: PropTypes.string,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
authorizing: PropTypes.bool.isRequired,
error: PropTypes.object,
onPress: PropTypes.func.isRequired

@ -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);

@ -12,6 +12,10 @@
composes: hasWarning from '~Components/Form/Input.css';
}
.hasButton {
composes: hasButton from '~Components/Form/Input.css';
}
.isDisabled {
opacity: 0.7;
cursor: not-allowed;

@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'hasButton': string;
'hasError': string;
'hasWarning': string;
'isDisabled': string;

@ -28,6 +28,7 @@ class SelectInput extends Component {
isDisabled,
hasError,
hasWarning,
hasButton,
autoFocus,
onBlur
} = this.props;
@ -38,6 +39,7 @@ class SelectInput extends Component {
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
hasButton && styles.hasButton,
isDisabled && disabledClassName
)}
disabled={isDisabled}
@ -80,6 +82,7 @@ SelectInput.propTypes = {
isDisabled: PropTypes.bool,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
hasButton: PropTypes.bool,
autoFocus: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func

@ -12,7 +12,7 @@ import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
cookieAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
@ -56,22 +56,20 @@ function PageHeaderActionsMenu(props) {
</MenuItem>
{
formsAuth &&
<div className={styles.separator} />
}
{
formsAuth &&
<MenuItem
to={`${window.Radarr.urlBase}/logout`}
noRouter={true}
>
<Icon
className={styles.itemIcon}
name={icons.LOGOUT}
/>
Logout
</MenuItem>
cookieAuth &&
<>
<div className={styles.separator} />
<MenuItem
to={`${window.Radarr.urlBase}/logout?ReturnUrl=/`}
noRouter={true}
>
<Icon
className={styles.itemIcon}
name={icons.LOGOUT}
/>
Logout
</MenuItem>
</>
}
</MenuContent>
</Menu>
@ -80,7 +78,7 @@ function PageHeaderActionsMenu(props) {
}
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
cookieAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired

@ -10,7 +10,7 @@ function createMapStateToProps() {
(state) => state.system.status,
(status) => {
return {
formsAuth: status.item.authentication === 'forms'
cookieAuth: ['forms', 'oidc', 'plex'].includes(status.item.authentication)
};
}
);

@ -2,19 +2,38 @@ import PropTypes from 'prop-types';
import React, { useEffect, useRef } from 'react';
import Alert from 'Components/Alert';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import OAuthInputConnector from 'Components/Form/OAuthInputConnector';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings';
import translate from 'Utilities/String/translate';
import styles from './AuthenticationRequiredModalContent.css';
const oauthData = {
implementation: { value: 'PlexImport' },
configContract: { value: 'PlexListSettings' },
fields: [
{
type: 'textbox',
name: 'accessToken'
},
{
type: 'oAuth',
name: 'signIn',
value: 'startAuth'
}
]
};
function onModalClose() {
// No-op
}
@ -22,6 +41,7 @@ function onModalClose() {
function AuthenticationRequiredModalContent(props) {
const {
isPopulated,
plexServersPopulated,
error,
isSaving,
settings,
@ -34,10 +54,18 @@ function AuthenticationRequiredModalContent(props) {
authenticationMethod,
authenticationRequired,
username,
password
password,
plexAuthServer,
plexRequireOwner,
oidcClientId,
oidcClientSecret,
oidcAuthority
} = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
const showUserPass = authenticationMethod && ['basic', 'forms'].includes(authenticationMethod.value);
const plexEnabled = authenticationMethod && authenticationMethod.value === 'plex';
const oidcEnabled = authenticationMethod && authenticationMethod.value === 'oidc';
const didMount = useRef(false);
@ -97,29 +125,111 @@ function AuthenticationRequiredModalContent(props) {
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
{
showUserPass &&
<>
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')}
{...username}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')}
{...username}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
onChange={onInputChange}
helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')}
{...password}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
onChange={onInputChange}
helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')}
{...password}
/>
</FormGroup>
</>
}
{
plexEnabled &&
<>
<FormGroup>
<FormLabel>Plex Server</FormLabel>
<FormInputGroup
type={inputTypes.PLEX_MACHINE_SELECT}
name="plexAuthServer"
buttons={[
<FormInputButton
key="auth"
ButtonComponent={OAuthInputConnector}
label={plexServersPopulated ? <Icon name={icons.REFRESH} /> : 'Fetch'}
name="plexAuth"
provider="importList"
providerData={oauthData}
section="settings.importLists"
onChange={onInputChange}
/>
]}
onChange={onInputChange}
{...plexAuthServer}
/>
</FormGroup>
<FormGroup>
<FormLabel>Restrict Access to Server Owner</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="plexRequireOwner"
onChange={onInputChange}
{...plexRequireOwner}
/>
</FormGroup>
</>
}
{
oidcEnabled &&
<>
<FormGroup>
<FormLabel>Authority</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="oidcAuthority"
onChange={onInputChange}
{...oidcAuthority}
/>
</FormGroup>
<FormGroup>
<FormLabel>ClientId</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="oidcClientId"
onChange={onInputChange}
{...oidcClientId}
/>
</FormGroup>
<FormGroup>
<FormLabel>ClientSecret</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="oidcClientSecret"
onChange={onInputChange}
{...oidcClientSecret}
/>
</FormGroup>
</>
}
</div> :
null
}
@ -145,6 +255,7 @@ function AuthenticationRequiredModalContent(props) {
AuthenticationRequiredModalContent.propTypes = {
isPopulated: PropTypes.bool.isRequired,
plexServersPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,

@ -13,9 +13,11 @@ const SECTION = 'general';
function createMapStateToProps() {
return createSelector(
createSettingsSectionSelector(SECTION),
(sectionSettings) => {
(state) => state.settings.plex,
(sectionSettings, plex) => {
return {
...sectionSettings
...sectionSettings,
plexServersPopulated: plex.isPopulated
};
}
);

@ -10,6 +10,7 @@ export const NUMBER = 'number';
export const OAUTH = 'oauth';
export const PASSWORD = 'password';
export const PATH = 'path';
export const PLEX_MACHINE_SELECT = 'plexMachineSelect';
export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
export const INDEXER_SELECT = 'indexerSelect';
export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
@ -38,6 +39,7 @@ export const all = [
OAUTH,
PASSWORD,
PATH,
PLEX_MACHINE_SELECT,
QUALITY_PROFILE_SELECT,
INDEXER_SELECT,
DOWNLOAD_CLIENT_SELECT,

@ -107,6 +107,7 @@ class GeneralSettings extends Component {
packageUpdateMechanism,
onInputChange,
onConfirmResetApiKey,
plexServersPopulated,
...otherProps
} = this.props;
@ -145,6 +146,7 @@ class GeneralSettings extends Component {
<SecuritySettings
settings={settings}
plexServersPopulated={plexServersPopulated}
isResettingApiKey={isResettingApiKey}
onInputChange={onInputChange}
onConfirmResetApiKey={onConfirmResetApiKey}
@ -202,6 +204,7 @@ class GeneralSettings extends Component {
GeneralSettings.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
plexServersPopulated: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,

@ -17,12 +17,14 @@ const SECTION = 'general';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.plex,
createSettingsSectionSelector(SECTION),
createCommandExecutingSelector(commandNames.RESET_API_KEY),
createSystemStatusSelector(),
(advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => {
(advancedSettings, plexSettings, sectionSettings, isResettingApiKey, systemStatus) => {
return {
advancedSettings,
plexServersPopulated: plexSettings.isPopulated,
isResettingApiKey,
isWindows: systemStatus.isWindows,
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service',

@ -5,6 +5,7 @@ import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import OAuthInputConnector from 'Components/Form/OAuthInputConnector';
import Icon from 'Components/Icon';
import ClipboardButton from 'Components/Link/ClipboardButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
@ -37,6 +38,18 @@ export const authenticationMethodOptions = [
get value() {
return translate('AuthForm');
}
},
{
key: 'plex',
get value() {
return translate('AuthPlex');
}
},
{
key: 'oidc',
get value() {
return translate('AuthOidc');
}
}
];
@ -76,6 +89,22 @@ const certificateValidationOptions = [
}
];
const oauthData = {
implementation: { value: 'PlexImport' },
configContract: { value: 'PlexListSettings' },
fields: [
{
type: 'textbox',
name: 'accessToken'
},
{
type: 'oAuth',
name: 'signIn',
value: 'startAuth'
}
]
};
class SecuritySettings extends Component {
//
@ -115,6 +144,7 @@ class SecuritySettings extends Component {
render() {
const {
settings,
plexServersPopulated,
isResettingApiKey,
onInputChange
} = this.props;
@ -124,11 +154,19 @@ class SecuritySettings extends Component {
authenticationRequired,
username,
password,
plexAuthServer,
plexRequireOwner,
oidcClientId,
oidcClientSecret,
oidcAuthority,
apiKey,
certificateValidation
} = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
const showUserPass = authenticationMethod && ['basic', 'forms'].includes(authenticationMethod.value);
const plexEnabled = authenticationMethod && authenticationMethod.value === 'plex';
const oidcEnabled = authenticationMethod && authenticationMethod.value === 'oidc';
return (
<FieldSet legend={translate('Security')}>
@ -164,33 +202,107 @@ class SecuritySettings extends Component {
}
{
authenticationEnabled ?
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
showUserPass &&
<>
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
{...username}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
{...username}
/>
</FormGroup> :
null
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
onChange={onInputChange}
{...password}
/>
</FormGroup>
</>
}
{
authenticationEnabled ?
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
plexEnabled &&
<>
<FormGroup>
<FormLabel>{translate('PlexServer')}</FormLabel>
<FormInputGroup
type={inputTypes.PLEX_MACHINE_SELECT}
name="plexAuthServer"
buttons={[
<FormInputButton
key="auth"
ButtonComponent={OAuthInputConnector}
label={plexServersPopulated ? <Icon name={icons.REFRESH} /> : 'Fetch'}
name="plexAuth"
provider="importList"
providerData={oauthData}
section="settings.importLists"
onChange={onInputChange}
/>
]}
onChange={onInputChange}
{...plexAuthServer}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
onChange={onInputChange}
{...password}
/>
</FormGroup> :
null
<FormGroup>
<FormLabel>{translate('RestrictAccessToServerOwner')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="plexRequireOwner"
onChange={onInputChange}
{...plexRequireOwner}
/>
</FormGroup>
</>
}
{
oidcEnabled &&
<>
<FormGroup>
<FormLabel>{translate('Authority')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="oidcAuthority"
onChange={onInputChange}
{...oidcAuthority}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ClientId')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="oidcClientId"
onChange={onInputChange}
{...oidcClientId}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ClientSecret')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="oidcClientSecret"
onChange={onInputChange}
{...oidcClientSecret}
/>
</FormGroup>
</>
}
<FormGroup>
@ -254,6 +366,7 @@ class SecuritySettings extends Component {
SecuritySettings.propTypes = {
settings: PropTypes.object.isRequired,
plexServersPopulated: PropTypes.bool.isRequired,
isResettingApiKey: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onConfirmResetApiKey: PropTypes.func.isRequired

@ -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: { }
};

@ -22,6 +22,7 @@ import metadataOptions from './Settings/metadataOptions';
import naming from './Settings/naming';
import namingExamples from './Settings/namingExamples';
import notifications from './Settings/notifications';
import plex from './Settings/plex';
import qualityDefinitions from './Settings/qualityDefinitions';
import qualityProfiles from './Settings/qualityProfiles';
import releaseProfiles from './Settings/releaseProfiles';
@ -49,6 +50,7 @@ export * from './Settings/metadataOptions';
export * from './Settings/naming';
export * from './Settings/namingExamples';
export * from './Settings/notifications';
export * from './Settings/plex';
export * from './Settings/qualityDefinitions';
export * from './Settings/qualityProfiles';
export * from './Settings/remotePathMappings';
@ -86,6 +88,7 @@ export const defaultState = {
naming: naming.defaultState,
namingExamples: namingExamples.defaultState,
notifications: notifications.defaultState,
plex: plex.defaultState,
qualityDefinitions: qualityDefinitions.defaultState,
qualityProfiles: qualityProfiles.defaultState,
remotePathMappings: remotePathMappings.defaultState,
@ -132,6 +135,7 @@ export const actionHandlers = handleThunks({
...naming.actionHandlers,
...namingExamples.actionHandlers,
...notifications.actionHandlers,
...plex.actionHandlers,
...qualityDefinitions.actionHandlers,
...qualityProfiles.actionHandlers,
...remotePathMappings.actionHandlers,
@ -169,6 +173,7 @@ export const reducers = createHandleActions({
...naming.reducers,
...namingExamples.reducers,
...notifications.reducers,
...plex.reducers,
...qualityDefinitions.reducers,
...qualityProfiles.reducers,
...remotePathMappings.reducers,

@ -210,34 +210,37 @@
<form
role="form"
action="/login"
data-parsley-validate=""
novalidate=""
class="mb-lg"
method="POST"
>
<div class="form-group">
<input
type="email"
name="username"
class="form-input"
placeholder="Username"
autocomplete="off"
pattern=".{1,}"
required
title="User name is required"
autoFocus="true"
autoCapitalize="false"
/>
</div>
<div id="user-pass" class="hidden">
<div class="form-group">
<input
type="email"
name="username"
class="form-input"
placeholder="Username"
autocomplete="off"
pattern=".{1,}"
required
title="User name is required"
autoFocus="true"
autoCapitalize="false"
/>
</div>
<div class="form-group">
<input
type="password"
name="password"
class="form-input"
placeholder="Password"
required
/>
<div class="form-group">
<input
type="password"
name="password"
class="form-input"
placeholder="Password"
required
/>
</div>
</div>
<div class="remember-me-container">
@ -257,10 +260,10 @@
>Forgot your password?</a
>
</div>
<button type="submit" class="button">Login</button>
<button type="submit" class="button">LOGIN_PLACEHOLDER</button>
<div id="login-failed" class="login-failed hidden">
Incorrect Username or Password
FAILED_PLACEHOLDER
</div>
</form>
</div>
@ -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");
}
</script>

@ -5,6 +5,8 @@ namespace NzbDrone.Core.Authentication
None = 0,
Basic = 1,
Forms = 2,
External = 3
External = 3,
Oidc = 4,
Plex = 5,
}
}

@ -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", ProxyType.Http);

@ -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; }

@ -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",

@ -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<PlexTvResource> GetResources(string clientIdentifier, string token);
}
public class PlexTvProxy : IPlexTvProxy
@ -61,6 +63,20 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
return false;
}
public List<PlexTvResource> GetResources(string clientIdentifier, string token)
{
var request = BuildRequest(clientIdentifier);
request.AddQueryParam("X-Plex-Token", token);
request.ResourceUrl = "api/v2/resources";
if (!Json.TryDeserialize<List<PlexTvResource>>(ProcessRequest(request), out var response))
{
response = new List<PlexTvResource>();
}
return response;
}
private HttpRequestBuilder BuildRequest(string clientIdentifier)
{
var requestBuilder = new HttpRequestBuilder("https://plex.tv")

@ -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; }
}
}

@ -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<PlexTvResource> 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<PlexTvResource> GetResources(string token)
{
return _proxy.GetResources(_configService.PlexClientIdentifier, token);
}
public HttpRequest GetWatchlist(string authToken, int pageSize, int pageOffset)
{
Ping(authToken);

@ -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<IAuthorizationPolicyProvider, UiAuthorizationPolicyProvider>();
services.AddSingleton<IAuthorizationHandler, UiAuthorizationHandler>();
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();
});

@ -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);
}
}
}

@ -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<AuthenticationType> UserPassAuths = new List<AuthenticationType> { 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);

@ -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,

@ -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;
}
}
}

@ -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<AuthenticationSchemeOptions, NoAuthenticationHandler>(name, options => { });
}
public static AuthenticationBuilder AddExternal(this AuthenticationBuilder authenticationBuilder, string name)
public static string GetChallengeScheme(this AuthenticationType scheme)
{
return authenticationBuilder.AddScheme<AuthenticationSchemeOptions, NoAuthenticationHandler>(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;
}
}
}

@ -23,13 +23,24 @@ namespace Radarr.Http.Authentication
}
[HttpPost("login")]
public async Task<IActionResult> 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<Claim>
@ -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<IActionResult> 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());
}
}
}
}

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace NzbDrone.Http.Authentication
namespace Radarr.Http.Authentication
{
public class BypassableDenyAnonymousAuthorizationRequirement : DenyAnonymousAuthorizationRequirement
{

@ -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;
}
}
}

@ -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<BypassableDenyAnonymousAuthorizationRequirement>, IAuthorizationRequirement, IHandle<ConfigSavedEvent>
{

@ -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);

@ -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<ICacheBreakerProvider> 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");

@ -26,6 +26,9 @@ namespace Radarr.Http.Frontend
[AllowAnonymous]
[HttpGet("login")]
[HttpGet("login/failed")]
[HttpGet("login/sso")]
[HttpGet("login/sso/failed")]
public async Task<IActionResult> LoginPage()
{
return await MapResource("login");

@ -3,6 +3,7 @@
<TargetFrameworks>net6.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.13" />
<PackageReference Include="FluentValidation" Version="9.5.4" />
<PackageReference Include="ImpromptuInterface" Version="7.0.1" />
<PackageReference Include="NLog" Version="5.2.3" />

Loading…
Cancel
Save