Passwordless frontend login – cookbook recipe?

What I’m trying to achieve is a simple frontend form to provide access to a restricted client’s area like described here:
Restricting access to your site

But instead of using default email/password login, the new passwordless authentification should be triggered.

I know the docs already cover the required techniques …
Available methods
Login methods
… but a recipe with all the individual steps to make it work on frontend would be just great. I think the new authentication method is particularly well suited for such a purpose, so other people might find it useful, too.

1 Like

Have you seen the documentation page on Frontend login? That covers most of the information you need to build such a form. But I agree that a recipe about this would be great!

PS: It might make sense to wait until Kirby 3.5.1 for this as this version will make it much simpler to build frontend passwordless login forms. You can find the docs on that here already.

Yes, thank you, I missed to include the link. The article is very helpful in understanding and applying it. However, it would be great to have a practical guide that brings the parts together.

Thanks for pointing out version 3.5.1, that sounds promising. The pace and quality of Kirby’s development is really impressive. Hats off!

1 Like

Understood, I agree! I will see if I can write a recipe about this or add a section to the existing recipe when I find the time.

Wow, great – thank you! :slightly_smiling_face:

I am very interested in an example too

Well i did it. Not a receipe but a working case.

Some precisions

Don’t forget settings

Email transport settings are required in site/config/config.php in order to send the login code.

Behavior

There is two mode:

  • password
  • passwordless

Default is password.

I have a custom attribute data-mode on <form> which reflect that mode for design purpose. I hide or show inputs depending on mode.

Give new users a link to passwordless mode

I have a trigger which send a welcome mail with link to new users for login purpose. I add a #passwordless hash to url. That way they come to the form with passwordless mode enabled. Later they can add a password.

Notifications

I have a .error area where i notice user errors.
I have a .info area where i notice an email was send.

Final automatic redirection

I have an automatic redirection. Users with panel access are redirected to panel. Other are redirected to the page they come from. I use both url param and hidden field for that purpose. The url param is added to <a> targetting the login page in menu (an other script). Then i use the hidden field cause the login page can be call multiple times (user errors, of passwordless mode).

Code

Controller:

<?php

use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;

return function ($kirby) {

    $error = false;
    $errorText = "";
    $info = "";
    $redirection = get('redirection') ?? get('location');
    $mode = get('mode');
    $codeSent = false;

    function openNextPage($user, $redirection)
    {
        if ($user->role()->permissions()->for('access', 'panel')) {
            go($user->site()->panelUrl());
        } else if ($redirection) {
            go($redirection);
        } else {
            go('/');
        }
    }
    // don't show the login screen to already logged in users
    if ($user = $kirby->user()) {
        openNextPage($user, $redirection);
    }

    // handle the form submission
    if ($kirby->request()->is('POST') && get('email')) {
        if (get('mode') === "passwordless") {
            if ($verification_code = get('verification_code')) {
                $codeSent = true;
                try {
                    $user = $kirby->auth()->verifyChallenge($verification_code);
                    openNextPage($user, $redirection);
                } catch (LogicException $exception) {
                    $error = true;
                    $errorText = "Le challenge d'authentification est invalid.";
                } catch (NotFoundException $exception) {
                    $error = true;
                    $errorText = "L'utilisateur n'existe pas.";
                } catch (PermissionException $exception) {
                    $error = true;
                    $errorText = "Le code est erroné ou périmé.";
                } catch (InvalidArgumentException $exception) {
                    $error = true;
                    $errorText = "Aucun challenge d'authentification n'est actif.";
                }
            } else {
                try {
                    $status = $kirby->auth()->createChallenge(get('email'), false, 'login');
                    $codeSent = true;
                    $info = "Un code d'identification vient de vous être envoyé par email.";
                } catch (LogicException $exception) {
                    $error = true;
                    $errorText = "Il n'y a pas de challenge d'authentification approprié.";
                } catch (NotFoundException $exception) {
                    $error = true;
                    $errorText = "L'utilisateur n'existe pas.";
                } catch (PermissionException $exception) {
                    $error = true;
                    $errorText = "Vous avez dépassé le nombre de tentatives autorisé.";
                }
            }
        } else if (get('mode') === "password") {
            try {
                $user = $kirby->auth()->login(get('email'), get('password'), false);
                openNextPage($user, $redirection);
            } catch (NotFoundException $exception) {
                $error = true;
                $errorText = "L'utilisateur n'existe pas.";
            } catch (PermissionException $exception) {
                $error = true;
                $errorText = "Vous avez dépassé le nombre de tentatives autorisé.";
            } catch (InvalidArgumentException $exception) {
                $error = true;
                $errorText = "Mot de passe erroné.";
            }
        }
    } else {
        $error = false;
    }

    return compact('error', 'errorText', 'mode', 'codeSent', 'info', 'redirection');
};

