laravel 🚀
vue 🚀
Passwordless logins like Slack

This came from https://blog.logrocket.com/magic-login-links-with-laravel/ my version below uses VueJs and some other patterns.

This post will show how you can easily add a "Passwordless" login like Slack

We are going to do the same thing with Laravel and the SignedUrl feautre https://laravel.com/docs/9.x/urls#signed-urls

Step 1) Migration and Model

database/migrations/2022_11_19_141210_create_login_tokens_table.php

    public function up()
    {
        Schema::create('login_tokens', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(User::class);
            $table->string('token')->unique();
            $table->timestamp('consumed_at')->nullable();
            $table->timestamp('expires_at')->nullable();
        });
    }

We are going to make a model to save the LoginTakon request.

You can see the model test here:

class LoginTokenTest extends TestCase
{
    use RefreshDatabase;

    public function test_factory()
    {
        $model = LoginToken::factory()->create();
        $this->assertNotNull($model->token);
        $this->assertNotNull($model->expires_at);
        $this->assertNotNull($model->user->id);
    }
}

And the Factory

    public function definition()
    {
        return [
            'token' => Uuid::uuid4()->toString(),
            'expires_at' => now()->addMinute(30),
            'user_id' => User::factory(),
        ];
    }

Ok so now we have the model, migration and a test.

Step 2) Now the Controller and Route

The next step is the Controller to manage the saving of the request:

We will hand two requests. One is the User Interface where the user requests the singed url you can see that here

That button will make a request to this route:

Route::post(
    '/signed', [SignedUrlAuth::class, 'create']
)->name('signed_url.create');

That controller will look like this:

    public function create()
    {
        $validated = request()->validate(
            ['email' => 'required|email']
        );
        $user = User::whereEmail($validated['email'])->first();

        if (! $user) {
            return response()->json([], 200);
        }

        $loginToken = new LoginToken();
        $loginToken->token = Uuid::uuid4()->toString();
        $loginToken->user_id = $user->id;
        $loginToken->expires_at = now()->addMinutes(30);
        $loginToken->save();

        Mail::to($validated['email'])->queue(
            new MagicSignIn($loginToken)
        );

        return response()->json([], 200);
    }

And there is a test for that (a few really):

class SignedUrlAuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_ignores()
    {
        Mail::fake();
        $this->post('/api/signed', [
            'email' => 'foo@bar.com',
        ])->assertStatus(200);

        $this->assertDatabaseCount('login_tokens', 0);
        Mail::assertNothingQueued();
    }

    public function test_creats_token_and_returns_200()
    {
        Mail::fake();
        $user = User::factory()->create([
            'email' => 'foo@bar.com',
        ]);

        $this->post('/api/signed', [
            'email' => 'foo@bar.com',
        ])->assertStatus(200);

        $this->assertDatabaseCount('login_tokens', 1);
    }

    public function test_sends_email()
    {
        Mail::fake();
        $user = User::factory()->create([
            'email' => 'foo@bar.com',
        ]);

        $this->post('/api/signed', [
            'email' => 'foo@bar.com',
        ])->assertStatus(200);

        $this->assertDatabaseCount('login_tokens', 1);
        Mail::assertQueued(function (MagicSignIn $mail) use ($user) {
            $loginToken = LoginToken::where('user_id', $user->id)->first();

            return $mail->loginToken->id === $loginToken->id;
        });
    }

Here is the Inertia/Vue.js that makes the request:

resources/js/Pages/Auth/Login.vue

I alter the file that comes with Breeze

<script setup>
import Checkbox from '@/Components/Checkbox.vue';
import SimpleModal from '@/Components/SimpleModal.vue';
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
import { ref, onMounted } from 'vue'
import { useToast } from "vue-toastification";

defineProps({
    canResetPassword: Boolean,
    status: String,
});

const usePassword = ref(false);

const showMagicSentModal = ref(false);

const toast = useToast();

const form = useForm({
    email: '',
    password: '',
    remember: false
});

const useMagic = () => {
    usePassword.value = false;
}

const closeMagicModal = () => {
    showMagicSentModal.value = false;
}

const magic = () => {
    showMagicSentModal.value = true;
    axios.post(route('signed_url.create'), {
        email: form.email
    });
}

const passwordInstead = () => {
    usePassword.value = true;
}

const submit = () => {
    form.post(route('login'), {
        onFinish: () => form.reset('password'),
    });
};
</script>

