From eb39826f46b1140bf0344b47aea0bf2a4024ec55 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 22 Dec 2019 17:03:44 -0800 Subject: [PATCH] Add base logic to configure two factor on account --- .../TwoFactorAuthenticationTokenInvalid.php | 11 +- .../Api/Client/TwoFactorController.php | 106 ++++++++++++++++ app/Services/Users/ToggleTwoFactorService.php | 4 +- package.json | 1 + .../api/account/enableAccountTwoFactor.ts | 9 ++ .../api/account/getTwoFactorTokenUrl.ts | 9 ++ resources/scripts/components/App.tsx | 43 ++++--- .../dashboard/AccountOverviewContainer.tsx | 30 ++++- .../forms/ConfigureTwoFactorForm.tsx | 38 ++++++ .../dashboard/forms/SetupTwoFactorModal.tsx | 113 ++++++++++++++++++ resources/scripts/gloabl.d.ts | 2 +- resources/scripts/style.d.ts | 17 +++ resources/styles/components/forms.css | 53 ++++---- routes/api-client.php | 3 + yarn.lock | 4 + 15 files changed, 389 insertions(+), 54 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/TwoFactorController.php create mode 100644 resources/scripts/api/account/enableAccountTwoFactor.ts create mode 100644 resources/scripts/api/account/getTwoFactorTokenUrl.ts create mode 100644 resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx create mode 100644 resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx create mode 100644 resources/scripts/style.d.ts diff --git a/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php b/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php index e14a33aed..a4ea4dd09 100644 --- a/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php +++ b/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php @@ -1,16 +1,9 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Exceptions\Service\User; -use Exception; +use Pterodactyl\Exceptions\DisplayException; -class TwoFactorAuthenticationTokenInvalid extends Exception +class TwoFactorAuthenticationTokenInvalid extends DisplayException { } diff --git a/app/Http/Controllers/Api/Client/TwoFactorController.php b/app/Http/Controllers/Api/Client/TwoFactorController.php new file mode 100644 index 000000000..17bae9baf --- /dev/null +++ b/app/Http/Controllers/Api/Client/TwoFactorController.php @@ -0,0 +1,106 @@ +setupService = $setupService; + $this->validation = $validation; + $this->toggleTwoFactorService = $toggleTwoFactorService; + } + + /** + * Returns two-factor token credentials that allow a user to configure + * it on their account. If two-factor is already enabled this endpoint + * will return a 400 error. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function index(Request $request) + { + if ($request->user()->totp_enabled) { + throw new BadRequestHttpException('Two-factor authentication is already enabled on this account.'); + } + + return JsonResponse::create([ + 'data' => [ + 'image_url_data' => $this->setupService->handle($request->user()), + ], + ]); + } + + /** + * Updates a user's account to have two-factor enabled. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + * + * @throws \Illuminate\Validation\ValidationException + * @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException + * @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException + * @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + * @throws \Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid + */ + public function store(Request $request) + { + $validator = $this->validation->make($request->all(), [ + 'code' => 'required|string', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $this->toggleTwoFactorService->handle($request->user(), $request->input('code'), true); + + return JsonResponse::create([], Response::HTTP_NO_CONTENT); + } + + public function delete() + { + } +} diff --git a/app/Services/Users/ToggleTwoFactorService.php b/app/Services/Users/ToggleTwoFactorService.php index b68dc911d..2c393db01 100644 --- a/app/Services/Users/ToggleTwoFactorService.php +++ b/app/Services/Users/ToggleTwoFactorService.php @@ -65,7 +65,9 @@ class ToggleTwoFactorService $isValidToken = $this->google2FA->verifyKey($secret, $token, config()->get('pterodactyl.auth.2fa.window')); if (! $isValidToken) { - throw new TwoFactorAuthenticationTokenInvalid; + throw new TwoFactorAuthenticationTokenInvalid( + 'The token provided is not valid.' + ); } $this->repository->withoutFreshModel()->update($user->id, [ diff --git a/package.json b/package.json index c3e89969c..126bcf2a2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react-transition-group": "^4.3.0", "sockette": "^2.0.6", "styled-components": "^4.4.1", + "styled-components-breakpoint": "^3.0.0-preview.20", "use-react-router": "^1.0.7", "uuid": "^3.3.2", "xterm": "^3.14.4", diff --git a/resources/scripts/api/account/enableAccountTwoFactor.ts b/resources/scripts/api/account/enableAccountTwoFactor.ts new file mode 100644 index 000000000..d44d09acb --- /dev/null +++ b/resources/scripts/api/account/enableAccountTwoFactor.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (code: string): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/client/account/two-factor', { code }) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/account/getTwoFactorTokenUrl.ts b/resources/scripts/api/account/getTwoFactorTokenUrl.ts new file mode 100644 index 000000000..6d9a2aa94 --- /dev/null +++ b/resources/scripts/api/account/getTwoFactorTokenUrl.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/account/two-factor') + .then(({ data }) => resolve(data.data.image_url_data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index bb16b7bc8..510835678 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -8,6 +8,7 @@ import ServerRouter from '@/routers/ServerRouter'; import AuthenticationRouter from '@/routers/AuthenticationRouter'; import { Provider } from 'react-redux'; import { SiteSettings } from '@/state/settings'; +import { DefaultTheme, ThemeProvider } from 'styled-components'; interface ExtendedWindow extends Window { SiteConfiguration?: SiteSettings; @@ -23,6 +24,16 @@ interface ExtendedWindow extends Window { }; } +const theme: DefaultTheme = { + breakpoints: { + xs: 0, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + }, +}; + const App = () => { const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow); if (PterodactylUser && !store.getState().user.data) { @@ -43,21 +54,23 @@ const App = () => { } return ( - - - -
- - - - - - - -
-
-
-
+ + + + +
+ + + + + + + +
+
+
+
+
); }; diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index e35fb7d61..777a4e3a5 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -2,16 +2,38 @@ import * as React from 'react'; import ContentBox from '@/components/elements/ContentBox'; import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm'; import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm'; +import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm'; +import styled from 'styled-components'; +import { breakpoint } from 'styled-components-breakpoint'; + +const Container = styled.div` + ${tw`flex flex-wrap my-10`}; + + & > div { + ${tw`w-full`}; + + ${breakpoint('md')` + width: calc(50% - 1rem); + `} + + ${breakpoint('xl')` + ${tw`w-auto flex-1`}; + `} + } +`; export default () => { return ( -
- + + - + -
+ + + + ); }; diff --git a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx new file mode 100644 index 000000000..5aef3bce9 --- /dev/null +++ b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { useStoreState } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import SetupTwoFactorModal from '@/components/dashboard/forms/SetupTwoFactorModal'; + +export default () => { + const user = useStoreState((state: ApplicationStore) => state.user.data!); + const [visible, setVisible] = useState(false); + + return user.useTotp ? +
+

+ Two-factor authentication is currently enabled on your account. +

+
+ +
+
+ : +
+ setVisible(false)}/> +

