How to protect URLs with a PIN code in Laravel

Martin Betz • May 26, 2020

Please use with caution! The solution presented here should not be used for sites where high security needs to be guaranteed. I will update the post accordingly, but refer to this Reddit post for reference.

Also, Thomas Moors added very good comments.

Here is the user story for this post: As a website owner I only want to allow access to my website for visitors who enter the correct PIN.

How the minimum version will work

When you enter the page for the first time, you will just see an input to enter a PIN. When you enter the wrong PIN, it fails silently and just reloads the page. If you enter the right PIN, it will save a cookie access and shows you the welcome page. If you try more than 3 times within a minute, you get a message Too Many Requests.

And here is how it will look in the browser:

The tests

I will first create the sad path where I cannot access the welcome page as I did not enter the right PIN. Then I will simulate that I previously entered the right PIN and have a cookie that lets me pass. Then I will test that actual entering of the PIN and check whether I get a cookie. Lastly I check that I get banned if I enter three wrong PINs in a very short time:

<?php

namespace Tests\Feature\Http\Controllers;

use Illuminate\Support\Facades\Config;
use Tests\TestCase;

class WelcomeTest extends TestCase
{

    /** @test */
    public function can_not_access_welcome_page_without_pin() {
        $response = $this->get(route('root'));
        $response->assertStatus(302);
        $response->assertRedirect(route('pin.create'));
    }

    /** @test */
    public function can_access_welcome_page_with_pin_cookie() {
        $response = $this->withCookie('access', 'pass')->get(route('root'));
        $response->assertStatus(200);
    }

    /** @test */
    public function can_enter_pin_and_access_root_page() {
        Config::set('settings.PIN', '5678');
        $response = $this->post(route('pin.store', [
            'pin' => '5678',
        ]));
        $response->assertCookie('access', 'pass');
    }

    /** @test */
    public function blocks_for_one_minute_after_three_attemps() {
        $this->post(route('pin.store', [
            'pin' => '1',
        ]));
        $this->post(route('pin.store', [
            'pin' => '2',
        ]));
        $this->post(route('pin.store', [
            'pin' => '3',
        ]));
        $response = $this->post(route('pin.store', [
            'pin' => '3',
        ]));
        $response->assertStatus(429);
    }
}

The code

I wrote the tests first and added the code one error after another. You may replicate by just running the tests above and go through the errors yourself.

Here's the files you need with a short explanation what they do:

And here are the important sections of the files. I marked omissions with (…). You can find the full source code on https://github.com/minthemiddle/pin-tutorial:

// routes/web.php
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
})->name('root')->middleware('pin');

Route::get('pin/create', function () {
    return view('create');
})->name('pin.create');

Route::post('pin/store', 'PinController@store')->name('pin.store')->middleware('throttle:3,1');
// app/Http/Middleware/CheckPin.php
<?php

namespace App\Http\Middleware;

use Closure;

class CheckPin
{
    public function handle($request, Closure $next)
    {
        if ($request->cookie('access') === 'pass') {
            return $next($request);
        }

        return redirect(route('pin.create'));
    }
}
// app/Http/Kernel.php
<?php

namespace App\Http;

use App\Http\Middleware\CheckPin;
use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    (…)

    protected $routeMiddleware = [
        (…)
        \App\Http\Middleware\CheckPin::class,
    ];
}
// app/Http/Controllers/PinController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;

class PinController extends Controller
{
    public function store(Request $request)
    {
        if ($request->pin === Config::get('settings.PIN')) {
            return redirect(route('root'))->withCookie('access', 'pass', 60);
        }

        return redirect(route('pin.create'));
    }
}
// resources/views/create.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://unpkg.com/@tailwindcss/custom-forms@0.2.1/dist/custom-forms.min.css">
    <title>Enter PIN</title>
</head>
<body class="p-8 mx-auto">
<form action="{{ route('pin.store') }}" method="POST">
    @csrf
    <input type="number" required name="pin" class="form-input text-xl">
    <button type="submit" class="form-input p-2 text-xl">Save</button>
</form>
</body>
</html>
// resources/views/welcome.blade.php
// I just used the standard welcome page that ships with Laravel

```php
// config/settings.php
<?php

return [
    'PIN' => env('PIN', '1234'),
];
# .env
(…)
PIN=5678
(…)