NAME
Punk::Plugin::TOTP - a second factor for Punk applications
SYNOPSIS
use Punk;
session secret => secret('session_key');
auth model => 'User';
plugin 'TOTP' => { issuer => 'openapi-proxy.com' };
# enrolment
post '/account/2fa' => sub {
my ($c) = @_;
my $secret = $c->totp_secret;
$c->session->{totp_enrolling} = $secret;
return $c->render('account/2fa',
{ qr => $c->totp_qr($secret) });
};
# the challenge
post '/login/totp' => sub {
my ($c) = @_;
my $user = ...;
return $c->render('auth/totp', { error => 'Try again.' })
unless $c->totp_verify($user, $c->param('code'));
...
};
DESCRIPTION
Wires Punk::TOTP into a Punk application: secret generation, the enrolment QR, and verification with the counter write-back that makes replay protection hold. The engine is usable without this plugin; the plugin exists so every application does not re-derive the wiring that is easy to get wrong.
CONFIG
plugin 'TOTP' => {
issuer => 'openapi-proxy.com', # required
algorithm => 'sha1', # sha1 | sha256 | sha512
digits => 6,
period => 30,
skew => 1,
model => 'User',
render => sub { ... }, # render the challenge form can also be a context method name
fields => {
secret => 'totp_secret',
counter => 'totp_last_counter',
enabled => 'totp_enabled',
email => 'email',
},
};
fields maps the plugin onto the application's own column names. Unknown options croak at boot.
HELPERS
$c->totp_secret
A fresh secret, base32, sized for the configured algorithm.
$c->totp_uri($secret, %opt)
The otpauth:// URI, issuer from config, account from %opt or the signed-in user's email field.
$c->totp_qr($secret, %opt)
The URI as an SVG through QR::Code, at ECC level H - an enrolment QR is scanned once, from a screen, by every phone the user will ever own, so it gets maximum margin and no logo unless %opt asks. %opt otherwise passes through to QR::Code->svg. An application that renders the QR some other way builds the otpauth:// URI with totp_uri instead.
$c->totp_verify($user, $code)
Verifies against the user's stored secret and, on success, writes the matched counter back through the configured model before returning true - the write is the replay protection. An account with no secret burns the same verification work and returns false, so response time does not report who has 2FA enabled.
$c->totp_recovery_codes($user, %opt)
my $codes = $c->totp_recovery_codes($user); # ten
my $codes = $c->totp_recovery_codes($user, count => 5);
A fresh set of single-use recovery codes, returned in plaintext exactly once - grouped base32 in an alphabet with no confusable digits. Issuing a new set revokes the old one entirely: partial invalidation is how a drawer ends up holding codes that half-work. Rows go through recovery_model (default Token) in the token table's shape - user_id, kind totp_recovery, a lowercase SHA-256 hex digest, expires 0 for never - so they share a production tokens table untouched.
This is deliberately not $c->issue_token: that keyword deletes older same-kind tokens first (ten codes would collapse to one) and refuses a non-positive ttl (a code in a drawer must not expire). Both behaviours are right for mailed links; recovery codes get their own path rather than weakening them.
$c->totp_use_recovery($user, $code)
Spends a code: true once, false forever after. Case and grouping fold before the digest, so what the paper shows is what works. The row is deleted BEFORE the code is judged - a probe with the wrong user or the wrong kind burns the row it hit rather than leaving it live for its real endpoint, the same discipline take_token documents.
THE AUTH SEAM
A half-authenticated session writes totp_pending into the session and not the auth session key. That is the whole mechanism: Punk's auth battery reads exactly one key, so a session that has passed a password but not a code has no identity as far as every existing auth_guard is concerned. Nothing was taught about 2FA; nobody is signed in yet; there is no new enforcement path to get wrong.
$c->totp_challenge($user, %opt)
Writes the pending marker (user id, an expiry pending_ttl seconds out, the destination from to =>) and returns the redirect to the challenge route. Call it from the sign-in chokepoint - the place every authentication path funnels through - INSTEAD of $c->login when the user has the factor enabled. Gating only the password form leaves the mailed-link sign-ins (verify, reset, invite) as a bypass.
$c->totp_complete
Deletes the marker, signs the user in, records totp_at in the session, calls session_rotate where Punk provides it (0.26+; with a session store that closes a real fixation window), and returns the stored destination. The challenge route calls this for you.
The challenge route
Mounted at challenge_path (default /login/totp). GET renders - a self-contained form by default, or whatever render => names (a coderef or context method, called with the context and an error flag). POST verifies the submitted code as a TOTP code or a recovery code, promotes on success, and clears the pending state entirely after attempts failures (default 5): a limit that only delays is a limit a patient script waits out; this one un-answers the first factor. A per-address rate_limit (30/minute) sits on top.
An application with the csrf keyword enabled must pass its own render that includes the token, or exempt the path; the default form carries none.
totp_guard
my $admin = under '/admin' => totp_guard();
$admin->get('/x' => ...);
The step-up guard, installed as a keyword at registration (so it is called with parens). It passes when this session recorded totp_at - that is, when THIS login satisfied the factor - and otherwise writes a pending marker for the signed-in user and redirects to the challenge (or to login_path when nobody is signed in). It sits inside or after auth_guard, which establishes identity; totp_guard asks a different question.
Note the test-kit gap, deliberately: Punk::Test::login_as seals the auth key directly and sails past the factor - existing suites keep passing, which is correct and worth knowing. totp_guard is not fooled, because totp_at is absent; a green suite is only evidence of enforcement where the step-up guard stands.
STORAGE
Three columns on the user row, named by fields: the secret (text, and it cannot be hashed - whoever reads it can mint codes), the last accepted counter (the replay floor; one write per successful verification), and the enabled flag the application flips only after a first successful verification - enrolment is not complete until one code proves the phone has the secret.
AUTHOR
LNATION, <email at lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)