Skip to content

Commit 821e435

Browse files
committed
Enable login throttling
1 parent b4a27f3 commit 821e435

File tree

9 files changed

+282
-2
lines changed

9 files changed

+282
-2
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,34 @@ You can change the URL by setting this option:
7777

7878
It runs under the `web` middleware since it uses the session to keep you logged in. You can change the middleware if needed in the [configuration file](#publish-configuration-file).
7979

80+
## Throttle Login Attempts
81+
82+
To prevent malicious users from brute forcing passwords, login attempts will be throttled unless you disable it. You can change the number of failed attempts per minute to allow, and the delay (in minutes) that users have to wait after reaching the maximum failed attempts.
83+
84+
| Option | Type | Default |
85+
| --------------------------- | --------- | ---------------- |
86+
| `STAGEFRONT_THROTTLE` | `bool` | `true` |
87+
| `STAGEFRONT_THROTTLE_TRIES` | `integer` | `3` (per minute) |
88+
| `STAGEFRONT_THROTTLE_DELAY` | `integer` | `5` (minutes) |
89+
90+
When you tried to login too many times, Laravel's 429 error page will be shown. You can easily modify this by creating a `429.blade.php` view in `resources/views/errors`. To save you a little time, I have included a localized template you can include in that page:
91+
92+
```blade
93+
@include('stagefront::429')
94+
```
95+
96+
If you want to include a different partial for other throttled pages, you can check the request:
97+
98+
```blade
99+
@if (request()->is(config('stagefront.url')))
100+
@include('stagefront::429')
101+
@else
102+
@include('your.partial.view')
103+
@endif
104+
```
105+
106+
Text in this view can be changed via the [translation files](#translations-and-views).
107+
80108
## Ignore URLs
81109

82110
If for any reason you wish to disable StageFront on specific routes, you can add these to the `ignore_urls` array in the [configuration file](#publish-configuration-file). You can use wildcards if needed. You can't set this in the `.env` file.
@@ -112,7 +140,7 @@ php artisan vendor:publish
112140

113141
Each option is documented.
114142

115-
## Translations & Login View
143+
## Translations and Views
116144

117145
You can publish the translations to quickly adjust the text on the login screen and the errors. If you want to customize the login page entirely, you can also publish the view.
118146

config/stagefront.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@
7878
*/
7979
'url' => env('STAGEFRONT_URL', 'stagefront'),
8080

81+
/**
82+
* To prevent malicious users from brute forcing passwords
83+
* login attempts will be throttled unless you disable it.
84+
*
85+
* Default: true
86+
*/
87+
'throttle' => env('STAGEFRONT_THROTTLE', true),
88+
89+
/**
90+
* Number of failed login attempts per minute before
91+
* users are locked out for a period of time.
92+
*
93+
* Default: 3
94+
*/
95+
'throttle_tries' => env('STAGEFRONT_THROTTLE_TRIES', 3),
96+
97+
/**
98+
* Number of minutes to lock out users after reaching
99+
* the maximum number of login attempts.
100+
*
101+
* Default: 5
102+
*/
103+
'throttle_delay' => env('STAGEFRONT_THROTTLE_DELAY', 5),
104+
81105
/**
82106
* The route middleware to use.
83107
* Since StageFront uses the session, we definitely require

resources/lang/en/errors.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
<?php
22

33
return [
4+
45
'login' => [
56
'required' => 'Enter a login.',
67
],
8+
79
'password' => [
810
'required' => 'Enter a password.',
911
'match' => 'Invalid credentials.',
1012
],
13+
14+
'throttled' => [
15+
'intro' => 'Maximum number of failed login attempts exceeded.',
16+
'remaining' => 'Try again in :remaining.',
17+
'moment' => 'a moment',
18+
'back' => 'Back',
19+
],
20+
1121
];

resources/lang/nl/errors.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
<?php
22

33
return [
4+
45
'login' => [
56
'required' => 'Vul een login in.',
67
],
8+
79
'password' => [
810
'required' => 'Vul een wachtwoord in.',
911
'match' => 'Ongeldige gebruikersnaam of wachtwoord.',
1012
],
13+
14+
'throttled' => [
15+
'intro' => 'Maximum aantal mislukte login pogingen overschreden.',
16+
'remaining' => 'Probeer opnieuw over :remaining.',
17+
'moment' => 'enkele ogenblikken',
18+
'back' => 'Terug',
19+
],
20+
1121
];

resources/views/429.blade.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<!doctype html>
2+
<html lang="{{ app()->getLocale() }}">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
6+
<meta http-equiv="X-UA-Compatible" content="ie=edge">
7+
<title>{{ config('app.name') }}</title>
8+
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lato:300,700">
9+
<style>
10+
html, body {
11+
height: 100%;
12+
margin: 0;
13+
}
14+
body {
15+
display: flex;
16+
flex-direction: row;
17+
align-items: center;
18+
justify-content: center;
19+
font-family: 'Lato', sans-serif;
20+
text-align: center;
21+
font-size: 20px;
22+
}
23+
section {
24+
padding: 1em;
25+
}
26+
small {
27+
font-size: .8em;
28+
}
29+
a, a:visited, a:active {
30+
text-decoration: none;
31+
}
32+
a.button {
33+
display: inline-block;
34+
width: 100%;
35+
max-width: 150px;
36+
line-height: 1.5em;
37+
padding: .5em;
38+
font-size: 1rem;
39+
box-shadow: none;
40+
background: #212121;
41+
color: #ffffff;
42+
border: 1px solid #212121;
43+
cursor: pointer;
44+
margin-top: 1em;
45+
}
46+
a.button:focus {
47+
outline: none;
48+
box-shadow: none;
49+
border: 1px solid #212121;
50+
}
51+
.caps {
52+
text-transform: uppercase;
53+
}
54+
.center {
55+
text-align: center;
56+
}
57+
</style>
58+
</head>
59+
<body>
60+
61+
<section>
62+
63+
<h1 class="caps">{{ config('app.name') }}</h1>
64+
65+
<p>{{ trans('stagefront::errors.throttled.intro') }}</p>
66+
67+
<p><small>{{ trans('stagefront::errors.throttled.remaining', ['remaining' => $timeRemaining]) }}</small></p>
68+
69+
<p class="center">
70+
<a href="{{ config('stagefront.url') }}" class="button caps">
71+
&langle; {{ trans('stagefront::errors.throttled.back') }}
72+
</a>
73+
</p>
74+
75+
</section>
76+
77+
</body>
78+
</html>

routes/routes.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77
Route::group(['middleware' => config('stagefront.middleware')], function () {
88

99
$url = config('stagefront.url');
10+
$throttle = config('stagefront.throttle');
11+
$tries = config('stagefront.throttle_tries');
12+
$delay = config('stagefront.throttle_delay');
13+
$middleware = $throttle ? "throttle:{$tries},{$delay}" : [];
1014

1115
Route::get($url, StageFrontController::class.'@create');
12-
Route::post($url, StageFrontController::class.'@store');
16+
Route::post($url, StageFrontController::class.'@store')->middleware($middleware);
1317

1418
});
1519

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace CodeZero\StageFront\Composers;
4+
5+
use Carbon\Carbon;
6+
use Illuminate\Cache\RateLimiter;
7+
use Illuminate\Contracts\View\View;
8+
9+
class ThrottleTimeRemaining
10+
{
11+
/**
12+
* Provide the view with the time remaining on the throttle.
13+
*
14+
* @param \Illuminate\Contracts\View\View $view
15+
*
16+
* @return void
17+
*/
18+
public function compose(View $view)
19+
{
20+
$view->with('timeRemaining', $this->getTimeRemaining());
21+
}
22+
23+
/**
24+
* Get the time remaining on the throttle in a human readable format.
25+
*
26+
* @return string
27+
*/
28+
protected function getTimeRemaining()
29+
{
30+
if ( ! $key = $this->getCacheKey()) {
31+
return trans('stagefront::errors.throttled.moment');
32+
}
33+
34+
$secondsRemaining = $this->getSecondRemaining($key);
35+
36+
Carbon::setLocale(app()->getLocale());
37+
38+
return Carbon::now()
39+
->addSeconds($secondsRemaining)
40+
->diffForHumans(null, true);
41+
}
42+
43+
/**
44+
* Resolve the cache key for the throttle info.
45+
* See `resolveRequestSignature` method:
46+
* https://github.com/illuminate/routing/blob/master/Middleware/ThrottleRequests.php#L88
47+
*
48+
* @return string|null
49+
*/
50+
protected function getCacheKey()
51+
{
52+
$request = request();
53+
54+
if ($user = $request->user()) {
55+
return sha1($user->getAuthIdentifier());
56+
}
57+
58+
if ($route = $request->route()) {
59+
return sha1($route->getDomain().'|'.$request->ip());
60+
}
61+
62+
return null;
63+
}
64+
65+
/**
66+
* Get the remaining seconds on the throttle.
67+
*
68+
* @param string $key
69+
*
70+
* @return mixed
71+
*/
72+
protected function getSecondRemaining($key)
73+
{
74+
return app(RateLimiter::class)->availableIn($key);
75+
}
76+
}

src/StageFrontServiceProvider.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace CodeZero\StageFront;
44

5+
use CodeZero\StageFront\Composers\ThrottleTimeRemaining;
56
use Illuminate\Support\ServiceProvider;
67

78
class StageFrontServiceProvider extends ServiceProvider
@@ -22,6 +23,7 @@ public function boot()
2223
{
2324
$this->loadRoutes();
2425
$this->loadViews();
26+
$this->loadViewComposers();
2527
$this->loadTranslations();
2628
$this->registerPublishableFiles();
2729
}
@@ -56,6 +58,16 @@ protected function loadViews()
5658
$this->loadViewsFrom(__DIR__.'/../resources/views', $this->name);
5759
}
5860

61+
/**
62+
* Load the package view composers.
63+
*
64+
* @return void
65+
*/
66+
protected function loadViewComposers()
67+
{
68+
view()->composer('stagefront::429', ThrottleTimeRemaining::class);
69+
}
70+
5971
/**
6072
* Load package translations.
6173
*

tests/StageFrontTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,44 @@ public function urls_can_be_ignored_so_access_is_not_denied_by_stagefront()
242242
$this->get('/public/route')->assertStatus(200)->assertSee('Route');
243243
}
244244

245+
/** @test */
246+
public function it_throttles_login_attempts()
247+
{
248+
$faultyCredentials = [
249+
'login' => 'tester',
250+
'password' => 'invalid',
251+
];
252+
253+
config()->set('stagefront.throttle', true);
254+
config()->set('stagefront.throttle_tries', 2);
255+
config()->set('stagefront.throttle_delay', 2);
256+
257+
$this->enableStageFront();
258+
259+
$this->submitForm($faultyCredentials)->assertRedirect($this->url);
260+
$this->submitForm($faultyCredentials)->assertRedirect($this->url);
261+
$this->submitForm($faultyCredentials)->assertStatus(429);
262+
}
263+
264+
/** @test */
265+
public function throttling_login_attempts_can_be_disabled()
266+
{
267+
$faultyCredentials = [
268+
'login' => 'tester',
269+
'password' => 'invalid',
270+
];
271+
272+
config()->set('stagefront.throttle', false);
273+
config()->set('stagefront.throttle_tries', 2);
274+
config()->set('stagefront.throttle_delay', 2);
275+
276+
$this->enableStageFront();
277+
278+
$this->submitForm($faultyCredentials)->assertRedirect($this->url);
279+
$this->submitForm($faultyCredentials)->assertRedirect($this->url);
280+
$this->submitForm($faultyCredentials)->assertRedirect($this->url);
281+
}
282+
245283
/**
246284
* Tell Laravel we navigated to this intended URL and
247285
* got redirected to the login page so that

0 commit comments

Comments
 (0)