PHV demo

Two-factor codes

PHV has TOTP (the six-digit codes of authenticator apps) and QR codes built in: totp_secret(), qr_svg() and totp_verify(), which tolerates a little clock drift and tells you which time step matched so the same code cannot be accepted twice.

Scan this with Google Authenticator, 1Password, Authy or similar, then enter the code it shows.

Or type the secret: MUWWQGH3MSQJFB5GWBVBAYCLHT3TSN5S

No authenticator app at hand?

The current code for this secret is 921400 (from totp_code($secret)). A real site would never show it.

The secret lives only in your server-side session and is gone when it expires. The QR code is drawn as SVG on the server; its markup contains only the modules, not the text.

Source of this page

Read from disk by lib/source.phv as the page is served.

public/totp.phv
<?phv
import std.http.{request, response, session};
import lib.client.client_ip;
import lib.source.sources;

$req = request();
$s = session();

if ($req->isPost() && $req->form("action") == "reset") {
    $s->remove("totp_secret");
    $s->remove("totp_step");
    response()->redirect("/totp");
    return;
}

// A secret per visitor, kept in the server-side session.
$secret = $s->get("totp_secret") ?? "";
if ($secret->isEmpty()) {
    $secret = totp_secret();
    $s->set("totp_secret", $secret);
}

?string $result = null;
$ok = false;
if ($req->isPost()) {
    $code = ($req->form("code") ?? "")->replace(" ", "");
    if (!rate_limit("totp:" . client_ip(), 10, 60)) {
        $result = "Too many attempts; please wait a minute.";
    } else {
        $step = totp_verify($secret, $code);   // ?int: the time step it matched
        $last = ($s->get("totp_step") ?? "")->toInt() ?? 0;
        if ($step == null) {
            $result = "That code is not valid for this secret.";
        } elseif ($step <= $last) {
            $result = "That code was already used. Wait for the next one.";
        } else {
            $s->set("totp_step", (string)$step);   // so the same code cannot be used twice
            $result = "Correct! The code matched time step {$step}.";
            $ok = true;
        }
    }
}

$uri = "otpauth://totp/" . rawurlencode("PHV demo:visitor")
    . "?" . http_build_query(["secret" => $secret, "issuer" => "PHV demo"]);

$body = render(
    "views/totp.phv.html",
    secret: $secret,
    qr: qr_svg($uri),
    current: totp_code($secret),
    result: $result,
    ok: $ok,
);
echo render(
    "views/layout.phv.html",
    title: "Two-factor codes",
    path: "/totp",
    body: $body,
    files: sources(["public/totp.phv", "views/totp.phv.html"]),
);
views/totp.phv.html
<?phv params(string $secret, Html $qr, string $current, ?string $result, bool $ok) ?>
<h1>Two-factor codes</h1>
<p class="lead">PHV has TOTP (the six-digit codes of authenticator apps) and QR codes built in:
<code>totp_secret()</code>, <code>qr_svg()</code> and <code>totp_verify()</code>, which tolerates a little clock
drift and tells you which time step matched so the same code cannot be accepted twice.</p>

<div class="totp">
  <div class="qr"><?= $qr ?></div>
  <div>
    <p>Scan this with Google Authenticator, 1Password, Authy or similar, then enter the code it shows.</p>
    <p class="muted small">Or type the secret: <code class="secret"><?= $secret ?></code></p>

<?phv if ($result != null): ?>
    <div class="notice <?= $ok ? "ok" : "error" ?>"><?= $result ?></div>
<?phv endif; ?>

    <form method="post" action="/totp" class="inline">
      <?= csrf_field() ?>
      <label for="code">Code</label>
      <input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="7" size="8" required>
      <button>Verify</button>
    </form>

    <details>
      <summary>No authenticator app at hand?</summary>
      <p>The current code for this secret is <code><?= $current ?></code> (from <code>totp_code($secret)</code>).
      A real site would never show it.</p>
    </details>

    <form method="post" action="/totp">
      <?= csrf_field() ?>
      <input type="hidden" name="action" value="reset">
      <button class="secondary">New secret</button>
    </form>
  </div>
</div>
<p class="muted small">The secret lives only in your server-side session and is gone when it expires. The QR code
is drawn as SVG on the server; its markup contains only the modules, not the text.</p>