+ You do not currently have two-factor authentication enabled on your account. Click + the button below to begin configuring it. +

+
+ +
+
+ ; +}; diff --git a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx new file mode 100644 index 000000000..6151094bb --- /dev/null +++ b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import { Form, Formik, FormikActions } from 'formik'; +import { object, string } from 'yup'; +import Field from '@/components/elements/Field'; +import getTwoFactorTokenUrl from '@/api/account/getTwoFactorTokenUrl'; +import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import { httpErrorToHuman } from '@/api/http'; + +interface Values { + code: string; +} + +export default ({ visible, onDismissed }: RequiredModalProps) => { + const [ token, setToken ] = useState(''); + const [ loading, setLoading ] = useState(true); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + useEffect(() => { + if (!visible) { + clearFlashes('account:two-factor'); + getTwoFactorTokenUrl() + .then(setToken) + .catch(error => { + console.error(error); + }); + } + }, [ visible ]); + + const submit = ({ code }: Values, { resetForm, setSubmitting }: FormikActions) => { + clearFlashes('account:two-factor'); + enableAccountTwoFactor(code) + .then(() => { + resetForm(); + setToken(''); + setLoading(true); + }) + .catch(error => { + console.error(error); + + addError({ message: httpErrorToHuman(error), key: 'account:two-factor' }); + setSubmitting(false); + }); + }; + + return ( + + {({ isSubmitting, isValid, resetForm }) => ( + { + resetForm(); + setToken(''); + setLoading(true); + onDismissed(); + }} + dismissable={!isSubmitting} + showSpinnerOverlay={loading || isSubmitting} + > +
+ +
+
+
+ {!token || !token.length ? + + : + setLoading(false)} + className={'w-full h-full shadow-none rounded-0'} + /> + } +
+
+
+
+ +
+
+ +
+
+
+ +
+ )} +
+ ); +}; diff --git a/resources/scripts/gloabl.d.ts b/resources/scripts/gloabl.d.ts index 2a3eab57c..b0dfa478c 100644 --- a/resources/scripts/gloabl.d.ts +++ b/resources/scripts/gloabl.d.ts @@ -1 +1 @@ -declare function tw(a: TemplateStringsArray | string): any; +declare function tw (a: TemplateStringsArray | string): any; diff --git a/resources/scripts/style.d.ts b/resources/scripts/style.d.ts new file mode 100644 index 000000000..b60e8039b --- /dev/null +++ b/resources/scripts/style.d.ts @@ -0,0 +1,17 @@ +import { Breakpoints, css, DefaultTheme, StyledProps } from 'styled-components'; + +declare module 'styled-components' { + type Breakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + + export interface DefaultTheme { + breakpoints: { + [name in 'xs' | 'sm' | 'md' | 'lg' | 'xl']: number; + }; + } +} + +declare module 'styled-components-breakpoint' { + type CSSFunction = (...params: Parameters) =>

({ theme }: StyledProps

) => ReturnType; + + export const breakpoint: (breakpointA: Breakpoints, breakpointB?: Breakpoints) => CSSFunction; +} diff --git a/resources/styles/components/forms.css b/resources/styles/components/forms.css index 3504f5e43..56323d76a 100644 --- a/resources/styles/components/forms.css +++ b/resources/styles/components/forms.css @@ -145,32 +145,29 @@ a.btn { @apply .rounded .p-2 .uppercase .tracking-wide .text-sm; transition: all 150ms linear; - /** - * Button Colors - */ - &.btn-primary { - @apply .bg-primary-500 .border-primary-600 .border .text-primary-50; + &.btn-secondary { + @apply .border .border-neutral-600 .bg-transparent .text-neutral-200; &:hover:not(:disabled) { - @apply .bg-primary-600 .border-primary-700; + @apply .border-neutral-500 .text-neutral-100; } - } - &.btn-green { - @apply .bg-green-500 .border-green-600 .border .text-green-50; - - &:hover:not(:disabled) { - @apply .bg-green-600 .border-green-700; - } - } - - &.btn-red { - &:not(.btn-secondary) { + &.btn-red:hover:not(:disabled) { @apply .bg-red-500 .border-red-600 .text-red-50; } + &.btn-green:hover:not(:disabled) { + @apply .bg-green-500 .border-green-600 .text-green-50; + } + } + + &.btn-primary { + &:not(.btn-secondary) { + @apply .bg-primary-500 .border-primary-600 .border .text-primary-50; + } + &:hover:not(:disabled) { - @apply .bg-red-600 .border-red-700; + @apply .bg-primary-600 .border-primary-700; } } @@ -182,16 +179,24 @@ a.btn { } } - &.btn-secondary { - @apply .border .border-neutral-600 .bg-transparent .text-neutral-200; - - &:hover:not(:disabled) { - @apply .border-neutral-500 .text-neutral-100; + &.btn-green { + &:not(.btn-secondary) { + @apply .bg-green-500 .border-green-600 .border .text-green-50; } - &.btn-red:hover:not(:disabled) { + &:hover:not(:disabled), &.btn-secondary:active:not(:disabled) { + @apply .bg-green-600 .border-green-700; + } + } + + &.btn-red { + &:not(.btn-secondary) { @apply .bg-red-500 .border-red-600 .text-red-50; } + + &:hover:not(:disabled), &.btn-secondary:active:not(:disabled) { + @apply .bg-red-600 .border-red-700; + } } /** diff --git a/routes/api-client.php b/routes/api-client.php index 37f699ff1..53cd113e0 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -16,6 +16,9 @@ Route::get('/permissions', 'ClientController@permissions'); Route::group(['prefix' => '/account'], function () { Route::get('/', 'AccountController@index')->name('api.client.account'); + Route::get('/two-factor', 'TwoFactorController@index'); + Route::post('/two-factor', 'TwoFactorController@store'); + Route::delete('/two-factor', 'TwoFactorController@delete'); Route::put('/email', 'AccountController@updateEmail')->name('api.client.account.update-email'); Route::put('/password', 'AccountController@updatePassword')->name('api.client.account.update-password'); diff --git a/yarn.lock b/yarn.lock index f46c2f81f..a327ede5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6857,6 +6857,10 @@ style-loader@^0.23.1: loader-utils "^1.1.0" schema-utils "^1.0.0" +styled-components-breakpoint@^3.0.0-preview.20: + version "3.0.0-preview.20" + resolved "https://registry.yarnpkg.com/styled-components-breakpoint/-/styled-components-breakpoint-3.0.0-preview.20.tgz#877e88a00c0cf66976f610a1d347839a1a0b6d70" + styled-components@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-4.4.1.tgz#e0631e889f01db67df4de576fedaca463f05c2f2"