Template:

<?php snippet('header') ?>

<div id="login">

  <form method="post" class="card" data-mode="<?= $mode ?>" action="<?= $page->url() ?>">
    <button type="button" class="mode_button password_mode" onclick='setMode("passwordless")'>Se connecter sans mot de passe</button>
    <button type="button" class="mode_button passwordless_mode" onclick='setMode("password")'>Se connecter avec un mot de passe</button>

    <?php if ($error) : ?>
      <div class="error"><?= $errorText ?? $page->alert()->html() ?></div>
    <?php endif ?>
    <?php if ($info) : ?>
      <div class="info"><?= $info ?></div>
    <?php endif ?>

    <div>
      <label for="email"><?= $page->login()->html() ?></label>
      <input type="email" id="email" name="email" placeholder="nom@exemple.com" value="<?= esc(get('email')) ?>">
    </div>
    <div class="password_mode">
      <label for="password"><?= $page->password()->html() ?></label>
      <input type="password" id="password" name="password" placeholder="************" value="<?= esc(get('password')) ?>">
    </div>
    <?php if ($codeSent) : ?>
      <div class="passwordless_mode">
        <label for="verification_code"><?= $page->verification_code()->html() ?></label>
        <input type="text" id="verification_code" name="verification_code" placeholder="123 456" value="<?= esc(get('verification_code')) ?>">
      </div>
    <?php endif ?>
    <input type="hidden" id="redirection" name="redirection" value="<?= esc($redirection) ?>" />
    <input type="hidden" id="mode" name="mode" value="<?= esc(get('mode')) ?>" />
    <div class="password_mode">
      <input type="submit" name="submit_login" value="<?= $page->login_button()->html() ?>">
    </div>
    <div class="passwordless_mode">
      <?php if ($codeSent) : ?>
        <input type="submit" name="submit_passwordless" value="<?= $page->login_passwordless_button()->html() ?>">
      <?php else : ?>
        <input type="submit" name="submit_passwordless" value="<?= $page->get_code_button()->html() ?>">
      <?php endif ?>

    </div>
  </form>
  <script>
    if (window.location.hash === "#passwordless" || document.querySelector("#mode").value === "passwordless") {
      setMode("passwordless");
    } else {
      setMode("password");
    }

    function setMode(mode) {
      window.location.hash = '#' + mode;
      document.querySelector("#login form").dataset.mode = mode;
      document.querySelector("#login #mode").value = mode;
    }

    document.querySelectorAll("form [type=submit]").forEach(button => {
      button.addEventListener("click", event => {
        event.currentTarget.toggleAttribute("disabled");
        event.currentTarget.value = "Veuillez patienter...";
      })
    })
  </script>
</div>

<?php snippet('footer') ?>

CSS:

#login [data-mode="password"] .passwordless_mode,
#login :not([data-mode]) .passwordless_mode {
  display: none;
}
#login [data-mode="passwordless"] .password_mode {
  display: none;
}
[type="submit"]:disabled {
  background-color: grey !important;
  pointer-events: none;
}

If you like it … use it

2 Likes

Thank you, @Anthony1, part of your code is very useful for my project, too!

During adaptation, I discovered a little typo in your controller.

Line 38:

$user = $kirby->auth->verifyChallenge($verification_code);

The brackets for the auth()-method are missing. It should be:

$user = $kirby->auth()->verifyChallenge($verification_code);

Then, in line 88 the return statement closes with two semicolons. This doesn’t really break things, but I guess it should still better be only one.

Thanks again for sharing!

The brackets for the auth()-method are missing.

You are right. Thanks.

Sadly i can not edit anymore my previous message.

I’ve fixed it for you. :slight_smile: