Users don't manage passwords well. They forget them or choose easy to remember passwords then use that same password on every site they visit. We then have to build features into our application to let them login when the password is forgotten or allow them to change the password at any time. We have to ensure we hold passwords securely even if our application is not that important because a user might be trusting us with their use-everywhere password.
Our applications are easier to manage with less support issues if we use a password-less login process. Such schemes are popular on sites like Medium with their 'magic link' or Notion.so with their login code such as jay-bawl-sack-lid
There is a good discussion of these password-less login methods in this .
So, how could we implement such a solution with a new Laravel 8 Jetstream project?
This article is using Jetstream with Livewire (this site is TALL stack focussed) but the principles should hold for Inertia also.
Prepare
I'm starting with a new Laravel 8 project, with Jetstream installed in Livewire flavour.
Remove the requirement for passwords
Our first task is to remove passwords from the Login process. To make this simple, we will give everyone a default password of 'password'. It won't be used, but it prevents us having to make too many changes to the Fortify+Jetstream code.
Remove password fields from login and register forms
Here, the password field is commented out of the validation, and the password added to the user record is just the hash of the string 'password'.
Testing: We should now be able to register a user, and login without any password. We have built a very insecure application at this point!
Create a source of pass-phrases
Create a folder within app called Utility
Place PassPhrase.php and wordlist.txt in this folder
Put pass-phrase into session when user logs in
When the user registers, Laravel will fire a Registered event and when the user is logged in either by the login form or the remember function, then a Login event is fired. We are going to listen for these events and place our randomly generated pass-phrase into session. Ultimately, the user will only be allowed access to our application if they can provide the same code that we have in session.
Create a listener
php artisan make:listener RequirePassPhrase
This uses our utility class to create a pass-phrase and store it along with an expiry timestamp.
app/Listeners/RequirePassPhrase.php
<?php
namespace App\Listeners;
use App\Utility\PassPhrase;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Session;
class RequirePassPhrase
{
protected $generator;
/**
* Create the event listener.
*
* @return void
*/
public function __construct(PassPhrase $generator)
{
$this->generator = $generator;
}
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle($event)
{
// don't need to interrupt the process if the user
// logged in with remember token
if(auth()->viaRemember()) {
return;
}
$passphrase = $this->generator->passPhrase(3);
Session::put('passphrase', $passphrase);
Session::put('passphrase_expiry', now()->addMinutes(15)->timestamp);
}
}
With this; $this->generator->passPhrase(3); we create a phrase with three words. Set this according to your preferences. The EFF article mentioned earlier explains;
for k words chosen from a list of length n, there are nk possible passphrases of this type. It will take an adversary about nk/2 guesses on average to crack this passphrase.
Our wordlist is approximately 4100 words, so 3 words is 4100x4100x4100/2 = 34,400,000,000 guesses so don't go overboard with the number of words in the passphrase.
Bind our Listener to Events
Listening for events is configured in the app\Providers\EventServiceProvider. We add our listener for the two events. We can remove the email verification listener as if the user can receive the passcode then they verified the email at the same time.
Having created the code and put it in session we need to send this to the user. Notifications are the easiest to implement here, and you could, for instance, choose to send the code to the user by one of the other notification methods such as SMS text.
Create the notification
php artisan make:notification AdvisePassPhrase
Adjust your new notification (use your own words as required)
app/Notifications/AdvisePassPhrase.php
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AdvisePassPhrase extends Notification
{
use Queueable;
public $passphrase;
public function __construct(string $passphrase)
{
$this->passphrase = $passphrase;
}
public function via($notifiable)
{
return ['mail'];
}
public function toMail($notifiable)
{
return (new MailMessage)
->subject('Your login code for ' . config('app.name'))
->line('Here is your login PassPhrase which is valid for the next 15 minutes')
->line($this->passphrase)
// ->action('Notification Action', url('/'))
->line('Thank you for using our application!');
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}
Call the Notification and provide the pass-phrase
In our earlier Listener, add a line to send the notification;
app/Listeners/RequirePassPhrase.php
use App\Notifications\AdvisePassPhrase;
Session::put('passphrase', $passphrase);
Session::put('passphrase_expiry', now()->addMinutes(15)->timestamp);
$event->user->notify(new AdvisePassPhrase($passphrase));
Line 7 is added to the earlier file
Testing
Provided we have configured a mail service such as mailtrap, when we register or login, a mail should be received containing our passphrase.
Accept and validate the pass-phrase
Create Controller
php artisan make:controller PassPhraseController
app/Http/Controllers/PassPhraseController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\LoginResponse;
class PassPhraseController extends Controller
{
public function show()
{
return view('auth.passphrase');
}
public function store(Request $request)
{
if (Session::get('passphrase_expiry') < now()->timestamp ){
Auth::logout();
$this->clearSession($request);
return redirect()->route('login')->withErrors(['email' =>['Your Passphrase has expired. Please login again']]);
}
if (strToLower($request->passphrase) != Session::get('passphrase')) {
throw ValidationException::withMessages([
'passphrase' => ['Sorry, that is not the correct passphrase. Please check your email for the latest message.'],
]);
}
$this->clearSession($request);
return app(LoginResponse::class);
}
public function clearSession($request)
{
$request->session()->forget('passphrase');
$request->session()->forget('passphrase_expiry');
}
}
Call this from your routes file
routes/web.php
use App\Http\Controllers\PassPhraseController;
//
Route::get('/login/confirm',[PassPhraseController::class,'show'])->name('login.confirm');
Route::post('/login/confirm',[PassPhraseController::class,'store'])->name('login.confirmation');
Create a form for the capture of the pass-phrase
The easiest route with a new application is to just copy the Login view and edit a few of the fields;
Visit the route /login/confirm and check you can see the form.
Entering an invalid code should show the message that the code is incorrect
Entering a valid code should direct to the home route
Waiting 15 minutes and entering a code should report that the code has expired
Add middleware to block access until pass-phrase accepted
We can create a middleware that checks the user's session. If it contains a passphase key then the user is in the middle of logging in and should not be permitted to access the application. We need to except the login routes from the middleware so that the user can access the login process.
Make Middleware
php artisan make:middleware PassPhraseGuard
app/Http/Middleware/PassPhraseGuard.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PassPhraseGuard
{
// if the user's session contains a passphrase then we need to direct the user to the
// passphrase confirm route instead.
// need to allow the user through to any login routes
public function handle(Request $request, Closure $next)
{
$passphrase = $request->session()->get('passphrase', null);
if (is_null($passphrase)) {
return $next($request);
}
// passphrase set, still valid?
if ($request->session()->get('passphrase_expiry') < now()->timestamp) {
$request->session()->forget('passphrase');
$request->session()->forget('passphrase_expiry');
Auth::logout();
return redirect('/');
}
if ($request->route()->named('login.*')) {
return $next($request);
}
return redirect()->route('login.confirm');
}
}
Include the Middleware as a global route middleware
Include the new middleware in your web middleware stack
For this solution a passphrase is a combination of 3 or 4 words separated by hyphens. The words are sourced from a list and are chosen because they are short and easy to spell.
Rather than publish the code and word list here, you can access it via
In this article we created a password-less login process that uses a pass-phrase technique. .
If you have any suggestions how this article can be improved, contribute to discussion at