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 TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Portal from 'Components/Portal'; import Portal from 'Components/Portal';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
@ -242,7 +243,7 @@ class ImportMovieSelectMovie extends Component {
<FormInputButton <FormInputButton
kind={kinds.DEFAULT} kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH} spinnerIcon={icons.REFRESH}
canSpin={true} ButtonComponent={SpinnerButton}
isSpinning={isFetching} isSpinning={isFetching}
onPress={this.onRefreshPress} onPress={this.onRefreshPress}
> >

@ -2,33 +2,19 @@ import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css'; import styles from './FormInputButton.css';
function FormInputButton(props) { function FormInputButton(props) {
const { const {
className, className,
canSpin, ButtonComponent,
isLastButton, isLastButton,
...otherProps ...otherProps
} = props; } = props;
if (canSpin) {
return (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return ( return (
<Button <ButtonComponent
className={classNames( className={classNames(
className, className,
!isLastButton && styles.middleButton !isLastButton && styles.middleButton
@ -41,14 +27,14 @@ function FormInputButton(props) {
FormInputButton.propTypes = { FormInputButton.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired, ButtonComponent: PropTypes.elementType.isRequired,
canSpin: PropTypes.bool.isRequired isLastButton: PropTypes.bool.isRequired
}; };
FormInputButton.defaultProps = { FormInputButton.defaultProps = {
className: styles.button, className: styles.button,
isLastButton: true, ButtonComponent: Button,
canSpin: false isLastButton: true
}; };
export default FormInputButton; export default FormInputButton;

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

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

@ -5,6 +5,7 @@ import { kinds } from 'Helpers/Props';
function OAuthInput(props) { function OAuthInput(props) {
const { const {
className,
label, label,
authorizing, authorizing,
error, error,
@ -12,21 +13,21 @@ function OAuthInput(props) {
} = props; } = props;
return ( return (
<div> <SpinnerErrorButton
<SpinnerErrorButton className={className}
kind={kinds.PRIMARY} kind={kinds.PRIMARY}
isSpinning={authorizing} isSpinning={authorizing}
error={error} error={error}
onPress={onPress} onPress={onPress}
> >
{label} {label}
</SpinnerErrorButton> </SpinnerErrorButton>
</div>
); );
} }
OAuthInput.propTypes = { OAuthInput.propTypes = {
label: PropTypes.string.isRequired, className: PropTypes.string,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
authorizing: PropTypes.bool.isRequired, authorizing: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
onPress: PropTypes.func.isRequired 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'; composes: hasWarning from '~Components/Form/Input.css';
} }
.hasButton {
composes: hasButton from '~Components/Form/Input.css';
}
.isDisabled { .isDisabled {
opacity: 0.7; opacity: 0.7;
cursor: not-allowed; cursor: not-allowed;

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

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

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

@ -10,7 +10,7 @@ function createMapStateToProps() {
(state) => state.system.status, (state) => state.system.status,
(status) => { (status) => {
return { 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 React, { useEffect, useRef } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import OAuthInputConnector from 'Components/Form/OAuthInputConnector';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; 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 { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './AuthenticationRequiredModalContent.css'; 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() { function onModalClose() {
// No-op // No-op
} }
@ -22,6 +41,7 @@ function onModalClose() {
function AuthenticationRequiredModalContent(props) { function AuthenticationRequiredModalContent(props) {
const { const {
isPopulated, isPopulated,
plexServersPopulated,
error, error,
isSaving, isSaving,
settings, settings,
@ -34,10 +54,18 @@ function AuthenticationRequiredModalContent(props) {
authenticationMethod, authenticationMethod,
authenticationRequired, authenticationRequired,
username, username,
password password,
plexAuthServer,
plexRequireOwner,
oidcClientId,
oidcClientSecret,
oidcAuthority
} = settings; } = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; 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); const didMount = useRef(false);
@ -97,29 +125,111 @@ function AuthenticationRequiredModalContent(props) {
/> />
</FormGroup> </FormGroup>
<FormGroup> {
<FormLabel>{translate('Username')}</FormLabel> showUserPass &&
<>
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.TEXT} type={inputTypes.TEXT}
name="username" name="username"
onChange={onInputChange} onChange={onInputChange}
helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')} helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')}
{...username} {...username}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormLabel>{translate('Password')}</FormLabel> <FormLabel>{translate('Password')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.PASSWORD} type={inputTypes.PASSWORD}
name="password" name="password"
onChange={onInputChange} onChange={onInputChange}
helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')} helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')}
{...password} {...password}
/> />
</FormGroup> </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> : </div> :
null null
} }
@ -145,6 +255,7 @@ function AuthenticationRequiredModalContent(props) {
AuthenticationRequiredModalContent.propTypes = { AuthenticationRequiredModalContent.propTypes = {
isPopulated: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired,
plexServersPopulated: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,

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

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

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

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

@ -5,6 +5,7 @@ import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton'; import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import OAuthInputConnector from 'Components/Form/OAuthInputConnector';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import ClipboardButton from 'Components/Link/ClipboardButton'; import ClipboardButton from 'Components/Link/ClipboardButton';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
@ -37,6 +38,18 @@ export const authenticationMethodOptions = [
get value() { get value() {
return translate('AuthForm'); 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 { class SecuritySettings extends Component {
// //
@ -115,6 +144,7 @@ class SecuritySettings extends Component {
render() { render() {
const { const {
settings, settings,
plexServersPopulated,
isResettingApiKey, isResettingApiKey,
onInputChange onInputChange
} = this.props; } = this.props;
@ -124,11 +154,19 @@ class SecuritySettings extends Component {
authenticationRequired, authenticationRequired,
username, username,
password, password,
plexAuthServer,
plexRequireOwner,
oidcClientId,
oidcClientSecret,
oidcAuthority,
apiKey, apiKey,
certificateValidation certificateValidation
} = settings; } = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; 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 ( return (
<FieldSet legend={translate('Security')}> <FieldSet legend={translate('Security')}>
@ -164,33 +202,107 @@ class SecuritySettings extends Component {
} }
{ {
authenticationEnabled ? showUserPass &&
<FormGroup> <>
<FormLabel>{translate('Username')}</FormLabel> <FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
{...username}
/>
</FormGroup>
<FormInputGroup <FormGroup>
type={inputTypes.TEXT} <FormLabel>{translate('Password')}</FormLabel>
name="username"
onChange={onInputChange} <FormInputGroup
{...username} type={inputTypes.PASSWORD}
/> name="password"
</FormGroup> : onChange={onInputChange}
null {...password}
/>
</FormGroup>
</>
} }
{ {
authenticationEnabled ? plexEnabled &&
<FormGroup> <>
<FormLabel>{translate('Password')}</FormLabel> <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 <FormGroup>
type={inputTypes.PASSWORD} <FormLabel>{translate('RestrictAccessToServerOwner')}</FormLabel>
name="password"
onChange={onInputChange} <FormInputGroup
{...password} type={inputTypes.CHECK}
/> name="plexRequireOwner"
</FormGroup> : onChange={onInputChange}
null {...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> <FormGroup>
@ -254,6 +366,7 @@ class SecuritySettings extends Component {
SecuritySettings.propTypes = { SecuritySettings.propTypes = {
settings: PropTypes.object.isRequired, settings: PropTypes.object.isRequired,
plexServersPopulated: PropTypes.bool.isRequired,
isResettingApiKey: PropTypes.bool.isRequired, isResettingApiKey: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onConfirmResetApiKey: 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 naming from './Settings/naming';
import namingExamples from './Settings/namingExamples'; import namingExamples from './Settings/namingExamples';
import notifications from './Settings/notifications'; import notifications from './Settings/notifications';
import plex from './Settings/plex';
import qualityDefinitions from './Settings/qualityDefinitions'; import qualityDefinitions from './Settings/qualityDefinitions';
import qualityProfiles from './Settings/qualityProfiles'; import qualityProfiles from './Settings/qualityProfiles';
import releaseProfiles from './Settings/releaseProfiles'; import releaseProfiles from './Settings/releaseProfiles';
@ -49,6 +50,7 @@ export * from './Settings/metadataOptions';
export * from './Settings/naming'; export * from './Settings/naming';
export * from './Settings/namingExamples'; export * from './Settings/namingExamples';
export * from './Settings/notifications'; export * from './Settings/notifications';
export * from './Settings/plex';
export * from './Settings/qualityDefinitions'; export * from './Settings/qualityDefinitions';
export * from './Settings/qualityProfiles'; export * from './Settings/qualityProfiles';
export * from './Settings/remotePathMappings'; export * from './Settings/remotePathMappings';
@ -86,6 +88,7 @@ export const defaultState = {
naming: naming.defaultState, naming: naming.defaultState,
namingExamples: namingExamples.defaultState, namingExamples: namingExamples.defaultState,
notifications: notifications.defaultState, notifications: notifications.defaultState,
plex: plex.defaultState,
qualityDefinitions: qualityDefinitions.defaultState, qualityDefinitions: qualityDefinitions.defaultState,
qualityProfiles: qualityProfiles.defaultState, qualityProfiles: qualityProfiles.defaultState,
remotePathMappings: remotePathMappings.defaultState, remotePathMappings: remotePathMappings.defaultState,
@ -132,6 +135,7 @@ export const actionHandlers = handleThunks({
...naming.actionHandlers, ...naming.actionHandlers,
...namingExamples.actionHandlers, ...namingExamples.actionHandlers,
...notifications.actionHandlers, ...notifications.actionHandlers,
...plex.actionHandlers,
...qualityDefinitions.actionHandlers, ...qualityDefinitions.actionHandlers,
...qualityProfiles.actionHandlers, ...qualityProfiles.actionHandlers,
...remotePathMappings.actionHandlers, ...remotePathMappings.actionHandlers,
@ -169,6 +173,7 @@ export const reducers = createHandleActions({
...naming.reducers, ...naming.reducers,
...namingExamples.reducers, ...namingExamples.reducers,
...notifications.reducers, ...notifications.reducers,
...plex.reducers,
...qualityDefinitions.reducers, ...qualityDefinitions.reducers,
...qualityProfiles.reducers, ...qualityProfiles.reducers,
...remotePathMappings.reducers, ...remotePathMappings.reducers,

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

@ -5,6 +5,8 @@ namespace NzbDrone.Core.Authentication
None = 0, None = 0,
Basic = 1, Basic = 1,
Forms = 2, 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 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 bool ProxyEnabled => GetValueBoolean("ProxyEnabled", false);
public ProxyType ProxyType => GetValueEnum<ProxyType>("ProxyType", ProxyType.Http); public ProxyType ProxyType => GetValueEnum<ProxyType>("ProxyType", ProxyType.Http);

@ -89,6 +89,16 @@ namespace NzbDrone.Core.Configuration
string RijndaelSalt { get; } string RijndaelSalt { get; }
string HmacSalt { get; } string HmacSalt { get; }
// Plex Auth
string PlexAuthServer { get; }
bool PlexRequireOwner { get; }
// OIDC Auth
string OidcClientId { get; }
string OidcClientSecret { get; }
string OidcAuthority { get; }
// Proxy // Proxy
bool ProxyEnabled { get; } bool ProxyEnabled { get; }
ProxyType ProxyType { get; } ProxyType ProxyType { get; }

@ -79,6 +79,8 @@
"AudioLanguages": "Audio Languages", "AudioLanguages": "Audio Languages",
"AuthBasic": "Basic (Browser Popup)", "AuthBasic": "Basic (Browser Popup)",
"AuthForm": "Forms (Login Page)", "AuthForm": "Forms (Login Page)",
"AuthOidc": "OpenID Connect",
"AuthPlex": "Plex",
"Authentication": "Authentication", "Authentication": "Authentication",
"AuthenticationMethod": "Authentication Method", "AuthenticationMethod": "Authentication Method",
"AuthenticationMethodHelpText": "Require Username and Password to access {appName}", "AuthenticationMethodHelpText": "Require Username and Password to access {appName}",
@ -88,6 +90,7 @@
"AuthenticationRequiredPasswordHelpTextWarning": "Enter a new password", "AuthenticationRequiredPasswordHelpTextWarning": "Enter a new password",
"AuthenticationRequiredUsernameHelpTextWarning": "Enter a new username", "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.", "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", "Auto": "Auto",
"AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release", "AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release",
"AutoTagging": "Auto Tagging", "AutoTagging": "Auto Tagging",
@ -157,7 +160,9 @@
"ClickToChangeMovie": "Click to change movie", "ClickToChangeMovie": "Click to change movie",
"ClickToChangeQuality": "Click to change quality", "ClickToChangeQuality": "Click to change quality",
"ClickToChangeReleaseGroup": "Click to change release group", "ClickToChangeReleaseGroup": "Click to change release group",
"ClientId": "ClientId",
"ClientPriority": "Client Priority", "ClientPriority": "Client Priority",
"ClientSecret": "Client Secret",
"CloneAutoTag": "Clone Auto Tag", "CloneAutoTag": "Clone Auto Tag",
"CloneCondition": "Clone Condition", "CloneCondition": "Clone Condition",
"CloneCustomFormat": "Clone Custom Format", "CloneCustomFormat": "Clone Custom Format",
@ -826,6 +831,7 @@
"Permissions": "Permissions", "Permissions": "Permissions",
"PhysicalRelease": "Physical Release", "PhysicalRelease": "Physical Release",
"PhysicalReleaseDate": "Physical Release Date", "PhysicalReleaseDate": "Physical Release Date",
"PlexServer": "Plex Server",
"Popularity": "Popularity", "Popularity": "Popularity",
"PopularityIndex": "Current Popularity Index", "PopularityIndex": "Current Popularity Index",
"Port": "Port", "Port": "Port",
@ -1014,6 +1020,7 @@
"RestartRequiredHelpTextWarning": "Requires restart to take effect", "RestartRequiredHelpTextWarning": "Requires restart to take effect",
"Restore": "Restore", "Restore": "Restore",
"RestoreBackup": "Restore Backup", "RestoreBackup": "Restore Backup",
"RestrictAccessToServerOwner": "Restrict Access to Server Owner",
"Restrictions": "Restrictions", "Restrictions": "Restrictions",
"Result": "Result", "Result": "Result",
"Retention": "Retention", "Retention": "Retention",

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Net; using System.Net;
using NLog; using NLog;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
@ -12,6 +13,7 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
{ {
string GetAuthToken(string clientIdentifier, int pinId); string GetAuthToken(string clientIdentifier, int pinId);
bool Ping(string clientIdentifier, string authToken); bool Ping(string clientIdentifier, string authToken);
List<PlexTvResource> GetResources(string clientIdentifier, string token);
} }
public class PlexTvProxy : IPlexTvProxy public class PlexTvProxy : IPlexTvProxy
@ -61,6 +63,20 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
return false; 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) private HttpRequestBuilder BuildRequest(string clientIdentifier)
{ {
var requestBuilder = new HttpRequestBuilder("https://plex.tv") 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;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using NzbDrone.Common.Cache; using NzbDrone.Common.Cache;
@ -14,6 +15,8 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode);
string GetAuthToken(int pinId); string GetAuthToken(int pinId);
void Ping(string authToken); void Ping(string authToken);
List<PlexTvResource> GetResources(string token);
HttpRequest GetWatchlist(string authToken, int pageSize, int pageOffset); 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)); _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) public HttpRequest GetWatchlist(string authToken, int pageSize, int pageOffset)
{ {
Ping(authToken); Ping(authToken);

@ -22,7 +22,6 @@ using NzbDrone.Core.Instrumentation;
using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Host.AccessControl; using NzbDrone.Host.AccessControl;
using NzbDrone.Http.Authentication;
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Radarr.Api.V3.System; using Radarr.Api.V3.System;
using Radarr.Http; using Radarr.Http;
@ -176,20 +175,17 @@ namespace NzbDrone.Host
services.AddDataProtection() services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Configuration["dataProtectionFolder"])); .PersistKeysToFileSystem(new DirectoryInfo(Configuration["dataProtectionFolder"]));
services.AddSingleton<IAuthorizationPolicyProvider, UiAuthorizationPolicyProvider>();
services.AddSingleton<IAuthorizationHandler, UiAuthorizationHandler>();
services.AddAuthorization(options => services.AddAuthorization(options =>
{ {
options.AddPolicy("SignalR", policy => options.AddPolicy("SignalR", policy =>
{ {
policy.AuthenticationSchemes.Add("SignalR"); policy.AuthenticationSchemes.Add("SignalR");
policy.RequireAuthenticatedUser(); policy.Requirements.Add(new ApiKeyRequirement());
}); });
// Require auth on everything except those marked [AllowAnonymous] // Require auth on everything except those marked [AllowAnonymous]
options.FallbackPolicy = new AuthorizationPolicyBuilder("API") options.FallbackPolicy = new AuthorizationPolicyBuilder("API")
.RequireAuthenticatedUser() .AddRequirements(new ApiKeyRequirement())
.Build(); .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.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@ -23,6 +24,8 @@ namespace Radarr.Api.V3.Config
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IUserService _userService; private readonly IUserService _userService;
private static readonly List<AuthenticationType> UserPassAuths = new List<AuthenticationType> { AuthenticationType.Basic, AuthenticationType.Forms };
public HostConfigController(IConfigFileProvider configFileProvider, public HostConfigController(IConfigFileProvider configFileProvider,
IConfigService configService, IConfigService configService,
IUserService userService, IUserService userService,
@ -42,10 +45,12 @@ namespace Radarr.Api.V3.Config
SharedValidator.RuleFor(c => c.UrlBase).ValidUrlBase(); SharedValidator.RuleFor(c => c.UrlBase).ValidUrlBase();
SharedValidator.RuleFor(c => c.InstanceName).ContainsRadarr().When(c => c.InstanceName.IsNotNullOrWhiteSpace()); SharedValidator.RuleFor(c => c.InstanceName).ContainsRadarr().When(c => c.InstanceName.IsNotNullOrWhiteSpace());
SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Basic || SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => UserPassAuths.Contains(c.AuthenticationMethod));
c.AuthenticationMethod == AuthenticationType.Forms); SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => UserPassAuths.Contains(c.AuthenticationMethod));
SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Basic || SharedValidator.RuleFor(c => c.PlexAuthServer).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Plex);
c.AuthenticationMethod == AuthenticationType.Forms); 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).ValidPort().When(c => c.EnableSsl);
SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).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 bool UpdateAutomatically { get; set; }
public UpdateMechanism UpdateMechanism { get; set; } public UpdateMechanism UpdateMechanism { get; set; }
public string UpdateScriptPath { 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 bool ProxyEnabled { get; set; }
public ProxyType ProxyType { get; set; } public ProxyType ProxyType { get; set; }
public string ProxyHostname { get; set; } public string ProxyHostname { get; set; }
@ -74,6 +79,11 @@ namespace Radarr.Api.V3.Config
UpdateAutomatically = model.UpdateAutomatically, UpdateAutomatically = model.UpdateAutomatically,
UpdateMechanism = model.UpdateMechanism, UpdateMechanism = model.UpdateMechanism,
UpdateScriptPath = model.UpdateScriptPath, UpdateScriptPath = model.UpdateScriptPath,
PlexAuthServer = configService.PlexAuthServer,
PlexRequireOwner = configService.PlexRequireOwner,
OidcClientId = configService.OidcClientId,
OidcClientSecret = configService.OidcClientSecret,
OidcAuthority = configService.OidcAuthority,
ProxyEnabled = configService.ProxyEnabled, ProxyEnabled = configService.ProxyEnabled,
ProxyType = configService.ProxyType, ProxyType = configService.ProxyType,
ProxyHostname = configService.ProxyHostname, 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 System;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Authentication; using NzbDrone.Core.Authentication;
using Radarr.Http.Authentication.Plex;
namespace Radarr.Http.Authentication namespace Radarr.Http.Authentication
{ {
@ -22,25 +24,50 @@ namespace Radarr.Http.Authentication
return authenticationBuilder.AddScheme<AuthenticationSchemeOptions, NoAuthenticationHandler>(name, options => { }); 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) public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection services)
{ {
return services.AddAuthentication() var builder = services.AddAuthentication()
.AddNone(AuthenticationType.None.ToString()) .AddNone(AuthenticationType.None.ToString())
.AddExternal(AuthenticationType.External.ToString()) .AddNone(AuthenticationType.External.ToString())
.AddBasic(AuthenticationType.Basic.ToString()) .AddBasic(AuthenticationType.Basic.ToString())
.AddCookie(AuthenticationType.Forms.ToString(), options => .AddCookie(AuthenticationType.Forms.ToString(), options =>
{ {
options.Cookie.Name = "RadarrAuth"; options.Cookie.Name = BuildInfo.AppName + "Auth";
options.AccessDeniedPath = "/login?loginFailed=true";
options.LoginPath = "/login"; 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.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true; options.SlidingExpiration = true;
}) })
.AddOpenIdConnect(AuthenticationType.Oidc.GetChallengeScheme(), _ => { } /* See ConfigureOidcOptions.cs */)
.AddApiKey("API", options => .AddApiKey("API", options =>
{ {
options.HeaderName = "X-Api-Key"; options.HeaderName = "X-Api-Key";
@ -51,6 +78,8 @@ namespace Radarr.Http.Authentication
options.HeaderName = "X-Api-Key"; options.HeaderName = "X-Api-Key";
options.QueryName = "access_token"; options.QueryName = "access_token";
}); });
return builder;
} }
} }
} }

@ -23,13 +23,24 @@ namespace Radarr.Http.Authentication
} }
[HttpPost("login")] [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); var user = _authService.Login(HttpContext.Request, resource.Username, resource.Password);
if (user == null) if (user == null)
{ {
return Redirect($"~/login?returnUrl={returnUrl}&loginFailed=true"); await HttpContext.ForbidAsync(AuthenticationType.Forms.ToString());
return;
} }
var claims = new List<Claim> var claims = new List<Claim>
@ -41,20 +52,36 @@ namespace Radarr.Http.Authentication
var authProperties = new AuthenticationProperties 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); 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")] [HttpGet("logout")]
public async Task<IActionResult> Logout() public async Task Logout()
{ {
_authService.Logout(HttpContext); _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; using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace NzbDrone.Http.Authentication namespace Radarr.Http.Authentication
{ {
public class BypassableDenyAnonymousAuthorizationRequirement : DenyAnonymousAuthorizationRequirement 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 NzbDrone.Core.Messaging.Events;
using Radarr.Http.Extensions; using Radarr.Http.Extensions;
namespace NzbDrone.Http.Authentication namespace Radarr.Http.Authentication
{ {
public class UiAuthorizationHandler : AuthorizationHandler<BypassableDenyAnonymousAuthorizationRequirement>, IAuthorizationRequirement, IHandle<ConfigSavedEvent> public class UiAuthorizationHandler : AuthorizationHandler<BypassableDenyAnonymousAuthorizationRequirement>, IAuthorizationRequirement, IHandle<ConfigSavedEvent>
{ {

@ -2,9 +2,11 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using Radarr.Http.Authentication.Plex;
namespace NzbDrone.Http.Authentication namespace Radarr.Http.Authentication
{ {
public class UiAuthorizationPolicyProvider : IAuthorizationPolicyProvider public class UiAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{ {
@ -28,10 +30,15 @@ namespace NzbDrone.Http.Authentication
{ {
if (policyName.Equals(POLICY_NAME, StringComparison.OrdinalIgnoreCase)) if (policyName.Equals(POLICY_NAME, StringComparison.OrdinalIgnoreCase))
{ {
var policy = new AuthorizationPolicyBuilder(_config.AuthenticationMethod.ToString()) var builder = new AuthorizationPolicyBuilder(_config.AuthenticationMethod.ToString())
.AddRequirements(new BypassableDenyAnonymousAuthorizationRequirement()); .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); return FallbackPolicyProvider.GetPolicyAsync(policyName);

@ -3,12 +3,15 @@ using System.IO;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
namespace Radarr.Http.Frontend.Mappers namespace Radarr.Http.Frontend.Mappers
{ {
public class LoginHtmlMapper : HtmlMapperBase public class LoginHtmlMapper : HtmlMapperBase
{ {
private readonly IConfigFileProvider _configFileProvider;
public LoginHtmlMapper(IAppFolderInfo appFolderInfo, public LoginHtmlMapper(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider, IDiskProvider diskProvider,
Lazy<ICacheBreakerProvider> cacheBreakProviderFactory, Lazy<ICacheBreakerProvider> cacheBreakProviderFactory,
@ -16,6 +19,8 @@ namespace Radarr.Http.Frontend.Mappers
Logger logger) Logger logger)
: base(diskProvider, cacheBreakProviderFactory, logger) : base(diskProvider, cacheBreakProviderFactory, logger)
{ {
_configFileProvider = configFileProvider;
HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html");
UrlBase = configFileProvider.UrlBase; UrlBase = configFileProvider.UrlBase;
} }
@ -25,6 +30,34 @@ namespace Radarr.Http.Frontend.Mappers
return HtmlPath; 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) public override bool CanHandle(string resourceUrl)
{ {
return resourceUrl.StartsWith("/login"); return resourceUrl.StartsWith("/login");

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

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

Loading…
Cancel
Save