Cleanup login/reset functionality, address security issue with 2FA pathways

This commit is contained in:
Dane Everitt 2018-04-07 16:17:51 -05:00
parent eade81f89b
commit c3e462ab2f
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
11 changed files with 158 additions and 39 deletions

View file

@ -106,6 +106,10 @@ abstract class AbstractLoginController extends Controller
$this->getField($request->input('user')) => $request->input('user'), $this->getField($request->input('user')) => $request->input('user'),
]); ]);
if ($request->route()->named('auth.checkpoint')) {
throw new DisplayException(trans('auth.checkpoint_failed'));
}
throw new DisplayException(trans('auth.failed')); throw new DisplayException(trans('auth.failed'));
} }

View file

@ -3,7 +3,7 @@
namespace Pterodactyl\Http\Controllers\Auth; namespace Pterodactyl\Http\Controllers\Auth;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Events\Auth\FailedPasswordReset; use Pterodactyl\Events\Auth\FailedPasswordReset;
@ -18,9 +18,9 @@ class ForgotPasswordController extends Controller
* *
* @param \Illuminate\Http\Request * @param \Illuminate\Http\Request
* @param string $response * @param string $response
* @return \Illuminate\Http\RedirectResponse * @return \Illuminate\Http\JsonResponse
*/ */
protected function sendResetLinkFailedResponse(Request $request, $response): RedirectResponse protected function sendResetLinkFailedResponse(Request $request, $response): JsonResponse
{ {
// As noted in #358 we will return success even if it failed // As noted in #358 we will return success even if it failed
// to avoid pointing out that an account does or does not // to avoid pointing out that an account does or does not
@ -34,9 +34,9 @@ class ForgotPasswordController extends Controller
* Get the response for a successful password reset link. * Get the response for a successful password reset link.
* *
* @param string $response * @param string $response
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
protected function sendResetLinkResponse($response) protected function sendResetLinkResponse($response): JsonResponse
{ {
return response()->json([ return response()->json([
'status' => trans($response), 'status' => trans($response),

View file

@ -10,9 +10,8 @@ class LoginCheckpointController extends AbstractLoginController
{ {
/** /**
* Handle a login where the user is required to provide a TOTP authentication * Handle a login where the user is required to provide a TOTP authentication
* token. In order to add additional layers of security, users are not * token. Once a user has reached this stage it is assumed that they have already
* informed of an incorrect password until this stage, forcing them to * provided a valid username and password.
* provide a token on each login attempt.
* *
* @param \Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest $request * @param \Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest $request
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
@ -28,7 +27,7 @@ class LoginCheckpointController extends AbstractLoginController
return $this->sendFailedLoginResponse($request); return $this->sendFailedLoginResponse($request);
} }
if (! array_get($cache, 'valid_credentials') || array_get($cache, 'request_ip') !== $request->ip()) { if (array_get($cache, 'request_ip') !== $request->ip()) {
return $this->sendFailedLoginResponse($request, $user); return $this->sendFailedLoginResponse($request, $user);
} }
@ -40,7 +39,7 @@ class LoginCheckpointController extends AbstractLoginController
return $this->sendFailedLoginResponse($request, $user); return $this->sendFailedLoginResponse($request, $user);
} }
$this->authManager->guard()->login($user, true); $this->auth->guard()->login($user, true);
return $this->sendLoginResponse($request); return $this->sendLoginResponse($request);
} }

View file

@ -33,22 +33,26 @@ class LoginController extends AbstractLoginController
return $this->sendFailedLoginResponse($request); return $this->sendFailedLoginResponse($request);
} }
$validCredentials = password_verify($request->input('password'), $user->password); // Ensure that the account is using a valid username and password before trying to
// continue. Previously this was handled in the 2FA checkpoint, however that has
// a flaw in which you can discover if an account exists simply by seeing if you
// can proceede to the next step in the login process.
if (! password_verify($request->input('password'), $user->password)) {
return $this->sendFailedLoginResponse($request, $user);
}
// If the user is using 2FA we do not actually log them in at this step, we return
// a one-time token to link the 2FA credentials to this account via the UI.
if ($user->use_totp) { if ($user->use_totp) {
$token = str_random(128); $token = str_random(128);
$this->cache->put($token, [ $this->cache->put($token, [
'user_id' => $user->id, 'user_id' => $user->id,
'valid_credentials' => $validCredentials,
'request_ip' => $request->ip(), 'request_ip' => $request->ip(),
], 5); ], 5);
return response()->json(['complete' => false, 'token' => $token]); return response()->json(['complete' => false, 'token' => $token]);
} }
if (! $validCredentials) {
return $this->sendFailedLoginResponse($request, $user);
}
$this->auth->guard()->login($user, true); $this->auth->guard()->login($user, true);
return response()->json(['complete' => true]); return response()->json(['complete' => true]);

View file

@ -1,9 +1,19 @@
<template> <template>
<div> <div>
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post"> <div class="pb-4" v-for="error in errors">
<div class="p-2 bg-red-dark border-red-darker border items-center text-red-lightest leading-normal rounded flex lg:inline-flex w-full text-sm"
role="alert">
<span class="flex rounded-full bg-red uppercase px-2 py-1 text-xs font-bold mr-3 leading-none">Error</span>
<span class="mr-2 text-left flex-auto">{{ error }}</span>
</div>
</div>
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post"
v-on:submit.prevent="submitForm"
>
<div class="flex flex-wrap -mx-3 mb-6"> <div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open"> <div class="input-open">
<input class="input" id="grid-email" type="email" aria-labelledby="grid-email" ref="email" required <input class="input" id="grid-email" type="email" aria-labelledby="grid-email" ref="email" required
v-bind:readonly="showSpinner"
v-bind:value="email" v-bind:value="email"
v-on:input="updateEmail($event)" v-on:input="updateEmail($event)"
/> />
@ -12,9 +22,11 @@
</div> </div>
</div> </div>
<div> <div>
<csrf/> <button class="btn btn-blue btn-jumbo" type="submit" v-bind:disabled="submitDisabled">
<button class="btn btn-blue btn-jumbo" type="submit"> <span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
{{ $t('auth.recover_account') }} <span v-bind:class="{ hidden: showSpinner }">
{{ $t('auth.recover_account') }}
</span>
</button> </button>
</div> </div>
<div class="pt-6 text-center"> <div class="pt-6 text-center">
@ -27,10 +39,7 @@
</template> </template>
<script> <script>
import Csrf from "../forms/CSRF";
export default { export default {
components: {Csrf},
name: 'forgot-password', name: 'forgot-password',
props: { props: {
email: {type: String, required: true}, email: {type: String, required: true},
@ -41,11 +50,43 @@
data: function () { data: function () {
return { return {
X_CSRF_TOKEN: window.X_CSRF_TOKEN, X_CSRF_TOKEN: window.X_CSRF_TOKEN,
errors: [],
submitDisabled: false,
showSpinner: false,
}; };
}, },
methods: { methods: {
updateEmail: function (event) { updateEmail: function (event) {
this.$data.submitDisabled = false;
this.$emit('update-email', event.target.value); this.$emit('update-email', event.target.value);
},
submitForm: function () {
const self = this;
this.$data.submitDisabled = true;
this.$data.showSpinner = true;
this.$data.errors = [];
window.axios.post(this.route('auth.forgot-password.send-link'), {
email: this.$props.email,
})
.then(function (response) {
self.$data.submitDisabled = false;
self.$data.showSpinner = false;
self.flash({message: response.data.status, variant: 'success'});
self.$router.push({name: 'login'});
})
.catch(function (err) {
self.$data.showSpinner = false;
if (!err.response) {
return console.error(err);
}
const response = err.response;
if (response.data && _.isObject(response.data.errors)) {
self.$data.errors.push(response.data.errors[0].detail);
}
});
} }
} }
} }

View file

@ -2,9 +2,7 @@
<div> <div>
<flash-message variant="danger" /> <flash-message variant="danger" />
<flash-message variant="success" /> <flash-message variant="success" />
<flash-message variant="warning" /> <div class="pb-4" v-if="errors && errors.length === 1">
<flash-message variant="info" />
<div class="py-4" v-if="errors && errors.length === 1">
<div class="p-2 bg-red-dark border-red-darker border items-center text-red-lightest leading-normal rounded flex lg:inline-flex w-full text-sm" <div class="p-2 bg-red-dark border-red-darker border items-center text-red-lightest leading-normal rounded flex lg:inline-flex w-full text-sm"
role="alert"> role="alert">
<span class="flex rounded-full bg-red uppercase px-2 py-1 text-xs font-bold mr-3 leading-none">Error</span> <span class="flex rounded-full bg-red uppercase px-2 py-1 text-xs font-bold mr-3 leading-none">Error</span>
@ -12,13 +10,12 @@
</div> </div>
</div> </div>
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post" <form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post"
v-on:submit.prevent="handleLogin" v-on:submit.prevent="submitForm"
> >
<div class="flex flex-wrap -mx-3 mb-6"> <div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open"> <div class="input-open">
<input class="input" id="grid-username" type="text" name="user" aria-labelledby="grid-username" <input class="input" id="grid-username" type="text" name="user" aria-labelledby="grid-username" required
ref="email" ref="email"
required
v-bind:value="user.email" v-bind:value="user.email"
v-on:input="updateEmail($event)" v-on:input="updateEmail($event)"
/> />
@ -28,6 +25,7 @@
<div class="flex flex-wrap -mx-3 mb-6"> <div class="flex flex-wrap -mx-3 mb-6">
<div class="input-open"> <div class="input-open">
<input class="input" id="grid-password" type="password" name="password" <input class="input" id="grid-password" type="password" name="password"
ref="password"
aria-labelledby="grid-password" required aria-labelledby="grid-password" required
v-model="user.password" v-model="user.password"
/> />
@ -35,8 +33,11 @@
</div> </div>
</div> </div>
<div> <div>
<button class="btn btn-blue btn-jumbo" type="submit"> <button class="btn btn-blue btn-jumbo" type="submit" v-bind:disabled="showSpinner">
{{ $t('auth.sign_in') }} <span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
<span v-bind:class="{ hidden: showSpinner }">
{{ $t('auth.sign_in') }}
</span>
</button> </button>
</div> </div>
<div class="pt-6 text-center"> <div class="pt-6 text-center">
@ -67,6 +68,7 @@
data: function () { data: function () {
return { return {
errors: [], errors: [],
showSpinner: false,
} }
}, },
mounted: function () { mounted: function () {
@ -75,8 +77,9 @@
methods: { methods: {
// Handle a login request eminating from the form. If 2FA is required the // Handle a login request eminating from the form. If 2FA is required the
// user will be presented with the 2FA modal window. // user will be presented with the 2FA modal window.
handleLogin: function () { submitForm: function () {
const self = this; const self = this;
this.$data.showSpinner = true;
axios.post(this.route('auth.login'), { axios.post(this.route('auth.login'), {
user: this.$props.user.email, user: this.$props.user.email,
@ -88,17 +91,20 @@
} }
self.$props.user.password = ''; self.$props.user.password = '';
self.$data.showSpinner = false;
self.$router.push({name: 'checkpoint', query: {token: response.data.token}}); self.$router.push({name: 'checkpoint', query: {token: response.data.token}});
}) })
.catch(function (err) { .catch(function (err) {
self.$props.user.password = ''; self.$props.user.password = '';
self.$data.showSpinner = false;
if (!err.response) { if (!err.response) {
return console.error(err); return console.error(err);
} }
const response = err.response; const response = err.response;
if (response.data && _.isObject(response.data.errors)) { if (response.data && _.isObject(response.data.errors)) {
self.$data.errors.push(response.data.errors[0].detail); self.$data.errors = [response.data.errors[0].detail];
self.$refs.password.focus();
} }
}); });
}, },

View file

@ -7,7 +7,7 @@
&.btn-blue { &.btn-blue {
@apply .bg-blue .border-blue-dark .border .text-white; @apply .bg-blue .border-blue-dark .border .text-white;
&:hover { &:hover:enabled {
@apply .bg-blue-dark .border-blue-darker; @apply .bg-blue-dark .border-blue-darker;
} }
} }
@ -18,4 +18,9 @@
&.btn-jumbo { &.btn-jumbo {
@apply .p-4 .w-full .uppercase .tracking-wide .text-sm; @apply .p-4 .w-full .uppercase .tracking-wide .text-sm;
} }
&:disabled {
opacity: 0.55;
cursor: default;
}
} }

View file

@ -0,0 +1,52 @@
.spinner {
color: transparent;
pointer-events: none;
position: relative;
&:after {
animation: spinners--spin 500ms infinite linear;
border-radius: 9999px;
@apply .border-2 .border-grey-light;
border-top-color: transparent !important;
border-right-color: transparent !important;
content: '';
display: block;
width: 1em;
height: 1em;
left: calc(50% - (1em / 2));
top: calc(50% - (1em / 2));
position: absolute !important;
}
/**
* Speeds
*/
&.spin-slow:after {
animation: spinners--spin 1200ms infinite linear;
}
/**
* Spinner Colors
*/
&.blue:after {
@apply .border-blue;
}
&.white:after {
@apply .border-white;
}
&.spinner-thick:after {
@apply .border-4;
}
}
@keyframes spinners--spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -10,6 +10,7 @@
@import "components/animations.css"; @import "components/animations.css";
@import "components/authentication.css"; @import "components/authentication.css";
@import "components/buttons.css"; @import "components/buttons.css";
@import "components/spinners.css";
/** /**
* Tailwind Utilities * Tailwind Utilities

View file

@ -19,7 +19,8 @@ return [
'reset_password_text' => 'Reset your account password.', 'reset_password_text' => 'Reset your account password.',
'reset_password' => 'Reset Account Password', 'reset_password' => 'Reset Account Password',
'email_sent' => 'An email has been sent to you with further instructions for resetting your password.', 'email_sent' => 'An email has been sent to you with further instructions for resetting your password.',
'failed' => 'The credentials provided do not match those we have on record, or the 2FA token provided was invalid.', 'failed' => 'No account matching those credentials could be found.',
'checkpoint_failed' => 'The two-factor authentication token was invalid.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'password_requirements' => 'Passwords must contain at least one uppercase, lowercase, and numeric character and must be at least 8 characters in length.', 'password_requirements' => 'Passwords must contain at least one uppercase, lowercase, and numeric character and must be at least 8 characters in length.',
'request_reset' => 'Locate Account', 'request_reset' => 'Locate Account',

View file

@ -9,14 +9,20 @@
| |
*/ */
Route::group(['middleware' => 'guest'], function () { Route::group(['middleware' => 'guest'], function () {
// Login specific routes
Route::get('/login', 'LoginController@showLoginForm')->name('auth.login'); Route::get('/login', 'LoginController@showLoginForm')->name('auth.login');
Route::get('/password/reset/{token}', 'ResetPasswordController@showResetForm')->name('auth.reset');
Route::post('/login', 'LoginController@login')->middleware('recaptcha'); Route::post('/login', 'LoginController@login')->middleware('recaptcha');
Route::post('/login/checkpoint', 'LoginCheckpointController@index')->name('auth.checkpoint'); Route::post('/login/checkpoint', 'LoginCheckpointController@index')->name('auth.checkpoint');
Route::post('/password', 'ForgotPasswordController@sendResetLinkEmail')->middleware('recaptcha');
// Forgot password route. A post to this endpoint will trigger an
// email to be sent containing a reset token.
Route::post('/password', 'ForgotPasswordController@sendResetLinkEmail')->name('auth.forgot-password.send-link')->middleware('recaptcha');
// Password reset routes. This endpoint is hit after going through
// the forgot password routes to acquire a token (or after an account
// is created).
Route::get('/password/reset/{token}', 'ResetPasswordController@showResetForm')->name('auth.reset-password');
Route::post('/password/reset', 'ResetPasswordController@reset')->name('auth.reset.post')->middleware('recaptcha'); Route::post('/password/reset', 'ResetPasswordController@reset')->name('auth.reset.post')->middleware('recaptcha');
Route::post('/password/reset/{token}', 'ForgotPasswordController@sendResetLinkEmail')->middleware('recaptcha');
}); });
/* /*