<template>
    <GuestLayout>
        <Head title="Log in" />

        <div v-if="status" class="mb-4 font-medium text-sm text-green-600">
            {{ status }}
        </div>

        <form @submit.prevent="submit">
            <div>
                <InputLabel for="email" value="Email" />
                <TextInput id="email" type="email" class="mt-1 block w-full" v-model="form.email" required autofocus autocomplete="username" />
                <InputError class="mt-2" :message="form.errors.email" />
            </div>

            <TransitionRoot
                :show="usePassword"
                enter="transition-opacity duration-75"
                enter-from="opacity-0"
                enter-to="opacity-100"
                leave="transition-opacity duration-150"
                leave-from="opacity-100"
                leave-to="opacity-0"
            >
                <div class="mt-4" v-if="usePassword">
                    <InputLabel for="password" value="Password" />
                    <TextInput id="password" type="password" class="mt-1 block w-full" v-model="form.password" required autocomplete="current-password" />
                    <InputError class="mt-2" :message="form.errors.password" />


                    <button class="block w-full justify-center flex text-lg
                    mt-4
                    bg-black text-white py-4 rounded-lg font-bold text-2xl" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
                        Log in
                    </button>
                </div>
                <div class="mt-4" v-else>
                    <button
                        :disabled="!form.email"
                        type="button"
                            @click="magic"
                            class="
                            disabled:opacity-70
                            disabled:cursor-not-allowed
                            block w-full justify-center flex text-lg
                    bg-black text-white py-4 rounded-lg font-bold text-2xl">
                        Sign in With Email
                    </button>
                    <div class="bg-gray-200 p-4 rounded-lg mt-2 flex items-start ">

                        <div>
                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-2">
                                <path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
                            </svg>

                        </div>
                        <div>
                            We’ll email you a magic code for a password-free sign in. Or you can
                            <button type="button" class="underline" @click="passwordInstead">sign in with password</button>
                            instead.
                        </div>
                    </div>
                </div>
            </TransitionRoot>

            <div class="block mt-4">
                <label class="flex items-center">
                    <Checkbox name="remember" v-model:checked="form.remember" />
                    <span class="ml-2 text-sm text-gray-600">Remember me</span>
                </label>
            </div>

            <div class="flex items-center justify-center mt-4">
                <Link v-if="canResetPassword" :href="route('password.request')" class="underline text-sm text-gray-600 hover:text-gray-900">
                    Forgot password?
                </Link>
                <span class="ml-1 text-gray-400">|</span>
                <button v-if="usePassword"
                        type="button"
                        @click="useMagic"
                        class="ml-1 underline text-sm text-gray-600 hover:text-gray-900">
                    SignIn with Magic Email?
                </button>
                <button v-else
                        type="button"
                        @click="passwordInstead"
                        class="ml-1 underline text-sm text-gray-600 hover:text-gray-900">
                    Use password to login
                </button>
            </div>
        </form>

        <SimpleModal
            @closedModal="closeMagicModal"
            :show-modal="showMagicSentModal">
            <div class="text-lg text-gray-500 font-bold flex items-start">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-gray-600 mr-2">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M9 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859M12 3v8.25m0 0l-3-3m3 3l3-3" />
                </svg>
                Email sent with your password free link to login!
            </div>
        </SimpleModal>


    </GuestLayout>
</template>

And the modal:

resources/js/Components/SimpleModal.vue

<template>
    <TransitionRoot as="template" :show="open">
        <Dialog as="div" class="relative z-10" @close="open = false">
            <TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
                <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
            </TransitionChild>

            <div class="fixed inset-0 z-10 overflow-y-auto">
                <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
                    <TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200" leave-from="opacity-100 translate-y-0 sm:scale-100" leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
                        <DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
                            <slot></slot>

                            <div class="mt-5 sm:mt-6">
                                <button type="button" class="inline-flex w-full justify-center rounded-md border border-transparent bg-black px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-black focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:text-lg" @click="closeModal">Close</button>
                            </div>
                        </DialogPanel>
                    </TransitionChild>
                </div>
            </div>
        </Dialog>
    </TransitionRoot>
</template>

<script>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'

export default {
    name: "SimpleModal",
    components: {
        Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot
    },
    props: {
        showModal: {
            default: false,
            type: Boolean
        }
    },
    watch: {
      showModal(newValue) {
          this.open = newValue;
      }
    },
    methods: {
      closeModal() {
          this.open = false;
          this.$emit('closedModal')
      }
    },
    data() {
        return {
            open: this.showModal
        }
    }
}
</script>

<style scoped>

</style>

Step 3) The simple Mailer

So now we can see that route will deal with the request only if the email exists.

This is the mailer file:

app/Mail/MagicSignIn.php

class MagicSignIn extends Mailable
{
    use Queueable, SerializesModels;

    public LoginToken $loginToken;

    public string $url;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct(LoginToken $loginToken)
    {
        $this->loginToken = $loginToken;
        $this->url = $this->loginToken->signed_url;
    }

    /**
     * Get the message envelope.
     *
     * @return \Illuminate\Mail\Mailables\Envelope
     */
    public function envelope()
    {
        return new Envelope(
            subject: 'Your Easy Login for '.config_path('app.name'),
        );
    }

    /**
     * Get the message content definition.
     *
     * @return \Illuminate\Mail\Mailables\Content
     */
    public function content()
    {
        return new Content(
            markdown: 'mail.magic-sign-in',
        );
    }

    /**
     * Get the attachments for the message.
     *
     * @return array
     */
    public function attachments()
    {
        return [];
    }
}

Pretty simple with the email template being just this:

<x-mail::message>
# Your Magic SignIn Link

Just click the link below to sign in, if you did not request this email just ignore it.


<x-mail::button :url="$url">
Login
</x-mail::button>

Thanks from,<br>
{{ config('app.name') }}
</x-mail::message>

Step 4) Back to the Controller and Route to receive the users request when they click on the link in the email

Ok so the controller makes the LoginToken, and then sends the email is the user eixsts otherwise it just acts like it did.

When the user clicks the email they end up here:

routes/web.php

Route::get('/login/signed/{token}', [SignedUrlAuth::class, 'signInWithToken'])->name('signed_url.login');

and the Controller

    public function signInWithToken(Request $request)
    {
        if (! $request->hasValidSignature()) {
            abort(401);
        }

        try {
            $loginToken = LoginToken::query()
                ->whereToken($request->token)
                ->whereNull('consumed_at')
                ->firstOrFail();

            $loginToken->consumed_at = now();
            $loginToken->save();

            /** @phpstan-ignore-next-line */
            Auth::login($loginToken->user);

            return redirect()->route('trips.my_trips');
        } catch (ModelNotFoundException $e) {
            logger('Attempt at signed url');
            logger($e->getMessage());
            abort(401);
        }
    }

Ok now you have the Routes, Controller, VueJS, Mailing Template to start making emails that have signed urls so users can quickly log into the site without a password!

The user will get the eamil, just like if it was a reset password request, but this one will log them in based on the created UUID token.