Skip to content

Commit c3e462a

Browse files
committed
Cleanup login/reset functionality, address security issue with 2FA pathways
1 parent eade81f commit c3e462a

File tree

11 files changed

+158
-39
lines changed

11 files changed

+158
-39
lines changed

app/Http/Controllers/Auth/AbstractLoginController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ protected function sendFailedLoginResponse(Request $request, Authenticatable $us
106106
$this->getField($request->input('user')) => $request->input('user'),
107107
]);
108108

109+
if ($request->route()->named('auth.checkpoint')) {
110+
throw new DisplayException(trans('auth.checkpoint_failed'));
111+
}
112+
109113
throw new DisplayException(trans('auth.failed'));
110114
}
111115

app/Http/Controllers/Auth/ForgotPasswordController.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace Pterodactyl\Http\Controllers\Auth;
44

55
use Illuminate\Http\Request;
6-
use Illuminate\Http\RedirectResponse;
6+
use Illuminate\Http\JsonResponse;
77
use Illuminate\Support\Facades\Password;
88
use Pterodactyl\Http\Controllers\Controller;
99
use Pterodactyl\Events\Auth\FailedPasswordReset;
@@ -18,9 +18,9 @@ class ForgotPasswordController extends Controller
1818
*
1919
* @param \Illuminate\Http\Request
2020
* @param string $response
21-
* @return \Illuminate\Http\RedirectResponse
21+
* @return \Illuminate\Http\JsonResponse
2222
*/
23-
protected function sendResetLinkFailedResponse(Request $request, $response): RedirectResponse
23+
protected function sendResetLinkFailedResponse(Request $request, $response): JsonResponse
2424
{
2525
// As noted in #358 we will return success even if it failed
2626
// to avoid pointing out that an account does or does not
@@ -34,9 +34,9 @@ protected function sendResetLinkFailedResponse(Request $request, $response): Red
3434
* Get the response for a successful password reset link.
3535
*
3636
* @param string $response
37-
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
37+
* @return \Illuminate\Http\JsonResponse
3838
*/
39-
protected function sendResetLinkResponse($response)
39+
protected function sendResetLinkResponse($response): JsonResponse
4040
{
4141
return response()->json([
4242
'status' => trans($response),

app/Http/Controllers/Auth/LoginCheckpointController.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ class LoginCheckpointController extends AbstractLoginController
1010
{
1111
/**
1212
* Handle a login where the user is required to provide a TOTP authentication
13-
* token. In order to add additional layers of security, users are not
14-
* informed of an incorrect password until this stage, forcing them to
15-
* provide a token on each login attempt.
13+
* token. Once a user has reached this stage it is assumed that they have already
14+
* provided a valid username and password.
1615
*
1716
* @param \Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest $request
1817
* @return \Illuminate\Http\JsonResponse
@@ -28,7 +27,7 @@ public function index(LoginCheckpointRequest $request): JsonResponse
2827
return $this->sendFailedLoginResponse($request);
2928
}
3029

31-
if (! array_get($cache, 'valid_credentials') || array_get($cache, 'request_ip') !== $request->ip()) {
30+
if (array_get($cache, 'request_ip') !== $request->ip()) {
3231
return $this->sendFailedLoginResponse($request, $user);
3332
}
3433

@@ -40,7 +39,7 @@ public function index(LoginCheckpointRequest $request): JsonResponse
4039
return $this->sendFailedLoginResponse($request, $user);
4140
}
4241

43-
$this->authManager->guard()->login($user, true);
42+
$this->auth->guard()->login($user, true);
4443

4544
return $this->sendLoginResponse($request);
4645
}

app/Http/Controllers/Auth/LoginController.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,26 @@ public function login(Request $request): JsonResponse
3333
return $this->sendFailedLoginResponse($request);
3434
}
3535

36-
$validCredentials = password_verify($request->input('password'), $user->password);
36+
// Ensure that the account is using a valid username and password before trying to
37+
// continue. Previously this was handled in the 2FA checkpoint, however that has
38+
// a flaw in which you can discover if an account exists simply by seeing if you
39+
// can proceede to the next step in the login process.
40+
if (! password_verify($request->input('password'), $user->password)) {
41+
return $this->sendFailedLoginResponse($request, $user);
42+
}
43+
44+
// If the user is using 2FA we do not actually log them in at this step, we return
45+
// a one-time token to link the 2FA credentials to this account via the UI.
3746
if ($user->use_totp) {
3847
$token = str_random(128);
3948
$this->cache->put($token, [
4049
'user_id' => $user->id,
41-
'valid_credentials' => $validCredentials,
4250
'request_ip' => $request->ip(),
4351
], 5);
4452

4553
return response()->json(['complete' => false, 'token' => $token]);
4654
}
4755

48-
if (! $validCredentials) {
49-
return $this->sendFailedLoginResponse($request, $user);
50-
}
51-
5256
$this->auth->guard()->login($user, true);
5357

5458
return response()->json(['complete' => true]);

resources/assets/pterodactyl/scripts/components/auth/ForgotPassword.vue

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
<template>
22
<div>
3-
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post">
3+
<div class="pb-4" v-for="error in errors">
4+
<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"
5+
role="alert">
6+
<span class="flex rounded-full bg-red uppercase px-2 py-1 text-xs font-bold mr-3 leading-none">Error</span>
7+
<span class="mr-2 text-left flex-auto">{{ error }}</span>
8+
</div>
9+
</div>
10+
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post"
11+
v-on:submit.prevent="submitForm"
12+
>
413
<div class="flex flex-wrap -mx-3 mb-6">
514
<div class="input-open">
615
<input class="input" id="grid-email" type="email" aria-labelledby="grid-email" ref="email" required
16+
v-bind:readonly="showSpinner"
717
v-bind:value="email"
818
v-on:input="updateEmail($event)"
919
/>
@@ -12,9 +22,11 @@
1222
</div>
1323
</div>
1424
<div>
15-
<csrf/>
16-
<button class="btn btn-blue btn-jumbo" type="submit">
17-
{{ $t('auth.recover_account') }}
25+
<button class="btn btn-blue btn-jumbo" type="submit" v-bind:disabled="submitDisabled">
26+
<span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
27+
<span v-bind:class="{ hidden: showSpinner }">
28+
{{ $t('auth.recover_account') }}
29+
</span>
1830
</button>
1931
</div>
2032
<div class="pt-6 text-center">
@@ -27,10 +39,7 @@
2739
</template>
2840

2941
<script>
30-
import Csrf from "../forms/CSRF";
31-
3242
export default {
33-
components: {Csrf},
3443
name: 'forgot-password',
3544
props: {
3645
email: {type: String, required: true},
@@ -41,11 +50,43 @@
4150
data: function () {
4251
return {
4352
X_CSRF_TOKEN: window.X_CSRF_TOKEN,
53+
errors: [],
54+
submitDisabled: false,
55+
showSpinner: false,
4456
};
4557
},
4658
methods: {
4759
updateEmail: function (event) {
60+
this.$data.submitDisabled = false;
4861
this.$emit('update-email', event.target.value);
62+
},
63+
64+
submitForm: function () {
65+
const self = this;
66+
this.$data.submitDisabled = true;
67+
this.$data.showSpinner = true;
68+
this.$data.errors = [];
69+
70+
window.axios.post(this.route('auth.forgot-password.send-link'), {
71+
email: this.$props.email,
72+
})
73+
.then(function (response) {
74+
self.$data.submitDisabled = false;
75+
self.$data.showSpinner = false;
76+
self.flash({message: response.data.status, variant: 'success'});
77+
self.$router.push({name: 'login'});
78+
})
79+
.catch(function (err) {
80+
self.$data.showSpinner = false;
81+
if (!err.response) {
82+
return console.error(err);
83+
}
84+
85+
const response = err.response;
86+
if (response.data && _.isObject(response.data.errors)) {
87+
self.$data.errors.push(response.data.errors[0].detail);
88+
}
89+
});
4990
}
5091
}
5192
}

resources/assets/pterodactyl/scripts/components/auth/LoginForm.vue

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,20 @@
22
<div>
33
<flash-message variant="danger" />
44
<flash-message variant="success" />
5-
<flash-message variant="warning" />
6-
<flash-message variant="info" />
7-
<div class="py-4" v-if="errors && errors.length === 1">
5+
<div class="pb-4" v-if="errors && errors.length === 1">
86
<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"
97
role="alert">
108
<span class="flex rounded-full bg-red uppercase px-2 py-1 text-xs font-bold mr-3 leading-none">Error</span>
119
<span class="mr-2 text-left flex-auto">{{ errors[0] }}</span>
1210
</div>
1311
</div>
1412
<form class="bg-white shadow-lg rounded-lg pt-10 px-8 pb-6 mb-4 animate fadein" method="post"
15-
v-on:submit.prevent="handleLogin"
13+
v-on:submit.prevent="submitForm"
1614
>
1715
<div class="flex flex-wrap -mx-3 mb-6">
1816
<div class="input-open">
19-
<input class="input" id="grid-username" type="text" name="user" aria-labelledby="grid-username"
17+
<input class="input" id="grid-username" type="text" name="user" aria-labelledby="grid-username" required
2018
ref="email"
21-
required
2219
v-bind:value="user.email"
2320
v-on:input="updateEmail($event)"
2421
/>
@@ -28,15 +25,19 @@
2825
<div class="flex flex-wrap -mx-3 mb-6">
2926
<div class="input-open">
3027
<input class="input" id="grid-password" type="password" name="password"
28+
ref="password"
3129
aria-labelledby="grid-password" required
3230
v-model="user.password"
3331
/>
3432
<label for="grid-password">{{ $t('strings.password') }}</label>
3533
</div>
3634
</div>
3735
<div>
38-
<button class="btn btn-blue btn-jumbo" type="submit">
39-
{{ $t('auth.sign_in') }}
36+
<button class="btn btn-blue btn-jumbo" type="submit" v-bind:disabled="showSpinner">
37+
<span class="spinner white" v-bind:class="{ hidden: ! showSpinner }">&nbsp;</span>
38+
<span v-bind:class="{ hidden: showSpinner }">
39+
{{ $t('auth.sign_in') }}
40+
</span>
4041
</button>
4142
</div>
4243
<div class="pt-6 text-center">
@@ -67,6 +68,7 @@
6768
data: function () {
6869
return {
6970
errors: [],
71+
showSpinner: false,
7072
}
7173
},
7274
mounted: function () {
@@ -75,8 +77,9 @@
7577
methods: {
7678
// Handle a login request eminating from the form. If 2FA is required the
7779
// user will be presented with the 2FA modal window.
78-
handleLogin: function () {
80+
submitForm: function () {
7981
const self = this;
82+
this.$data.showSpinner = true;
8083
8184
axios.post(this.route('auth.login'), {
8285
user: this.$props.user.email,
@@ -88,17 +91,20 @@
8891
}
8992
9093
self.$props.user.password = '';
94+
self.$data.showSpinner = false;
9195
self.$router.push({name: 'checkpoint', query: {token: response.data.token}});
9296
})
9397
.catch(function (err) {
9498
self.$props.user.password = '';
99+
self.$data.showSpinner = false;
95100
if (!err.response) {
96101
return console.error(err);
97102
}
98103
99104
const response = err.response;
100105
if (response.data && _.isObject(response.data.errors)) {
101-
self.$data.errors.push(response.data.errors[0].detail);
106+
self.$data.errors = [response.data.errors[0].detail];
107+
self.$refs.password.focus();
102108
}
103109
});
104110
},

resources/assets/pterodactyl/styles/components/buttons.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
&.btn-blue {
88
@apply .bg-blue .border-blue-dark .border .text-white;
99

10-
&:hover {
10+
&:hover:enabled {
1111
@apply .bg-blue-dark .border-blue-darker;
1212
}
1313
}
@@ -18,4 +18,9 @@
1818
&.btn-jumbo {
1919
@apply .p-4 .w-full .uppercase .tracking-wide .text-sm;
2020
}
21+
22+
&:disabled {
23+
opacity: 0.55;
24+
cursor: default;
25+
}
2126
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
.spinner {
2+
color: transparent;
3+
pointer-events: none;
4+
position: relative;
5+
6+
&:after {
7+
animation: spinners--spin 500ms infinite linear;
8+
border-radius: 9999px;
9+
@apply .border-2 .border-grey-light;
10+
border-top-color: transparent !important;
11+
border-right-color: transparent !important;
12+
content: '';
13+
display: block;
14+
width: 1em;
15+
height: 1em;
16+
left: calc(50% - (1em / 2));
17+
top: calc(50% - (1em / 2));
18+
position: absolute !important;
19+
}
20+
21+
/**
22+
* Speeds
23+
*/
24+
&.spin-slow:after {
25+
animation: spinners--spin 1200ms infinite linear;
26+
}
27+
28+
/**
29+
* Spinner Colors
30+
*/
31+
&.blue:after {
32+
@apply .border-blue;
33+
}
34+
35+
&.white:after {
36+
@apply .border-white;
37+
}
38+
39+
&.spinner-thick:after {
40+
@apply .border-4;
41+
}
42+
}
43+
44+
@keyframes spinners--spin {
45+
from {
46+
transform: rotate(0deg);
47+
}
48+
49+
to {
50+
transform: rotate(360deg);
51+
}
52+
}

resources/assets/pterodactyl/styles/main.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@import "components/animations.css";
1111
@import "components/authentication.css";
1212
@import "components/buttons.css";
13+
@import "components/spinners.css";
1314

1415
/**
1516
* Tailwind Utilities

resources/lang/en/auth.php

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

0 commit comments

Comments
 (0)