Authorization without password in Laravel

by Alex

If you’ve ever used a site like Vercel or Medium, you’ve probably encountered a login without a password before. The process usually looks like this: -> enter your email address -> send form -> an email will be sent to you -> you click the link inside -> you are logged in. It’s a pretty convenient way for everyone. Users don’t have to remember a password with an arbitrary set of website rules, and webmasters (people still use that term?) don’t have to worry about password leaks or whether they’re secure enough. In this article, we’re going to explore how you can implement this case using a standard Laravel installation. We’re going to assume that you have a working understanding of Laravel’s MVC structure and that Composer and PHP are already set up in your environment.

Note that the codeblocks in this article may not include the entire file for brevity.

Setting up your environment

Let’s start by creating a new Laravel 8 application:

$ composer create-project laravel/laravel magic-links

Then we need to log into our project and make sure we have accesses to our database specified. Be sure to create the database in advance. In my case, I use PostgreSQL. Open the .env file:

# .env
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=magic_link
DB_USERNAME=postgres
DB_PASSWORD=postgres

Our database is now set up, but don’t run the migrations yet! Let’s look at the default user migration that Laravel created for us in database/migrations/2014_10_12_000000_create_users_table.php. You’ll see that the default user table contains a column for the password. Since we are authenticating without a password, we can get rid of it:

public function up()
{
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->rememberToken();
    $table->timestamps();
  });
}

Save the file after deleting this row. Also delete the migration for the password reset table, since we won’t need it:

$ rm database/migrations/2014_10_12_100000_create_password_resets_table.php

Our initial database schema is ready, so let’s run our migrations:

$ php artisan migrate

Let’s also remove the password attribute from the $fillable array of the user model in app/Models/User.php, since it no longer exists:

protected $fillable = [
  'name',
  'email',
];

We also need to configure our mail driver so that we can preview our login emails. For training purposes, you can try the Mailtrap service, which is a free tool for capturing SMTP traffic (you can send emails to any address and they will only show up in Mailtrap, not delivered to the actual user), but you can use whatever you want. If you don’t want to configure anything, you can use the send log and the emails will show up in storage/logs/laravel.log as raw text. Let’s go back to the same .env file:

# .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=252525
MAIL_USERNAME=redacted
MAIL_PASSWORD=redacted
MAIL_ENCRYPTION=tls
[email protected]

Now we are ready to start developing!

What to do

At the beginning of the article, we talked about how the case looks from the user’s point of view, but how does it work from a technical point of view? Well, considering the user, we should be able to send him a unique link which, when he clicks on it, introduces him to his account. That tells us that we probably need to generate some kind of unique token, associate it with the user trying to log in, build a route that looks at that token and determines if it’s valid, and then do the user log in. You also need these tokens to be used only once and only valid for a certain amount of time after they are generated. Since we need to keep track of whether a token has already been used, we are going to store them in a database. It will also be handy to keep track of which token belongs to which user, as well as whether the token has been used or not, and whether it has already expired.

Creating a test user

Next we will focus only on the login process. You will need to create a login page, although it will do all the same steps. Because of this, we will need a user in the database to test the login. Let’s create it with tinker:

$ php artisan tinker
> User::create(['name' => 'Jane Doe', 'email' => '[email protected]'])

Logon Route

We will start by creating an AuthController, which we will use to handle the login, verification, and logout functions:

$ php artisan make:controller AuthController

Now let’s register the login routes in the routes/web.php file of our application. Under the welcome route, let’s define a group of routes that will protect our authentication routes with middleware, preventing people who are already logged in from viewing them. Within this group we will create two routes. One to display the login page, the other to handle the form submission. We will also give them names so that we can easily refer to them later:

Route::group(['middleware' => ['guest']], function() {
  Route::get('login', [AuthController::class, 'showLogin'])->name('login.show');
  Route::post('login', [AuthController::class, 'login'])->name('login');
});

The routes are now registered, but we need to create actions that will respond to these routes. Let’s create these methods in the app/Http/Controllers/AuthController.php controller we created. At this point, our login page will return the view located in auth.login (which we’ll create next), and let’s create a login method that we’ll return to after we create the form:

<?php
namespace AppHttpControllers;

use IlluminateHttpRequest;

class AuthController extends Controller
{
  public function showLogin()
  {
    return view('auth.login');
  }

  public function login(Request $request)
  {
    // TODO
  }
}

