mirror of
https://github.com/Radarr/Radarr.git
synced 2025-04-24 06:27:08 -04:00
Add OIDC and Plex authentication methods
(cherry picked from commit 3ff3de6b90704fba266833115cd9d03ace99aae9)
This commit is contained in:
parent
c5b12d074e
commit
4154414adf
47 changed files with 1077 additions and 159 deletions
|
@ -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 {
|
||||
|
|
|
@ -20,6 +20,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';
|
||||
|
@ -62,6 +63,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
|
||||
|
|
44
frontend/src/Components/Form/PlexMachineInput.js
Normal file
44
frontend/src/Components/Form/PlexMachineInput.js
Normal file
|
@ -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;
|
115
frontend/src/Components/Form/PlexMachineInputConnector.js
Normal file
115
frontend/src/Components/Form/PlexMachineInputConnector.js
Normal file
|
@ -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;
|
||||
|
|
|
@ -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,18 +2,37 @@ 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, authenticationRequiredWarning } from 'Settings/General/SecuritySettings';
|
||||
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
|
||||
}
|
||||
|
@ -21,6 +40,7 @@ function onModalClose() {
|
|||
function AuthenticationRequiredModalContent(props) {
|
||||
const {
|
||||
isPopulated,
|
||||
plexServersPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
settings,
|
||||
|
@ -33,10 +53,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);
|
||||
|
||||
|
@ -75,7 +103,7 @@ function AuthenticationRequiredModalContent(props) {
|
|||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText="Require Username and Password to access Sonarr"
|
||||
helpText="Require login to access Sonarr"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
|
@ -99,33 +127,107 @@ function AuthenticationRequiredModalContent(props) {
|
|||
}
|
||||
|
||||
{
|
||||
authenticationEnabled ?
|
||||
<FormGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
showUserPass &&
|
||||
<>
|
||||
<FormGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
}
|
||||
|
||||
{
|
||||
authenticationEnabled ?
|
||||
<FormGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
plexEnabled &&
|
||||
<>
|
||||
<FormGroup>
|
||||
<FormLabel>Plex Server</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
<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
|
||||
|
@ -152,6 +254,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
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -9,6 +9,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 ROOT_FOLDER_SELECT = 'rootFolderSelect';
|
||||
export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect';
|
||||
|
@ -35,6 +36,7 @@ export const all = [
|
|||
OAUTH,
|
||||
PASSWORD,
|
||||
PATH,
|
||||
PLEX_MACHINE_SELECT,
|
||||
QUALITY_PROFILE_SELECT,
|
||||
DOWNLOAD_CLIENT_SELECT,
|
||||
ROOT_FOLDER_SELECT,
|
||||
|
|
|
@ -106,6 +106,7 @@ class GeneralSettings extends Component {
|
|||
packageUpdateMechanism,
|
||||
onInputChange,
|
||||
onConfirmResetApiKey,
|
||||
plexServersPopulated,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
|
@ -144,6 +145,7 @@ class GeneralSettings extends Component {
|
|||
|
||||
<SecuritySettings
|
||||
settings={settings}
|
||||
plexServersPopulated={plexServersPopulated}
|
||||
isResettingApiKey={isResettingApiKey}
|
||||
onInputChange={onInputChange}
|
||||
onConfirmResetApiKey={onConfirmResetApiKey}
|
||||
|
@ -201,6 +203,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';
|
||||
|
@ -14,9 +15,11 @@ import translate from 'Utilities/String/translate';
|
|||
export const authenticationRequiredWarning = 'To prevent remote access without authentication, Radarr now requires authentication to be enabled. You can optionally disable authentication from local addresses.';
|
||||
|
||||
export const authenticationMethodOptions = [
|
||||
{ key: 'none', value: translate('None'), isDisabled: true },
|
||||
{ key: 'basic', value: translate('AuthBasic') },
|
||||
{ key: 'forms', value: translate('AuthForm') }
|
||||
{ key: 'none', value: 'None', isDisabled: true },
|
||||
{ key: 'basic', value: 'Basic (Browser Popup, insecure over HTTP)' },
|
||||
{ key: 'forms', value: 'Forms (Login Page)' },
|
||||
{ key: 'plex', value: 'Plex' },
|
||||
{ key: 'oidc', value: 'OpenID Connect' }
|
||||
];
|
||||
|
||||
export const authenticationRequiredOptions = [
|
||||
|
@ -30,6 +33,22 @@ const certificateValidationOptions = [
|
|||
{ key: 'disabled', value: translate('Disabled') }
|
||||
];
|
||||
|
||||
const oauthData = {
|
||||
implementation: { value: 'PlexImport' },
|
||||
configContract: { value: 'PlexListSettings' },
|
||||
fields: [
|
||||
{
|
||||
type: 'textbox',
|
||||
name: 'accessToken'
|
||||
},
|
||||
{
|
||||
type: 'oAuth',
|
||||
name: 'signIn',
|
||||
value: 'startAuth'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
class SecuritySettings extends Component {
|
||||
|
||||
//
|
||||
|
@ -69,6 +88,7 @@ class SecuritySettings extends Component {
|
|||
render() {
|
||||
const {
|
||||
settings,
|
||||
plexServersPopulated,
|
||||
isResettingApiKey,
|
||||
onInputChange
|
||||
} = this.props;
|
||||
|
@ -78,11 +98,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')}>
|
||||
|
@ -118,33 +146,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> :
|
||||
null
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<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.PASSWORD}
|
||||
name="password"
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
<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>{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>
|
||||
|
@ -208,6 +310,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
|
||||
|
|
48
frontend/src/Store/Actions/Settings/plex.js
Normal file
48
frontend/src/Store/Actions/Settings/plex.js
Normal file
|
@ -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: { }
|
||||
};
|
|
@ -20,6 +20,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 remotePathMappings from './Settings/remotePathMappings';
|
||||
|
@ -45,6 +46,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';
|
||||
|
@ -81,6 +83,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,
|
||||
|
@ -125,6 +128,7 @@ export const actionHandlers = handleThunks({
|
|||
...naming.actionHandlers,
|
||||
...namingExamples.actionHandlers,
|
||||
...notifications.actionHandlers,
|
||||
...plex.actionHandlers,
|
||||
...qualityDefinitions.actionHandlers,
|
||||
...qualityProfiles.actionHandlers,
|
||||
...remotePathMappings.actionHandlers,
|
||||
|
@ -160,6 +164,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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -403,6 +403,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);
|
||||
|
|
|
@ -88,6 +88,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; }
|
||||
|
|
|
@ -74,6 +74,7 @@
|
|||
"Authentication": "Authentication",
|
||||
"AuthenticationMethodHelpText": "Require Username and Password to access Radarr",
|
||||
"AuthForm": "Forms (Login Page)",
|
||||
"Authority": "Authority",
|
||||
"Auto": "Auto",
|
||||
"Automatic": "Automatic",
|
||||
"AutomaticSearch": "Automatic Search",
|
||||
|
@ -133,7 +134,9 @@
|
|||
"ClickToChangeMovie": "Click to change movie",
|
||||
"ClickToChangeQuality": "Click to change quality",
|
||||
"ClickToChangeReleaseGroup": "Click to change release group",
|
||||
"ClientId": "ClientId",
|
||||
"ClientPriority": "Client Priority",
|
||||
"ClientSecret": "ClientSecret",
|
||||
"CloneCustomFormat": "Clone Custom Format",
|
||||
"CloneFormatTag": "Clone Format Tag",
|
||||
"CloneIndexer": "Clone Indexer",
|
||||
|
@ -696,6 +699,7 @@
|
|||
"Permissions": "Permissions",
|
||||
"PhysicalRelease": "Physical Release",
|
||||
"PhysicalReleaseDate": "Physical Release Date",
|
||||
"PlexServer": "Plex Server",
|
||||
"Port": "Port",
|
||||
"PortNumber": "Port Number",
|
||||
"PosterOptions": "Poster Options",
|
||||
|
@ -860,6 +864,7 @@
|
|||
"RestartRequiredHelpTextWarning": "Requires restart to take effect",
|
||||
"Restore": "Restore",
|
||||
"RestoreBackup": "Restore Backup",
|
||||
"RestrictAccessToServerOwner": "Restrict Access to Server Owner",
|
||||
"Restrictions": "Restrictions",
|
||||
"Result": "Result",
|
||||
"Retention": "Retention",
|
||||
|
|
|
@ -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
|
||||
|
@ -30,9 +32,7 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
|
|||
var request = BuildRequest(clientIdentifier);
|
||||
request.ResourceUrl = $"/api/v2/pins/{pinId}";
|
||||
|
||||
PlexTvPinResponse response;
|
||||
|
||||
if (!Json.TryDeserialize<PlexTvPinResponse>(ProcessRequest(request), out response))
|
||||
if (!Json.TryDeserialize<PlexTvPinResponse>(ProcessRequest(request), out var response))
|
||||
{
|
||||
response = new PlexTvPinResponse();
|
||||
}
|
||||
|
@ -63,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 System.Text;
|
||||
|
@ -15,6 +16,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);
|
||||
}
|
||||
|
||||
|
@ -94,6 +97,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)
|
||||
{
|
||||
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.V4.System;
|
||||
using Radarr.Http;
|
||||
|
|
28
src/Radarr.Api.V4/Authentication/AuthenticationController.cs
Normal file
28
src/Radarr.Api.V4/Authentication/AuthenticationController.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.Notifications.Plex.PlexTv;
|
||||
using Radarr.Http;
|
||||
|
||||
namespace Radarr.Api.V4.Authentication
|
||||
{
|
||||
[V4ApiController]
|
||||
public class AuthenticationController : Controller
|
||||
{
|
||||
private readonly IPlexTvService _plex;
|
||||
|
||||
public AuthenticationController(IPlexTvService plex)
|
||||
{
|
||||
_plex = plex;
|
||||
}
|
||||
|
||||
[HttpGet("plex/resources")]
|
||||
public List<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.V4.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,8 +45,12 @@ namespace Radarr.Api.V4.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.None);
|
||||
SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None);
|
||||
SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => UserPassAuths.Contains(c.AuthenticationMethod));
|
||||
SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => UserPassAuths.Contains(c.AuthenticationMethod));
|
||||
SharedValidator.RuleFor(c => c.PlexAuthServer).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Plex);
|
||||
SharedValidator.RuleFor(c => c.OidcAuthority).IsValidUrl().Must(x => x.StartsWith("https://")).WithMessage("Authority must use HTTPS").When(c => c.AuthenticationMethod == AuthenticationType.Oidc);
|
||||
SharedValidator.RuleFor(c => c.OidcClientId).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Oidc);
|
||||
SharedValidator.RuleFor(c => c.OidcClientSecret).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Oidc);
|
||||
|
||||
SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl);
|
||||
SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).When(c => c.EnableSsl);
|
||||
|
|
|
@ -31,6 +31,11 @@ namespace Radarr.Api.V4.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.V4.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,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace NzbDrone.Http.Authentication
|
||||
namespace Radarr.Http.Authentication
|
||||
{
|
||||
public class ApiKeyRequirement : AuthorizationHandler<ApiKeyRequirement>, IAuthorizationRequirement
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
return Redirect(_configFileProvider.UrlBase + "/");
|
||||
private async Task LoginSso(LoginResource resource, string returnUrl = "/")
|
||||
{
|
||||
var authProperties = new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = resource.RememberMe == "on",
|
||||
RedirectUri = returnUrl
|
||||
};
|
||||
|
||||
await HttpContext.ChallengeAsync(_configFileProvider.AuthenticationMethod.GetChallengeScheme(), authProperties);
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
public async Task<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,34 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Authentication;
|
||||
using NzbDrone.Core.Configuration;
|
||||
|
||||
namespace Radarr.Http.Authentication.OpenIdConnect
|
||||
{
|
||||
public class ConfigureOidcOptions : IConfigureNamedOptions<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.");
|
||||
}
|
||||
}
|
9
src/Radarr.Http/Authentication/Plex/PlexConstants.cs
Normal file
9
src/Radarr.Http/Authentication/Plex/PlexConstants.cs
Normal file
|
@ -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";
|
||||
}
|
||||
}
|
17
src/Radarr.Http/Authentication/Plex/PlexDefaults.cs
Normal file
17
src/Radarr.Http/Authentication/Plex/PlexDefaults.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Radarr.Http.Authentication.Plex
|
||||
{
|
||||
public static class PlexDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "Plex";
|
||||
public static readonly string DisplayName = "Plex";
|
||||
public static readonly string AuthorizationEndpoint = "https://plex.tv/api/v2/pins";
|
||||
public static readonly string TokenEndpoint = "https://app.plex.tv/auth/#!";
|
||||
public static readonly string UserInformationEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo";
|
||||
}
|
||||
}
|
16
src/Radarr.Http/Authentication/Plex/PlexExtensions.cs
Normal file
16
src/Radarr.Http/Authentication/Plex/PlexExtensions.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Radarr.Http.Authentication.Plex
|
||||
{
|
||||
public static class PlexExtensions
|
||||
{
|
||||
public static AuthenticationBuilder AddPlex(this AuthenticationBuilder builder, string authenticationScheme, Action<PlexOptions> configureOptions)
|
||||
=> builder.AddOAuth<PlexOptions, PlexHandler>(authenticationScheme, PlexDefaults.DisplayName, configureOptions);
|
||||
}
|
||||
}
|
135
src/Radarr.Http/Authentication/Plex/PlexHandler.cs
Normal file
135
src/Radarr.Http/Authentication/Plex/PlexHandler.cs
Normal file
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
20
src/Radarr.Http/Authentication/Plex/PlexOptions.cs
Normal file
20
src/Radarr.Http/Authentication/Plex/PlexOptions.cs
Normal file
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
52
src/Radarr.Http/Authentication/Plex/PlexServerRequirement.cs
Normal file
52
src/Radarr.Http/Authentication/Plex/PlexServerRequirement.cs
Normal file
|
@ -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");
|
||||
|
|
|
@ -4,8 +4,6 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using Radarr.Http.Extensions;
|
||||
using Radarr.Http.Frontend.Mappers;
|
||||
|
||||
|
@ -27,6 +25,9 @@ namespace Radarr.Http.Frontend
|
|||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("login")]
|
||||
[HttpGet("login/failed")]
|
||||
[HttpGet("login/sso")]
|
||||
[HttpGet("login/sso/failed")]
|
||||
public IActionResult LoginPage()
|
||||
{
|
||||
return MapResource("login");
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.5" />
|
||||
<PackageReference Include="FluentValidation" Version="8.6.2" />
|
||||
<PackageReference Include="ImpromptuInterface" Version="7.0.1" />
|
||||
<PackageReference Include="NLog" Version="5.0.1" />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue