diff --git a/app/Exceptions/Http/TwoFactorAuthRequiredException.php b/app/Exceptions/Http/TwoFactorAuthRequiredException.php new file mode 100644 index 000000000..c38503572 --- /dev/null +++ b/app/Exceptions/Http/TwoFactorAuthRequiredException.php @@ -0,0 +1,21 @@ + [ SubstituteBindings::class, diff --git a/app/Http/Middleware/RequireTwoFactorAuthentication.php b/app/Http/Middleware/RequireTwoFactorAuthentication.php index 0689cc14a..d837df24d 100644 --- a/app/Http/Middleware/RequireTwoFactorAuthentication.php +++ b/app/Http/Middleware/RequireTwoFactorAuthentication.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Http\Middleware; @@ -13,6 +6,7 @@ use Closure; use Illuminate\Support\Str; use Illuminate\Http\Request; use Prologue\Alerts\AlertsMessageBag; +use Pterodactyl\Exceptions\Http\TwoFactorAuthRequiredException; class RequireTwoFactorAuthentication { @@ -30,7 +24,7 @@ class RequireTwoFactorAuthentication * * @var string */ - protected $redirectRoute = 'account'; + protected $redirectRoute = '/account'; /** * RequireTwoFactorAuthentication constructor. @@ -43,41 +37,46 @@ class RequireTwoFactorAuthentication } /** - * Handle an incoming request. + * Check the user state on the incoming request to determine if they should be allowed to + * proceed or not. This checks if the Panel is configured to require 2FA on an account in + * order to perform actions. If so, we check the level at which it is required (all users + * or just admins) and then check if the user has enabled it for their account. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed + * + * @throws \Pterodactyl\Exceptions\Http\TwoFactorAuthRequiredException */ public function handle(Request $request, Closure $next) { - if (! $request->user()) { - return $next($request); - } - + /** @var \Pterodactyl\Models\User $user */ + $user = $request->user(); + $uri = rtrim($request->getRequestUri(), '/') . '/'; $current = $request->route()->getName(); - if (in_array($current, ['auth', 'account']) || Str::startsWith($current, ['auth.', 'account.'])) { + + if (! $user || Str::startsWith($uri, ['/auth/']) || Str::startsWith($current, ['auth.', 'account.'])) { return $next($request); } - switch ((int) config('pterodactyl.auth.2fa_required')) { - case self::LEVEL_ADMIN: - if (! $request->user()->root_admin || $request->user()->use_totp) { - return $next($request); - } - break; - case self::LEVEL_ALL: - if ($request->user()->use_totp) { - return $next($request); - } - break; - case self::LEVEL_NONE: - default: - return $next($request); + $level = (int)config('pterodactyl.auth.2fa_required'); + // If this setting is not configured, or the user is already using 2FA then we can just + // send them right through, nothing else needs to be checked. + // + // If the level is set as admin and the user is not an admin, pass them through as well. + if ($level === self::LEVEL_NONE || $user->use_totp) { + return $next($request); + } else if ($level === self::LEVEL_ADMIN && ! $user->root_admin) { + return $next($request); + } + + // For API calls return an exception which gets rendered nicely in the API response. + if ($request->isJson() || Str::startsWith($uri, '/api/')) { + throw new TwoFactorAuthRequiredException; } $this->alert->danger(trans('auth.2fa_must_be_enabled'))->flash(); - return redirect()->route($this->redirectRoute); + return redirect()->to($this->redirectRoute); } } diff --git a/resources/scripts/api/interceptors.ts b/resources/scripts/api/interceptors.ts new file mode 100644 index 000000000..8ff03fbf0 --- /dev/null +++ b/resources/scripts/api/interceptors.ts @@ -0,0 +1,16 @@ +import http from '@/api/http'; +import { AxiosError } from 'axios'; +import { History } from 'history'; + +export const setupInterceptors = (history: History) => { + http.interceptors.response.use(resp => resp, (error: AxiosError) => { + if (error.response?.status === 400) { + if (error.response?.data.errors?.[0].code === 'TwoFactorAuthRequiredException') { + if (!window.location.pathname.startsWith('/account')) { + history.replace('/account', { twoFactorRedirect: true }); + } + } + } + throw error; + }); +}; diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index f61e73e0b..fefa501cd 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import ReactGA from 'react-ga'; import { hot } from 'react-hot-loader/root'; -import { BrowserRouter, Route, Switch, useLocation } from 'react-router-dom'; +import { Route, Router, Switch, useLocation } from 'react-router-dom'; import { StoreProvider } from 'easy-peasy'; import { store } from '@/state'; import DashboardRouter from '@/routers/DashboardRouter'; @@ -13,6 +13,8 @@ import ProgressBar from '@/components/elements/ProgressBar'; import NotFound from '@/components/screens/NotFound'; import tw from 'twin.macro'; import GlobalStylesheet from '@/assets/css/GlobalStylesheet'; +import { createBrowserHistory } from 'history'; +import { setupInterceptors } from '@/api/interceptors'; interface ExtendedWindow extends Window { SiteConfiguration?: SiteSettings; @@ -30,6 +32,10 @@ interface ExtendedWindow extends Window { }; } +const history = createBrowserHistory({ basename: '/' }); + +setupInterceptors(history); + const Pageview = () => { const { pathname } = useLocation(); @@ -72,7 +78,7 @@ const App = () => {
- + {SiteConfiguration?.analytics && } @@ -80,7 +86,7 @@ const App = () => { - +
diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index 98e7a8d53..47161f793 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -7,9 +7,11 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import { breakpoint } from '@/theme'; import styled from 'styled-components/macro'; +import { RouteComponentProps } from 'react-router'; +import MessageBox from '@/components/MessageBox'; const Container = styled.div` - ${tw`flex flex-wrap my-10`}; + ${tw`flex flex-wrap`}; & > div { ${tw`w-full`}; @@ -24,10 +26,15 @@ const Container = styled.div` } `; -export default () => { +export default ({ location: { state } }: RouteComponentProps) => { return ( - + {state?.twoFactorRedirect && + + Your account must have two-factor authentication enabled in order to continue. + + } + diff --git a/routes/api-client.php b/routes/api-client.php index 35a2938dd..bc689f80b 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -1,6 +1,7 @@ name('api:client.index'); 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::get('/', 'AccountController@index')->name('api:client.account')->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::get('/two-factor', 'TwoFactorController@index')->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::post('/two-factor', 'TwoFactorController@store')->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::delete('/two-factor', 'TwoFactorController@delete')->withoutMiddleware(RequireTwoFactorAuthentication::class); 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/routes/base.php b/routes/base.php index 4d43235c4..3be62423e 100644 --- a/routes/base.php +++ b/routes/base.php @@ -1,9 +1,14 @@ name('index')->fallback(); -Route::get('/account', 'IndexController@index')->name('account'); +Route::get('/account', 'IndexController@index') + ->withoutMiddleware(RequireTwoFactorAuthentication::class) + ->name('account'); Route::get('/locales/{locale}/{namespace}.json', 'LocaleController') + ->withoutMiddleware(RequireTwoFactorAuthentication::class) ->where('namespace', '.*'); Route::get('/{react}', 'IndexController@index')