NAME
Punk::Auth - the authentication battery
SYNOPSIS
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('/') };
DESCRIPTION
Authentication for Punk applications: a signed-in identity over the session, a memoized current_user, password hashing in C (Punk::Auth::Password), 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.
Needs the session keyword; to_app croaks without it.
THE KEYWORD
auth model => 'User',
fields => { id => 'id',
email => 'email',
password => 'password_hash',
verified => 'verified' },
session_key => 'user_id',
login_path => '/login',
iterations => 60_000,
roles => sub { my ($c, $user) = @_; ... },
rank => [qw(member admin owner)];
Everything shown is the default except model and roles, which have none. model names the Punk::Model class current_user loads through; the fields map renames columns, so an existing schema needs no migration. roles returns what the user holds - one role name, a list, or an arrayref; a user can hold several at once. rank orders the ladder names so auth_guard(role => 'admin') means "admin or better". A required role that is not in the ladder is orthogonal and matches by exact membership - which is how a platform-staff role lives alongside tenant roles without inventing a rung for it:
roles => sub {
my ($c, $user) = @_;
my @r = ($user->{role});
push @r, 'staff' if is_staff($user);
return @r;
};
under '/internal' => auth_guard(role => 'staff', on_denied => '404');
A 'Controller#method' string for roles resolves at to_app, so a typo croaks at boot. auth 0 turns the battery off. Unknown options croak - a misspelled auth option must not become a silently open door. It also reads an auth: block from config/punk.yml.
GUARDS
my $s = under '/account' => auth_guard;
under '/admin' => auth_guard(role => 'admin');
under '/staff' => auth_guard(role => 'admin', on_denied => '404');
auth_guard(verified => 1);
auth_guard returns an ordinary guard: a reference return short-circuits, anything else continues. Denial negotiates on the request's Accept - a client asking for text/html 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 - the staff-area convention) or a coderef receiving the context.
The ?to= Punk writes is a percent-encoded relative path, but the value your login form reads back is whatever the browser sent, and someone else may have written that link. Put it through $c->safe_path before redirecting to it, or a crafted ?to= turns your login into an open redirect:
post '/login' => sub {
my ($c) = @_;
...
$c->redirect($c->safe_path($c->param('to'), '/'));
};
A passing guard records what it knows in $c->stash->{auth} - user_id always, user and roles (an arrayref of everything the roles hook returned) when the guard loaded them - the same slot the Open::API security checkers use.
CONTEXT METHODS
login($user_or_id)
Record the signed-in user in the session (a row hashref uses its id field). This is the one primitive the federation half shares: a Punk::OAuth2 on_login body ends in $c->login($user).
logout
Empty the session and delete its cookie. Chains.
auth_id
The signed-in id straight off the session - no database. The shape oauth2_server's authenticate wants.
current_user
The user row, loaded once per request through the configured model and memoized; undef when nobody is signed in or the row is gone. Works on both model backends - a future-returning backend is awaited.
check_password($user, $password)
True when the password matches the user's stored hash. When there is no user or no hash it burns the same PBKDF2 work before returning false, so login response time cannot reveal which emails exist. Pair it with needs_rehash for opportunistic cost upgrades:
if ($c->check_password($user, $pw)) {
if (Punk::Auth::Password::needs_rehash($user->{password_hash})) {
$c->model('User')->update({ id => $user->{id},
password_hash => Punk::Auth::Password::hash($pw) });
}
$c->login($user);
}
issue_token($user_id, $kind, $ttl)
my $token = $c->issue_token($user->{id}, 'verify', 60 * 60 * 48);
# mail base_url . "/verify/$token" - the plaintext exists only here
A single-use token on the configured token_model. Only the SHA-256 digest is stored, with an absolute expiry epoch; issuing a new token invalidates the user's older tokens of the same kind, so a re-sent mail leaves exactly one live link.
take_token($token, @kinds)
my $row = $c->take_token($c->param('token'), 'reset', 'invite')
or return $c->text('that link has expired', 410);
The row, or nothing. The order inside is deliberate and inherited from production: the token is deleted first, then kind and expiry are checked - it is spent whether or not it turns out valid, so a wrong-kind probe burns it rather than leaving it live for its real endpoint.
THE SCHEMA
The defaults expect (spellings adjustable through fields):
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
password_hash text, -- null: invited or federated-only
verified integer NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX users_email ON users (lower(email));
A null password hash is meaningful: the account exists (an invite, or a federated sign-in) but no password has been set; check_password is simply false for it.
The token model (any name; declare it with auth token_model => ...):
CREATE TABLE auth_tokens (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
kind text NOT NULL, -- verify | reset | invite | yours
digest text NOT NULL,
expires bigint NOT NULL -- absolute epoch, compared in Perl
);
CREATE UNIQUE INDEX auth_tokens_digest ON auth_tokens (digest);
Single use is the delete plus the unique digest index; there is no used_at audit trail. Expired rows that were never taken are not reaped automatically - run
DELETE FROM auth_tokens WHERE expires < extract(epoch from now());
on whatever schedule suits (a Punk::Queue cron task is the natural home).
COMPOSING WITH OAUTH2
Local and federated sign-in meet at $c->login:
oauth2_login '/auth' => { on_login => sub {
my ($c, $identity, $tokens) = @_;
return $c->redirect('/login?error=oauth')
unless $identity->{email_verified} && $identity->{email};
my $user = find_or_create_by_email($c, $identity->{email});
$c->login($user);
return; # the plugin redirects
} };
And an oauth2_server authenticates with:
authenticate => sub {
my ($c) = @_;
return $c->auth_id // $c->redirect('/login?to=' . $c->req->path);
}
SEE ALSO
Punk, Punk::Auth::Password, Punk::Session, Punk::CSRF, Punk::RateLimit.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)