We’re going to use the Laravel Blade template system and Tailwind CSS for our views. Since the focus of this article is on server-side logic, we won’t go into styling details. I don’t want to spend time setting up the correct CSS configuration, so we’ll use a link from the CDN we can add to our layout to handle the correct styles. You may notice a flash of styles when the page first loads. That’s because the styles don’t exist until the page loads. You don’t need this in a production environment, but it’s fine for a tutorial. Let’s start by creating a common layout that we can use for all of our pages. This file will be in the resources/views/layouts/app.blade.php folder:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{$title }}</title>
</head>
<body>
@yield('content')
  <script src="https://unpkg.com/tailwindcss-jit-cdn"></script>
</body>
</html>

I’ll note a few things here:

  • The title of the page will be set by the $title variable, which we will pass to the layout when we extend it.
  • Blade @yield('content') directive – when we extend this layout, we will use a named section called ‘content’ to place our content for a specific page.
  • The TailwindCSS JIT CDN script we use to handle our styles

Now that we have the layout, we can create the login page in resources/views/auth/login.blade.php:

@extends('layouts.app', ['title' => 'Login'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      <h1 class="text-xl font-semibold">Login</h1>
      <form action="{{ route('login') }}" method="post" class="space-y-4">
        @csrf
        <div class="space-y-1">
          <label for="email" class="block">Email</label>
          <input type="email" name="email" id="email" class="block w-full border-gray-400 rounded-md px-4 py-2" />
          @error('email')
            <p class="text-sm text-red-600">{{ $message }}}</p>
          @enderror
        </div>
        <button class="rounded-md px-4 py-2 bg-indigo-600 text-white">Login</button>
      </form>
    </div>
  </div>
@endsection

Let’s take a closer look:

  • We start by extending the layout we created earlier and giving it an “Entry” header, which will be the header of our document tab.
  • We declare a section called content (remember @yield earlier?) And put the content of our page inside, which will be displayed in the layout.
  • Some basic containers and styles are applied to center the form in the middle of the screen.
  • The action of the form points to the named route ('login'), which, if we remember from the routes/web.php file, is the name we gave the POST login request to our controller.
  • We enable the hidden CSRF field with the @csrf directive(see documentation for details)
  • We show any validation errors provided by Laravel using the @error directive, if any.

If you load the page, it should look like this: Pretty simple, we’re just asking for the user’s email address. If we submit the form right now, you’ll just see a blank white screen because our login method, which we defined earlier, is empty. Let’s implement the login method in our AuthController to send a link using it to complete the login. The case will look something like this: ->check the form data -> send a link to log in -> show the user a message on the page asking him to check his email.

// app/Http/Controllers/AuthController.php
// near other use statements
use AppModelsUser;

// inside class
public function login(Request $request)
{
  $data = $request->validate([
    'email' => ['required', 'email', 'exists:users,email'],
  ]);
  User::whereEmail($data['email'])->first()->sendLoginLink();
  session()->flash('success', true);
  return redirect()->back();
}

Here we do several things:

  • Checking the form data – specifying that the email address is required, must be valid and exist in our database.
  • We find the user at the specified email address and call the sendLoginLink function, which we will need to implement.
  • We pass a session value indicating that the request was successful and then return the user back to the login page.

There are a couple of unfinished tasks in the steps above, so we need to complete them now. We’ll start by updating our login view to check for that logical success value, hide our form, and show the user a message if one is present. Back to resources/views/auth/login.blade.php:

@extends('layouts.app', ['title' => 'Login'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      @if(!session()->has('success'))
        <h1 class="text-xl font-semibold">Login</h1>
        <form action="{{ route('login') }}" method="post" class="space-y-4">
          @csrf
          <div class="space-y-1">
            <label for="email" class="block">Email</label>
            <input type="email" name="email" id="email" class="block w-full border-gray-400 rounded-md px-4 py-2" />
            @error('email')
              <p class="text-sm text-red-600">{{ $message }}}</p>
            @enderror
          </div>
          <button class="rounded-md px-4 py-2 bg-indigo-600 text-white">Login</button>
        </form>
      @else
Please click the link sent to your email to finish logging in.
      @endif
    </div>
  </div>
@endsection

Here we just wrapped the form with a conditional expression. Did we just submit the form successfully?

  • No – show registration form
  • Yes – let the user know that their account has been created and check their email for a link.

Now, if you submit that form again, you’ll see an error message saying that we need to implement this sendLoginLink function in the User model. I like to keep this kind of logic in the model itself so we can reuse it in our application later. Open app/Models/User.php and create an empty method to fill its place:

public function sendLoginLink()
{
  // TODO
}

Now submit the form again and make sure you see the success message as shown below: Of course, you haven’t received the email yet, but now we can move on to this step.

Reflecting on the token approach we discussed above, let’s outline a further plan of action:

  1. Generate a unique token and attach it to the user
  2. Send an email to the user with a link to a page that verifies this token.

We are going to store them in a table named login_tokens. We will create a model and a migration(-m):

$ php artisan make:model -m LoginToken

For the migration we will need:

  • The unique token for the URL we are generating
  • The link that will connect it to the requesting user
  • Expiration date of the token
  • A flag which tells us if the token has already been used. We are going to use the timestamp field for this, since the absence of a value in this column will tell us if it has been used, and this timestamp also lets us know when it has been used – a double benefit!

Open the generated migration and add the necessary columns:

Schema::create('login_tokens', function (Blueprint $table) {

$table->id();
$table->unsignedBigInteger(‘user_id’);
$table->foreign(‘user_id’)->references(‘id’)->on(‘users’)->cascadeOnDelete();
$table->string(‘token’)->unique();
$table->timestamp(‘consumed_at’)->nullable();
$table->timestamp(‘expires_at’);
$table->timestamps();
});

Don’t forget to run the migration afterwards:

$ php artisan migrate

Then update our new app/Models/LoginToken model to account for a few things: Set our $guarded property to an empty array, which means we don’t limit which columns can be populated Create a $date property that will convert our expires_at and consmed_at fields into CarbonCarbon instances when we reference them in php code for convenience later. Our user() method, which allows us to refer to the user associated with the token

the class LoginToken extends Model
{
  use HasFactory;

  protected $guarded = [];
  protected $dates = [
    'expires_at', 'consumed_at',
  ];

  public function user()
  {
    return $this->belongsTo(User::class);
  }
}

It’s also a good idea to put the reference back in the User model:

// inside app/Models/User.php
public function loginTokens()
{
  return $this->hasMany(LoginToken::class);
}

Now that we have the model set up, we can perform the first step of our sendLoginLink() function, which creates the token. Back in app/Models/User.php, we’re going to create a token for the user using the new loginTokens() link we just created and assign it a random string using the Str helper from Laravel and expiration after 15 minutes. Since we set expires_at and consmed_at as dates in the LoginToken model, we can simply pass the current date and it will be converted accordingly. We will also hash the token before inserting it into the database, so that if this table is hacked, no one can see the raw values of the token. We will use a reproducible hash so that we can find it later when needed:

use IlluminateSupportStr;

public function sendLoginLink()
{
    $plaintext = Str::random(32);
    $token = $this->loginTokens()->create([
      'token' => hash('sha256', $plaintext),
      'expires_at' => now()->addMinutes(15),
    ]);
    // todo send email
}

Now that we have the token, we can send the user an email containing a link with the (plain text) token in the URL that will validate their session. The token must be in the URL so that we can know which user it is for. Let’s move on to creating a class to generate the email.

$ php artisan make:mail MagicLoginLink

Open the class to create the email in app/Mail/MagicLoginLink.php, and enter the following:

<?php
namespace AppMail;

use IlluminateBusQueueable;;
use IlluminateMailMailable;
use IlluminateQueueSerializesModels;
use IlluminateSupportFacadesURL;

class MagicLoginLink extends Mailable
{
  use Queueable, SerializesModels;

  public $plaintextToken;
  public $expiresAt;

  public function __construct($plaintextToken, $expiresAt)
  {
    $this->plaintextToken = $plaintextToken;
    $this->expiresAt = $expiresAt;
  }

  public function build()
  {
    return $this->subject(
      config('app.name') . ' Login Verification'
    )->markdown('emails.magic-login-link', [
      'url' => URL::temporarySignedRoute('verify-login', $this->expiresAt, [
        'token' => $this->plaintextToken,
      ]),
    ]);
  }
}

The class constructor will take the open text token and expiration date and store them in public properties. This will allow us to use it later in the build() method when composing it. Inside the build() method, we set the subject line and tell it to look for the formatted representation inside resources/views/emails/magic-login-link.blade.php. Laravel provides some default styles for emails, which we’ll use shortly. We also pass a url variable to the view, which will be the link the user clicks on. This url property is a temporary signed URL. It takes a named route, an expiration date (which we want to use as the expiration date of our tokens) and any parameters (in this case the token is an unhashed random string we generated). A signed URL ensures that the URL has not been changed at all, by hashing the URL with a secret string known only to Laravel. Although we are going to add checks to our verify-login route to make sure our token is still valid (based on the expires_at and consmed_at properties), signing the URL gives us extra security at the framework level, since no one will be able to forcibly use the verify-login route with random tokens to see if they can find the one that does their login. Now we need to implement this markdown view in resources/views/emails/magic-login-link.blade.php. You may be wondering why this is a .blade.php extension. This is because even though we are writing markup in this file, we can use within the Blade directive to create reusable components that we can use in our emails. Laravel provides us with out-of-the-box components to get right to work. We use mail::message, which gives us a layout and a call to action via mail::button:

@component('mail::message')
Hello, to finish logging in please click the link below
  @component('mail::button', ['url' => $url])
    Click to login
  @endcomponent
@endcomponent

Now that we have the email content, we can complete the sendLoginLink() method by actually sending the email. We’re going to use the Mail facade provided by Laravel to specify the users email we’re sending it to, and that email content should be embedded from the MagicLoginLink class we just finished setting up. We also use queue() instead of send() so that the email is sent in the background instead of during the current request. Make sure your queue is configured properly or that you are using the sync driver (this is the default) if you want this to happen immediately.

use IlluminateSupportFacadesMail;
use AppMailMagicLoginLink;

public function sendLoginLink()
{
  $plaintext = Str::random(32);
  $token = $this->loginTokens()->create([
    'token' => hash('sha256', $plaintext),
    'expires_at' => now()->addMinutes(15),
  ]);
  Mail::to($this->email)->queue(new MagicLoginLink($plaintext, $token->expires_at));
}

If you submitted our login form, you would now see an email that looks like this: Авторизация без пароля в Laravel

Verification Route

If you tried to click the link, you probably got a 404 error. That’s because in our email, we sent the user a link to a named verify-login route, but we haven’t created one yet! Register the route in the routes group inside routes/web.php:

Route::group(['middleware' => ['guest']], function() {
  Route::get('login', [AuthController::class, 'showLogin'])->name('login.show');
  Route::post('login', [AuthController::class, 'login'])->name('login');
  Route::get('verify-login/{token}', [AuthController::class, 'verifyLogin'])->name('verify-login');
});

Then we’ll create an implementation inside our AuthController class using the verifyLogin method:

public function verifyLogin(Request $request, $token)
{
  $token = AppModelsLoginToken::whereToken(hash('sha256', $token))->firstOrFail();
  abort_unless($request->hasValidSignature() && $token->isValid(), 401);
  $token->consume();
  Auth::login($token->user);
  return redirect('/');
}

Here we do the following:

  • Search for the token by hashing the open text value and comparing it with the hashed version in our database (outputs 404, if not found – via firstOrFail())
  • Cancel the request with a 401 status code if the token is invalid or the signed URL is invalid (you can fancy it here if you want to show a view or something that lets the user know more information, but for this tutorial we’ll just complete the request)
  • Marking the token as used, so it can’t be used again
  • Authorize the user associated with the token
  • Redirecting to the homepage

We call a couple of methods for the token that don’t really exist yet, so let’s create them:

  • isValid() is true if the token has not yet been used(consmed_at === null) and if it has not expired(expires_at <= now)
  • We will extract expired and used by checking their own functions to make them more readable.
  • consume() is going to set the consmed_at property to the current timestamp
public function isValid()
{
  return !$this->isExpired() && !$this->isConsumed();
}

public function isExpired()
{
  return $this->expires_at->isBefore(now());
}

public function isConsumed()
{
  return $this->consumed_at !== null;
}

public function consume()
{
  $this->consumed_at = now();
  $this->save();
}

If you were to click this link right now to log into your email address, you should have been redirected to /route! You will also notice that if you click the link again, you will be shown an error screen because it is now invalid.

The finishing touches

Now that our authentication is working, let’s make our root route accessible only to those who are logged in, and add an exit option. First, edit the default root route in app/web.php to add an authentication middleware:

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

Let’s also configure the default greeting to show a bit of information about our logged in user, as well as provide a link to log out. Replace the contents of resources/views/welcome.blade.phpwith the following:

@extends('layouts.app', ['title' => 'Home'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      <h1>Logged in as {{ Auth::user()->name }}</h1>
      <a href="{{ route('logout') }}" class="text-indigo-600 inline-block underline mt-4">Logout</a>
    </div>
  </div>
@endsection

Finally, a logout functionality that will forget our session and return us to the login screen. Open routes/web.php again and add this route to the end of the file:

Route::get('logout', [AuthController::class, 'logout'])->name('logout');

Finally, we need to implement the logout action in our AuthController:

public function logout()
{
  Auth::logout();
  return redirect(route('login'));
}

Your home page should now look like this and be viewable only by those who are logged in:

Conclusion

That’s it! We have figured out how to implement the login functionality without a password. I hope you had fun.

Related Posts

LEAVE A COMMENT