Passwordless frontend login – cookbook recipe?

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