Update totp disable modal; require password for enable operation

This commit is contained in:
DaneEveritt 2022-07-03 14:27:37 -04:00
parent 92926ca193
commit 2d836156d2
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
10 changed files with 182 additions and 121 deletions

View file

@ -8,7 +8,6 @@ use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity; use Pterodactyl\Facades\Activity;
use Illuminate\Contracts\Validation\Factory; use Illuminate\Contracts\Validation\Factory;
use Illuminate\Validation\ValidationException;
use Pterodactyl\Services\Users\TwoFactorSetupService; use Pterodactyl\Services\Users\TwoFactorSetupService;
use Pterodactyl\Services\Users\ToggleTwoFactorService; use Pterodactyl\Services\Users\ToggleTwoFactorService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@ -73,22 +72,20 @@ class TwoFactorController extends ClientApiController
* *
* @throws \Throwable * @throws \Throwable
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
* @throws \Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid
*/ */
public function store(Request $request) public function store(Request $request)
{ {
$validator = $this->validation->make($request->all(), [ $validator = $this->validation->make($request->all(), [
'code' => 'required|string', 'code' => ['required', 'string', 'size:6'],
'password' => ['required', 'string'],
]); ]);
if ($validator->fails()) { $data = $validator->validate();
throw new ValidationException($validator); if (!password_verify($data['password'], $request->user()->password)) {
throw new BadRequestHttpException('The password provided was not valid.');
} }
$tokens = $this->toggleTwoFactorService->handle($request->user(), $request->input('code'), true); $tokens = $this->toggleTwoFactorService->handle($request->user(), $data['code'], true);
Activity::event('user:two-factor.create')->log(); Activity::event('user:two-factor.create')->log();
@ -105,6 +102,7 @@ class TwoFactorController extends ClientApiController
* is valid. * is valid.
* *
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* @throws \Throwable
*/ */
public function delete(Request $request) public function delete(Request $request)
{ {

View file

@ -1,7 +1,7 @@
import http from '@/api/http'; import http from '@/api/http';
export default async (code: string): Promise<string[]> => { export default async (code: string, password: string): Promise<string[]> => {
const { data } = await http.post('/api/client/account/two-factor', { code }); const { data } = await http.post('/api/client/account/two-factor', { code, password });
return data.attributes.tokens; return data.attributes.tokens;
}; };

View file

@ -1,16 +1,24 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { Button } from '@/components/elements/button/index'; import { Button } from '@/components/elements/button/index';
import DisableTwoFactorModal from '@/components/dashboard/forms/DisableTwoFactorModal'; import SetupTOTPDialog from '@/components/dashboard/forms/SetupTOTPDialog';
import SetupTOTPModal from '@/components/dashboard/forms/SetupTOTPModal';
import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog'; import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog';
import DisableTOTPDialog from '@/components/dashboard/forms/DisableTOTPDialog';
import { useFlashKey } from '@/plugins/useFlash';
export default () => { export default () => {
const [tokens, setTokens] = useState<string[]>([]); const [tokens, setTokens] = useState<string[]>([]);
const [visible, setVisible] = useState<'enable' | 'disable' | null>(null); const [visible, setVisible] = useState<'enable' | 'disable' | null>(null);
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp); const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
const { clearAndAddHttpError } = useFlashKey('account:two-step');
useEffect(() => {
return () => {
clearAndAddHttpError();
};
}, [visible]);
const onTokens = (tokens: string[]) => { const onTokens = (tokens: string[]) => {
setTokens(tokens); setTokens(tokens);
@ -19,9 +27,9 @@ export default () => {
return ( return (
<div> <div>
<SetupTOTPModal open={visible === 'enable'} onClose={() => setVisible(null)} onTokens={onTokens} /> <SetupTOTPDialog open={visible === 'enable'} onClose={() => setVisible(null)} onTokens={onTokens} />
<RecoveryTokensDialog tokens={tokens} open={tokens.length > 0} onClose={() => setTokens([])} /> <RecoveryTokensDialog tokens={tokens} open={tokens.length > 0} onClose={() => setTokens([])} />
<DisableTwoFactorModal visible={visible === 'disable'} onModalDismissed={() => setVisible(null)} /> <DisableTOTPDialog open={visible === 'disable'} onClose={() => setVisible(null)} />
<p css={tw`text-sm`}> <p css={tw`text-sm`}>
{isEnabled {isEnabled
? 'Two-step verification is currently enabled on your account.' ? 'Two-step verification is currently enabled on your account.'

View file

@ -0,0 +1,72 @@
import React, { useContext, useEffect, useState } from 'react';
import asDialog from '@/hoc/asDialog';
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
import { Button } from '@/components/elements/button/index';
import { Input } from '@/components/elements/inputs';
import Tooltip from '@/components/elements/tooltip/Tooltip';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import { useFlashKey } from '@/plugins/useFlash';
import { useStoreActions } from '@/state/hooks';
import FlashMessageRender from '@/components/FlashMessageRender';
const DisableTOTPDialog = () => {
const [submitting, setSubmitting] = useState(false);
const [password, setPassword] = useState('');
const { clearAndAddHttpError } = useFlashKey('account:two-step');
const { close, setProps } = useContext(DialogWrapperContext);
const updateUserData = useStoreActions((actions) => actions.user.updateUserData);
useEffect(() => {
setProps((state) => ({ ...state, preventExternalClose: submitting }));
}, [submitting]);
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
if (submitting) return;
setSubmitting(true);
clearAndAddHttpError();
disableAccountTwoFactor(password)
.then(() => {
updateUserData({ useTotp: false });
close();
})
.catch(clearAndAddHttpError)
.then(() => setSubmitting(false));
};
return (
<form id={'disable-totp-form'} className={'mt-6'} onSubmit={submit}>
<FlashMessageRender byKey={'account:two-step'} className={'-mt-2 mb-6'} />
<label className={'block pb-1'} htmlFor={'totp-password'}>
Password
</label>
<Input.Text
id={'totp-password'}
type={'password'}
variant={Input.Text.Variants.Loose}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Dialog.Footer>
<Button.Text onClick={close}>Cancel</Button.Text>
<Tooltip
delay={100}
disabled={password.length > 0}
content={'You must enter your account password to continue.'}
>
<Button.Danger type={'submit'} form={'disable-totp-form'} disabled={submitting || !password.length}>
Disable
</Button.Danger>
</Tooltip>
</Dialog.Footer>
</form>
);
};
export default asDialog({
title: 'Disable Two-Step Verification',
description: 'Disabling two-step verification will make your account less secure.',
})(DisableTOTPDialog);

View file

@ -1,73 +0,0 @@
import React, { useContext } from 'react';
import { Form, Formik, FormikHelpers } from 'formik';
import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
import { object, string } from 'yup';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
interface Values {
password: string;
}
const DisableTwoFactorModal = () => {
const { dismiss, setPropOverrides } = useContext(ModalContext);
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const submit = ({ password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setPropOverrides({ showSpinnerOverlay: true, dismissable: false });
disableAccountTwoFactor(password)
.then(() => {
updateUserData({ useTotp: false });
dismiss();
})
.catch((error) => {
console.error(error);
clearAndAddHttpError({ error, key: 'account:two-factor' });
setSubmitting(false);
setPropOverrides(null);
});
};
return (
<Formik
onSubmit={submit}
initialValues={{
password: '',
}}
validationSchema={object().shape({
password: string().required('You must provide your current password in order to continue.'),
})}
>
{({ isValid }) => (
<Form className={'mb-0'}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'} />
<Field
id={'password'}
name={'password'}
type={'password'}
label={'Current Password'}
description={
'In order to disable two-factor authentication you will need to provide your account password.'
}
autoFocus
/>
<div css={tw`mt-6 text-right`}>
<Button color={'red'} disabled={!isValid}>
Disable Two-Factor
</Button>
</div>
</Form>
)}
</Formik>
);
};
export default asModal()(DisableTwoFactorModal);

View file

@ -22,11 +22,12 @@ interface Props {
const ConfigureTwoFactorForm = ({ onTokens }: Props) => { const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [password, setPassword] = useState('');
const [token, setToken] = useState<TwoFactorTokenData | null>(null); const [token, setToken] = useState<TwoFactorTokenData | null>(null);
const { clearAndAddHttpError } = useFlashKey('account:two-step'); const { clearAndAddHttpError } = useFlashKey('account:two-step');
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData); const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const { close } = useContext(DialogWrapperContext); const { close, setProps } = useContext(DialogWrapperContext);
useEffect(() => { useEffect(() => {
getTwoFactorTokenData() getTwoFactorTokenData()
@ -34,13 +35,19 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
.catch((error) => clearAndAddHttpError(error)); .catch((error) => clearAndAddHttpError(error));
}, []); }, []);
const submit = () => { useEffect(() => {
setProps((state) => ({ ...state, preventExternalClose: submitting }));
}, [submitting]);
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
if (submitting) return; if (submitting) return;
setSubmitting(true); setSubmitting(true);
clearAndAddHttpError(); clearAndAddHttpError();
enableAccountTwoFactor(value, password)
enableAccountTwoFactor(value)
.then((tokens) => { .then((tokens) => {
updateUserData({ useTotp: true }); updateUserData({ useTotp: true });
onTokens(tokens); onTokens(tokens);
@ -52,7 +59,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
}; };
return ( return (
<> <form id={'enable-totp-form'} onSubmit={submit}>
<FlashMessageRender byKey={'account:two-step'} className={'mt-4'} /> <FlashMessageRender byKey={'account:two-step'} className={'mt-4'} />
<div <div
className={'flex items-center justify-center w-56 h-56 p-2 bg-gray-800 rounded-lg shadow mx-auto mt-6'} className={'flex items-center justify-center w-56 h-56 p-2 bg-gray-800 rounded-lg shadow mx-auto mt-6'}
@ -68,36 +75,53 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
{token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'} {token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'}
</p> </p>
</CopyOnClick> </CopyOnClick>
<div className={'mt-6'}> <p id={'totp-code-description'} className={'mt-6'}>
<p>
Scan the QR code above using the two-step authentication app of your choice. Then, enter the 6-digit Scan the QR code above using the two-step authentication app of your choice. Then, enter the 6-digit
code generated into the field below. code generated into the field below.
</p> </p>
</div>
<Input.Text <Input.Text
aria-labelledby={'totp-code-description'}
variant={Input.Text.Variants.Loose} variant={Input.Text.Variants.Loose}
value={value} value={value}
onChange={(e) => setValue(e.currentTarget.value)} onChange={(e) => setValue(e.currentTarget.value)}
className={'mt-4'} className={'mt-3'}
placeholder={'000000'} placeholder={'000000'}
type={'text'} type={'text'}
inputMode={'numeric'} inputMode={'numeric'}
autoComplete={'one-time-code'} autoComplete={'one-time-code'}
pattern={'\\d{6}'} pattern={'\\d{6}'}
/> />
<label htmlFor={'totp-password'} className={'block mt-3'}>
Account Password
</label>
<Input.Text
variant={Input.Text.Variants.Loose}
className={'mt-1'}
type={'password'}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Dialog.Footer> <Dialog.Footer>
<Button.Text onClick={close}>Cancel</Button.Text> <Button.Text onClick={close}>Cancel</Button.Text>
<Tooltip <Tooltip
disabled={value.length === 6} disabled={password.length > 0 && value.length === 6}
content={!token ? 'Waiting for QR code to load...' : 'You must enter the 6-digit code to continue.'} content={
!token
? 'Waiting for QR code to load...'
: 'You must enter the 6-digit code and your password to continue.'
}
delay={100} delay={100}
> >
<Button disabled={!token || value.length !== 6} onClick={submit}> <Button
disabled={!token || value.length !== 6 || !password.length}
type={'submit'}
form={'enable-totp-form'}
>
Enable Enable
</Button> </Button>
</Tooltip> </Tooltip>
</Dialog.Footer> </Dialog.Footer>
</> </form>
); );
}; };

View file

@ -7,7 +7,7 @@ import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button/index';
interface Values { interface Values {
email: string; email: string;
@ -66,9 +66,7 @@ export default () => {
/> />
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button size={'small'} disabled={isSubmitting || !isValid}> <Button disabled={isSubmitting || !isValid}>Update Email</Button>
Update Email
</Button>
</div> </div>
</Form> </Form>
</React.Fragment> </React.Fragment>

View file

@ -8,7 +8,7 @@ import updateAccountPassword from '@/api/account/updateAccountPassword';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button/index';
interface Values { interface Values {
current: string; current: string;
@ -91,9 +91,7 @@ export default () => {
/> />
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button size={'small'} disabled={isSubmitting || !isValid}> <Button disabled={isSubmitting || !isValid}>Update Password</Button>
Update Password
</Button>
</div> </div>
</Form> </Form>
</React.Fragment> </React.Fragment>

View file

@ -27,7 +27,7 @@ export interface RenderDialogProps extends DialogProps {
export type WrapperProps = Omit<RenderDialogProps, 'children' | 'open' | 'onClose'>; export type WrapperProps = Omit<RenderDialogProps, 'children' | 'open' | 'onClose'>;
export interface DialogWrapperContextType { export interface DialogWrapperContextType {
props: Readonly<WrapperProps>; props: Readonly<WrapperProps>;
setProps: Callback<Partial<WrapperProps>>; setProps: React.Dispatch<React.SetStateAction<WrapperProps>>;
close: () => void; close: () => void;
} }

View file

@ -59,13 +59,13 @@ class TwoFactorControllerTest extends ClientApiIntegrationTestCase
/** @var \Pterodactyl\Models\User $user */ /** @var \Pterodactyl\Models\User $user */
$user = User::factory()->create(['use_totp' => false]); $user = User::factory()->create(['use_totp' => false]);
$response = $this->actingAs($user)->postJson('/api/client/account/two-factor', [ $this->actingAs($user)
'code' => '', ->postJson('/api/client/account/two-factor', ['code' => ''])
]); ->assertUnprocessable()
->assertJsonPath('errors.0.meta.rule', 'required')
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); ->assertJsonPath('errors.0.meta.source_field', 'code')
$response->assertJsonPath('errors.0.code', 'ValidationException'); ->assertJsonPath('errors.1.meta.rule', 'required')
$response->assertJsonPath('errors.0.meta.rule', 'required'); ->assertJsonPath('errors.1.meta.source_field', 'password');
} }
/** /**
@ -90,6 +90,7 @@ class TwoFactorControllerTest extends ClientApiIntegrationTestCase
$response = $this->actingAs($user)->postJson('/api/client/account/two-factor', [ $response = $this->actingAs($user)->postJson('/api/client/account/two-factor', [
'code' => $token, 'code' => $token,
'password' => 'password',
]); ]);
$response->assertOk(); $response->assertOk();
@ -168,4 +169,39 @@ class TwoFactorControllerTest extends ClientApiIntegrationTestCase
$response->assertStatus(Response::HTTP_NO_CONTENT); $response->assertStatus(Response::HTTP_NO_CONTENT);
} }
/**
* Test that a valid account password is required when enabling two-factor.
*/
public function testEnablingTwoFactorRequiresValidPassword()
{
$user = User::factory()->create(['use_totp' => false]);
$this->actingAs($user)
->postJson('/api/client/account/two-factor', [
'code' => '123456',
'password' => 'foo',
])
->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonPath('errors.0.detail', 'The password provided was not valid.');
$this->assertFalse($user->refresh()->use_totp);
}
/**
* Test that a valid account password is required when disabling two-factor.
*/
public function testDisablingTwoFactorRequiresValidPassword()
{
$user = User::factory()->create(['use_totp' => true]);
$this->actingAs($user)
->deleteJson('/api/client/account/two-factor', [
'password' => 'foo',
])
->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonPath('errors.0.detail', 'The password provided was not valid.');
$this->assertTrue($user->refresh()->use_totp);
}
} }