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.