Authentication
Punk::Auth is the
authentication battery: a signed-in identity over the session, a
memoized current_user, password hashing in C, single-use email
tokens, and guards for under that replace the per-action
boilerplate. The common guard case - is anyone signed in - runs
entirely in C: one session load, one hash fetch.
package MyApp;
use Punk;
session secret => secret('session_key');
auth model => 'User',
roles => sub { my ($c, $user) = @_; $user->{role} };
my $account = under '/account' => auth_guard;
my $admin = under '/admin' => auth_guard(role => 'admin');
post '/login' => sub {
my ($c) = @_;
my $user = $c->model('User')->get(email => $c->param('email'));
return $c->redirect('/login')
unless $c->check_password($user, $c->param('password'));
$c->login($user);
return $c->redirect('/account');
};
post '/logout' => sub { $_[0]->logout->redirect('/') };
It needs the session keyword; to_app croaks without it. model
names the Punk::Model class current_user loads
through, and a fields map renames columns so an existing schema
needs no migration. Unknown options croak - a misspelled auth option
must not become a silently open door.
Guards
auth_guard returns an ordinary guard. Denial negotiates on the
request's Accept: a browser is redirected to login_path with
?to=<path> so a login can return the user where they were headed;
anything else gets a 401 in the house error shape. on_denied
overrides with '403', '404' (for pages whose existence is nobody's
business) or a coderef.
roles returns what the user holds - one name, a list, or an
arrayref - and rank orders a ladder so role => 'admin' means
"admin or better"; a role not in the ladder matches by exact
membership instead. A passing guard records user_id, user and
roles in $c->stash->{auth} - the same slot the OpenAPI security
checkers use.
Context methods
$c->login($user_or_id)/$c->logout- record or clear the signed-in user in the session.$c->auth_id- the id straight off the session, no database.$c->current_user- the row, loaded once per request and memoized; works on both model backends (a future is awaited).$c->check_password($user, $password)- PBKDF2 verification in C (Punk::Auth::Password); when there is no user or no hash it burns the same work before returning false, so login timing cannot reveal which emails exist.$c->issue_token($user_id, $kind, $ttl)/$c->take_token($token, @kinds)- single-use tokens for verify, reset and invite mails. Only the SHA-256 digest is stored, and a token is spent on take whether or not it turns out valid. Sending one is one line with Punk::Plugin::Mailer's$c->mail_token.
Local and federated sign-in meet at $c->login: an
OAuth2 on_login body ends in the same call.
A second factor
Punk::Plugin::TOTP
adds RFC 6238 TOTP on top of auth - secret generation, the enrolment
QR, and verification:
plugin 'TOTP' => { issuer => 'example.com' };
# enrolment: show the QR, confirm a code before enabling
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 after password login
post '/login/totp' => sub {
my ($c) = @_;
return $c->render('auth/totp', { error => 'Try again.' })
unless $c->totp_verify($user, $c->param('code'));
...
};
$c->totp_verify is replay-safe by construction: on success it writes
the matched counter back through the auth model, so a code that was
just used cannot be used again - nobody writes that comparison by hand.
A fields map names your own columns, skew tolerates clock drift,
and the engine underneath is
Punk::TOTP, usable without the
plugin. The HMAC comes from File::Raw::Hash at runtime - no libcrypto,
nothing beyond a C compiler.
Brute force on a six-digit code is a rate problem, and
rate limiting is the answer:
rate_limit for => '/login/totp', limit => 5, window => 60.