">
 

Punk: Fully Authed

Iniciado por joomlamz, Hoje at 22:25

Respostas: 1   |   Visualizações: 1

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

Saudações, comunidade do **webmastersmz.com**!

Como especialista em tecnologia, analisei o tópico em inglês **[Punk: Fully Authed]**. Este tipo de discussão é de extrema relevância para administradores de sistemas, programadores e entusiastas de cibersegurança que acompanham a evolução de protocolos de autenticação e a mitigação de vulnerabilidades em ambientes web.

### Análise Técnica dos Pontos Principais:

1. **Arquitetura de Autenticação Robusta:** O termo "Fully Authed" no contexto de projetos como o *Punk* sugere a implementação de um mecanismo de autenticação de ponta a ponta, possivelmente utilizando tokens JWT (JSON Web Tokens), OAuth 2.0 ou até mesmo metodologias *passwordless*. A preocupação central é garantir que a identidade do utilizador seja validada de forma criptograficamente segura antes de qualquer acesso a recursos sensíveis.
2. **Gestão de Sessões e Mitigação de Riscos:** Um dos pontos fortes discutidos em tópicos desta natureza é a prevenção contra ataques comuns, como *Session Hijacking* e *Cross-Site Scripting (XSS)*. A correta configuração de *cookies* (com as flags `HttpOnly`, `Secure` e `SameSite`) é vital para manter o estado de autenticação sem expor o cliente a vectores de ataque.
3. **Desempenho vs. Segurança:** Implementar camadas adicionais de validação e criptografia pode introduzir latência. A discussão técnica aborda como otimizar estas verificações de segurança no *backend* sem comprometer o tempo de resposta (*response time*) da aplicação, algo crucial para a experiência do utilizador.

Gostaria de saber a vossa opinião: como é que têm lidado com a implementação de autenticação avançada nos vossos projetos atuais? Já adotaram arquiteturas sem palavra-passe (*passwordless*) ou continuam a confiar nos métodos tradicionais? **Deixem os vossos comentários abaixo e vamos debater!**

***

Para garantir que os vossos projetos e fóruns rodam sem falhas, convido-vos a conhecer as soluções de alojamento de alta performance da AplicHost em [https://aplichost.com](https://aplichost.com).

Punk: Fully Authed



Tópico: Punk: Fully Authed
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------
Authentication is where a web framework's promises get tested, because every entry point of it is a door. A password form, a verification link in a mail, a reset link, a "continue with GitHub" button and a six-digit code from a phone all end in the same sentence - "this session belongs to that user".

This guide builds one application with all of those doors on it, from punk new to a passing test suite, using four pieces of the Punk ecosystem: Punk::Auth for the identity and the single-use tokens, Punk::Plugin::Mailer to send the tokens through Resend, Punk::Plugin::TOTP for the second factor, and Punk::Plugin::OAuth2 for GitHub and Google.



What you are building


Twenty routes, and this is all of them:

METHOD  PATH                      TARGET
GET     /login/totp               native handler
POST    /login/totp               native handler
GET     /auth/:provider           sub {...} at OAuth2.pm:56
GET     /auth/:provider/callback  sub {...} at OAuth2.pm:56
GET     /                         AuthApp::Controller::Web::Root::index
GET     /register                 AuthApp::Controller::Web::Auth::register_form
POST    /register                 AuthApp::Controller::Web::Auth::register
GET     /verify/:token            AuthApp::Controller::Web::Auth::verify
GET     /login                    AuthApp::Controller::Web::Auth::login_form
POST    /login                    AuthApp::Controller::Web::Auth::login
POST    /logout                   AuthApp::Controller::Web::Auth::logout
GET     /forgot                   AuthApp::Controller::Web::Auth::forgot_form
POST    /forgot                   AuthApp::Controller::Web::Auth::forgot
GET     /reset/:token             AuthApp::Controller::Web::Auth::reset_form
POST    /reset/:token             AuthApp::Controller::Web::Auth::reset
GET     /account                  AuthApp::Controller::Web::Account::home  [1 guard]
POST    /account/totp             AuthApp::Controller::Web::Account::enrol  [1 guard]
POST    /account/recovery         AuthApp::Controller::Web::Account::recovery  [1 guard]
GET     /vault/secrets            AuthApp::Controller::Web::Vault::secrets  [1 guard]
ANY     /static/*                 static root/static

That is punk routes on the finished app. The first four are not yours: the TOTP plugin mounts the challenge, the OAuth2 plugin mounts the provider hop and its callback. The rest is the application, and it is small: sign up and prove the address, sign in, forget and reset a password, enrol a phone, and one page - the vault - that opens only for a session holding both a signed-in user and a second factor passed since sign-in.



punk new AuthApp


cpanm Punk Punk::TOTP Punk::OAuth2 Punk::Mailer DBD::SQLite
punk new AuthApp
cd AuthApp

punk new writes a running application: an app.psgi that changes to its own directory before loading the class, a config/punk.yml with the views and static mount set and everything else commented, a two-line application class, one controller, a layout and a welcome template, a stylesheet, a test and a README. The class is the whole routing table:

package AuthApp;
use strict; use warnings; use Punk;
our $VERSION = '0.01';
config 'config/punk.yml';
get '/' => 'Web::Root#index';
1;

punk dev boots it on port 5000 and restarts on change. Two more generator calls give us the models and controllers we are about to fill in - the files they write are skeletons, and every one of them is replaced below:

punk generate model User
punk generate model AuthToken --table auth_tokens
punk generate controller Auth
punk generate controller Account
punk generate controller Vault

Everything Punk does, it does at to_app: routes are resolved, guard chains flattened, 'Web::Auth#login' turned into a coderef, the configuration read and every keyword checked. The consequence for the rest of this guide is that wiring mistakes are boot errors. A typo in a plugin option, a template a mail names that does not exist, a missing environment variable - each stops the process from starting, which is the cheapest moment to find out.

The app.psgi the generator wrote is kept. The first BEGIN is the generator's: it changes to the application root so the relative paths in the configuration resolve wherever the server was started from. lib/AuthApp.pm is loaded last, after both.

app.psgi:

#!/usr/bin/env perl

use strict;
use warnings;
use FindBin ();
use lib "$FindBin::Bin/lib";

BEGIN {
chdir $FindBin::Bin or die "cannot chdir to $FindBin::Bin: $!\n";
}

use AuthApp;

AuthApp->to_app;



Secrets before sessions


Everything here rides on the session, and a session needs a secret. Mint one next:

export AUTHAPP_SESSION_KEY=$(punk secret)

and reference it from the configuration rather than writing it there. This is the finished config/punk.yml - the oauth and plugins blocks at the bottom belong to sections further down, and are explained there:

config/punk.yml:

# AuthApp configuration. Safe to commit: secrets are referenced here,
# never written here.
#
# Layered - this file, then punk.$PUNK_ENV.yml, then punk.local.yml, each
# merged over the last. Put deployment differences in the environment file
# and machine-local ones in punk.local.yml, which is gitignored.

views:
Stencil:
template_dir: root/templates
wrapper: layout.tmpl

static:
/static: root/static

# The application's canonical origin, declared once. The OAuth2 redirect
# URIs and every link in an outbound mail derive from it. Never taken from
# a request's Host header, which is attacker-supplied.
host: http://localhost:5000

# The database. SQLite, in a file under var/ that lib/AuthApp/Schema.pm
# creates at boot - Punk ships no migrations, so the schema lives with the
# application.
database:
dsn: dbi:SQLite:dbname=var/authapp.db

# Signed cookie sessions, and single-use CSRF tokens over them. The secret
# belongs outside this file, like any other secret: `punk secret` mints
# one, and a missing AUTHAPP_SESSION_KEY is a boot error, not a default.
# The auth battery is declared in lib/AuthApp.pm, right after this file
# is applied.
session:
secret:   { $env: AUTHAPP_SESSION_KEY }
expires:  7d
samesite: Lax

csrf: true

# OAuth2 registrations, read with secret('oauth.github_id') and so on. The
# callback URLs registered with each provider are <host>/auth/github/callback
# and <host>/auth/google/callback.
oauth:
github_id:     { $env: AUTHAPP_GITHUB_ID }
github_secret: { $env: AUTHAPP_GITHUB_SECRET }
google_id:     { $env: AUTHAPP_GOOGLE_ID }
google_secret: { $env: AUTHAPP_GOOGLE_SECRET }

plugins:
TOTP:
issuer:         AuthApp          # names the account in the authenticator app
login_path:     /login           # an unauthenticated step-up goes here
render:         totp_page        # the challenge page, as a helper of ours
recovery_model: AuthToken        # recovery codes share the token table

{ $env: NAME } is resolved at boot from outside the file; $app->config shows [redacted] in its place, so the configuration can be logged. There is no default syntax, and that is the point: an application that boots with an empty session key because nobody set the variable is an application whose sessions anyone can forge, so it does not boot.

csrf: true is the bare form of the keyword, the same as writing csrf; in the class; a mapping there carries its options (keep, exempt, the field and header names). One keyword does go in the class rather than the file, immediately after the file is applied, because it reads better beside the routes that use it: lib/AuthApp.pm

config 'config/punk.yml';

# The authentication battery: who the signed-in user is (User), where
# single-use tokens live (AuthToken - verification and reset links, and the
# TOTP plugin's recovery codes), and where a guard sends a stranger.
auth model       => 'User',
token_model => 'AuthToken',
login_path  => '/login';

With csrf on, every POST, PUT, PATCH and DELETE must carry a live token and using one spends it. The token is not injected into your templates - copying a hashref per render to add one key would tax every page for the sake of the ones with forms - so you pass $c->csrf_field to the view and print it with {% raw csrf %}. A helper further down does that once for every page. One consequence worth knowing before it surprises you: the default keeps one live token per session, so the same form open in two tabs submits once. csrf keep => 3 relaxes that.



A database and two models


Punk ships no migrations. The shipped model backend is plain DBI, and the schema is yours to create - here, at boot, so the application runs from a fresh checkout:

lib/AuthApp/Schema.pm:

package AuthApp::Schema;

use strict;
use warnings;
use DBI ();
use File::Basename ();

# The two tables the application needs, created on demand so it runs from a
# fresh checkout with nothing to set up first. Punk ships no migrations; a
# real application would keep them somewhere less casual than this.
#
# `users` is Punk::Auth's schema plus the three columns Punk::Plugin::TOTP
# reads and writes. `auth_tokens` is shared: verification and reset links
# (kinds `verify` and `reset`) and TOTP recovery codes (kind `totp_recovery`)
# are all rows in it, told apart by `kind`.

my @DDL = (
<<'SQL',
CREATE TABLE IF NOT EXISTS users (
id                INTEGER PRIMARY KEY AUTOINCREMENT,
email             TEXT    NOT NULL,
password_hash     TEXT,                          -- null: federated-only
verified          INTEGER NOT NULL DEFAULT 0,
totp_secret       TEXT,
totp_last_counter INTEGER,
totp_enabled      INTEGER NOT NULL DEFAULT 0,
created           TEXT    NOT NULL
)
SQL
'CREATE UNIQUE INDEX IF NOT EXISTS users_email ON users (lower(email))',
<<'SQL',
CREATE TABLE IF NOT EXISTS auth_tokens (
id      INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
kind    TEXT    NOT NULL,
digest  TEXT    NOT NULL,
expires INTEGER NOT NULL
)
SQL
'CREATE UNIQUE INDEX IF NOT EXISTS auth_tokens_digest ON auth_tokens (digest)',
);

sub ensure {
my ($class, $dsn) = @_;
die "AuthApp::Schema: no database dsn configured\n" unless $dsn;

# dbi:SQLite:dbname=var/authapp.db - make sure var/ exists
if ($dsn =~ /dbname=([^;]+)/) {
my $dir = File::Basename::dirname($1);
mkdir $dir unless -d $dir;
}

my $dbh = DBI->connect($dsn, undef, undef,
{ RaiseError => 1, AutoCommit => 1, PrintError => 0 });
$dbh->do($_) for @DDL;
$dbh->disconnect;
return $dsn;
}

1;

password_hash is nullable on purpose. A user who arrives through GitHub has no password, and Punk::Auth's check_password is simply false for a null hash - it is a meaningful state, not a missing value. The token table holds only SHA-256 digests; the plaintext of a token exists in the link that was mailed and nowhere else.

The users row is Punk::Auth's four columns plus three the TOTP plugin owns, and the model declares every one of them, because the DBI backend writes only declared fields:

lib/AuthApp/Model/User.pm:

package AuthApp::Model::User;

use Punk::Model;

table 'users';

# Every column is declared, because the DBI backend writes only declared
# fields: a column left out here is a column that can never be written.
# Punk::Auth reads email, password_hash and verified; Punk::Plugin::TOTP
# reads and writes the three totp_ columns - the counter as a number,
# which is what the plugin hands back.
field id                => { type => 'integer', primary => 1 };
field email             => { type => 'string', required => 1 };
field password_hash     => { type => 'string' };
field verified          => { type => 'integer' };
field totp_secret       => { type => 'string' };
field totp_last_counter => { type => 'number' };
field totp_enabled      => { type => 'integer' };
field created           => { type => 'string' };

1;

The token model is the same shape over auth_tokens:

lib/AuthApp/Model/AuthToken.pm:

package AuthApp::Model::AuthToken;

use Punk::Model;

table 'auth_tokens';

# Punk::Auth's token table: only the SHA-256 digest of a token is stored,
# and a row is deleted the moment it is presented, valid or not. Verification
# and reset links live here, and so do the TOTP plugin's recovery codes.
field id      => { type => 'integer', primary => 1 };
field user_id => { type => 'integer' };
field kind    => { type => 'string' };
field digest  => { type => 'string' };
field expires => { type => 'integer' };

1;

Both are discovered automatically: anything under AuthApp::Model:: registers without a model line.



Wiring auth


The application class, after the keywords above, installs three helpers. The first draws every page through the layout with the same four things - who is signed in, the one-request notice the last action left in the flash, and the CSRF field:

helper page => sub {
my ($c, $template, $vars, %opt) = @_;
my $flash = $c->flash || {};
return $c->render($template, {
user   => $c->current_user,
notice => $flash->{notice},
kind   => $flash->{kind} // 'ok',
csrf   => $c->csrf_field,
%{ $vars || {} },
}, %opt);
};

$c->current_user loads the row once per request and memoises it; $c->flash reads what the previous request set. The layout prints the nav from user and the notice when there is one, and every form in every template carries {% raw csrf %}.

Here is that layout, the welcome page it wraps, and the controller behind the front page:

root/templates/layout.tmpl:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% title | default('AuthApp') %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1><a href="/">AuthApp</a></h1>
<nav>
{% if user %}
<span>signed in as <code>{% user.email %}</code></span>
<a href="/account">Account</a>
<a href="/vault/secrets">Vault</a>
<form method="post" action="/logout">{% raw csrf %}<button>Sign out</button></form>
{% else %}
<a href="/login">Sign in</a>
<a href="/register">Create an account</a>
{% end %}
</nav>
</header>

{% if notice %}<p class="notice {% kind %}">{% raw notice %}</p>{% end %}

<main>
{% content %}
</main>
</body>
</html>

root/templates/welcome.tmpl:

<h2>Passwords, mail, a second factor and social login</h2>
<p>One Punk application with every door on it: create an account and prove
the address by mail, sign in with a password or with GitHub or Google,
enrol a phone as a second factor, and reach a page that only a session
holding both can open.</p>
{% if user %}
<p>You are signed in. The <a href="/account">account page</a> is where the
second factor is enrolled; <a href="/vault/secrets">the vault</a> is what
it protects.</p>
{% else %}
<p><a href="/register">Create an account</a> or <a href="/login">sign in</a>.</p>
{% end %}

lib/AuthApp/Controller/Web/Root.pm:

package AuthApp::Controller::Web::Root;

use strict;
use warnings;
use parent 'Punk::Controller';

our $VERSION = '0.01';

sub index {
my ($c) = @_;
return $c->page('welcome');
}

1;

__END__

=head1 NAME

AuthApp::Controller::Web::Root - the front page

=cut

The stylesheet is the demo's only one - system fonts, light and dark, nothing else:

root/static/style.css:

/* The demo's one stylesheet: system fonts, light and dark, nothing else. */
:root { color-scheme: light dark }
body { font: 16px/1.5 system-ui, sans-serif; max-width: 40rem;
margin: 3rem auto; padding: 0 1rem }
header { display: flex; flex-wrap: wrap; justify-content: space-between;
align-items: baseline; gap: 1rem; margin-bottom: 1.5rem }
header h1 { margin: 0 }
header h1 a { color: inherit; text-decoration: none }
nav { display: flex; gap: 1rem; align-items: center; font-size: .9rem }
nav form { margin: 0; display: inline }
h2 { margin-top: 2rem }
code { user-select: all }
form.stack { display: flex; flex-direction: column; gap: .75rem;
max-width: 22rem; margin: 1rem 0 }
form.stack label { display: flex; flex-direction: column; gap: .25rem }
form.row { display: flex; gap: .5rem; margin: 1rem 0; align-items: center }
input { font: inherit; padding: .5rem .75rem }
button, a.button { font: inherit; padding: .5rem 1rem; border-radius: 6px;
border: 1px solid currentColor; background: none;
color: inherit; text-decoration: none; cursor: pointer }
p.row { display: flex; gap: .5rem; flex-wrap: wrap }
.qr { width: 16rem; max-width: 80vw; margin: 1rem 0 }
.qr svg { width: 100%; height: auto; display: block;
background: #fff; border-radius: 8px }
ul.status { list-style: none; padding: 0 }
li, .notice { padding: .35rem .6rem; border-radius: 6px; margin: .25rem 0 }
.ok    { background: #1a7f3722 }
.bad   { background: #b3261e22 }
.quiet { opacity: .8 }
small  { opacity: .6 }

The password door is in lib/AuthApp/Controller/Web/Auth.pm:

sub login {
my ($c) = @_;
my $email = _clean_email($c->param('email'));
my $pw    = $c->param('password') // '';
my $user  = $c->model('User')->get(email => $email);

# check_password burns the same work when there is no user, so the
# response time does not say whether the address exists
return $c->page('auth/login',
{ email => $email, error => 'Wrong email or password.' })
unless $c->check_password($user, $pw);
return $c->page('auth/login',
{ email => $email, error => 'Verify your address first - the link is in your mail.' })
unless $user->{verified};

if (Punk::Auth::Password::needs_rehash($user->{password_hash})) {
$c->model('User')->update({ id => $user->{id},
password_hash => Punk::Auth::Password::hash($pw) });
}
return $c->sign_in($user, to => $c->safe_path($c->param('to'), '/account'));
}

Three things here are the battery's, not ours. check_password takes undef for the user and burns a full PBKDF2 verification before saying no, so the timing of a wrong password and the timing of an unknown address are the same. needs_rehash is the opportunistic cost upgrade: when the hashing parameters are raised in a later release, each user's hash moves to the new cost on their next successful login, with no migration. And safe_path guards the ?to= that auth_guard writes when it redirects a stranger to the login: the value a login form reads back is whatever the browser sent, and someone else may have written that link, so anything that is not a same-origin relative path becomes /account.

The form it reads, with the social buttons and the hidden to that auth_guard filled in:

root/templates/auth/login.tmpl:

<h2>Sign in</h2>
{% if error %}<p class="notice bad">{% error %}</p>{% end %}
<form method="post" action="/login" class="stack">
{% raw csrf %}
<input type="hidden" name="to" value="{% to %}">
<label>Email <input name="email" type="email" value="{% email %}" autofocus required></label>
<label>Password <input name="password" type="password" required></label>
<button>Sign in</button>
</form>
<p class="row">
<a class="button" href="/auth/github">Continue with GitHub</a>
<a class="button" href="/auth/google">Continue with Google</a>
</p>
<p><small><a href="/forgot">Forgotten your password?</a> -
<a href="/register">No account yet?</a></small></p>

The guard itself is one line in the class:

# Signed in: the account page, enrolment and recovery codes.
my $account = under '/account' => auth_guard;
$account->get ('/'         => 'Web::Account#home');
$account->post('/totp'     => 'Web::Account#enrol');
$account->post('/recovery' => 'Web::Account#recovery');

auth_guard negotiates on Accept: a browser is redirected to login_path with ?to= set, anything else gets a 401 in the house error shape. The common case - is anyone signed in - runs entirely in C, one session load and one hash fetch, and every route under the scope inherits it without a per-action check.



Mail through Resend


Verification and reset links need a way out of the process. The mailer plugin takes one transport and makes it the application's:

# Outbound mail. With RESEND_API_KEY in the environment every message goes
# through Resend; without it the capture transport keeps each one under
# var/mail/new/ as a .eml file - what `punk dev` and the tests read. Links
# in every mail are built on the `host` declared in punk.yml.
my $resend = $ENV{RESEND_API_KEY};
plugin 'Mailer' => {
transport => $resend ? 'resend' : 'capture',
from      => $ENV{AUTHAPP_MAIL_FROM} // 'AuthApp <[email protected]>',
mail_dir  => 'root/mail',                  # base defaults to `host`
$resend ? (resend  => { api_key => $resend })
: (capture => { dir => 'var/mail' }),
};

Resend needs two things from you: an API key, and a sending domain you have verified in its dashboard, with from on that domain. With both in the environment, every message is a POST to Resend's API through Fetch, and the Result the helpers return says what happened - accepted with Resend's message id, or deferred for a 429 and rejected for anything else the API refused. Nothing dies for what happened on the wire; a malformed message is the only croak.

Without the key, the capture transport keeps every message as a .eml file under var/mail/new/, which is what a development box wants: open it in a mail client, or read the link straight out of it. The tests read the same transport in memory. The third rung, transport => 'log', writes each message to STDERR and records it as unsent, for a box that should not keep mail on disk either.

The mail templates live under mail_dir, one message per name: verify.txt.tmpl for the text part, verify.html.tmpl beside it for an HTML alternative if you want one. The directory is read once when the plugin registers, so a template that names nothing there is a croak listing what does exist.



Verification links with mail_token


Sign-up creates the user unverified and mails a link:

sub register {
my ($c) = @_;
my $email = _clean_email($c->param('email'));
my $pw    = $c->param('password') // '';

return $c->page('auth/register',
{ email => $email, error => 'Enter a valid email address.' })
unless _valid_email($email);
return $c->page('auth/register',
{ email => $email, error => "Use at least $MIN_PASSWORD characters." })
if length $pw < $MIN_PASSWORD;

# An address that already has an account gets the same page and no
# mail: the response must not say which addresses are registered.
my $users = $c->model('User');
my ($result, $link);
unless ($users->get(email => $email)) {
my $user = $users->create({
email         => $email,
password_hash => Punk::Auth::Password::hash($pw),
verified      => 0,
totp_enabled  => 0,
created       => _now(),
});
($result, $link) = $c->mail_token($user,
kind     => 'verify',
ttl      => 2 * 24 * 60 * 60,
path     => '/verify/%s',
template => 'verify',
subject  => 'Verify your AuthApp account',
);
}
return $c->page('auth/sent', {
kind     => 'verify',
email    => $email,
sent     => ($result && $result->accepted) ? 1 : 0,
dev_link => $c->app->env eq 'development' ? ($link // '') : '',
});
}

mail_token is the composition the two batteries were built to meet at. It issues a single-use token through Punk::Auth's issue_token - storing the digest on the AuthToken model with the kind and an expiry - builds the link on the configured origin, renders the template with link, token and user, sends, and returns the Result and the link. The link comes back so a development page can show it when no mail is configured, which auth/sent.tmpl does under punk dev. The template is all text:

root/mail/verify.txt.tmpl:

One click and your AuthApp account is live:

{% link %}

The link lasts 48 hours and works once. If you did not create an account
at {% base %}, ignore this mail - nothing happens without the click.

Redeeming it is take_token:

# The token is spent the moment it is presented, valid or not: a second
# click on the same link is a 410, not a second sign-in.
sub verify {
my ($c) = @_;
my $row = $c->take_token($c->param('token'), 'verify')
or return $c->page('auth/verify', { ok => 0 }, status => 410);
my $user = $c->model('User')->update({ id => $row->{user_id}, verified => 1 });
$c->flash(kind => 'ok', notice => 'Your address is verified.');
return $c->sign_in($user);
}

The order inside take_token is the feature: the row is deleted first and judged afterwards, so a token is spent whether or not it turns out to be the right kind or still in date. And issuing a new token of a kind invalidates the user's older ones, which is why a re-sent mail leaves exactly one live link rather than a trail of them.

The three pages around those two handlers - the form, the page after submit, and the one a spent link lands on (the reset flow reuses the last two):

root/templates/auth/register.tmpl:

<h2>Create an account</h2>
{% if error %}<p class="notice bad">{% error %}</p>{% end %}
<form method="post" action="/register" class="stack">
{% raw csrf %}
<label>Email <input name="email" type="email" value="{% email %}" autofocus required></label>
<label>Password <input name="password" type="password" minlength="10" required></label>
<button>Create account</button>
</form>
<p><small>A verification link is mailed to the address. Nothing works
until it is clicked.</small></p>

root/templates/auth/sent.tmpl:

<h2>Check your mail</h2>
{% if kind eq 'verify' %}
<p>If <code>{% email %}</code> is new here, a verification link is on its
way. It lasts 48 hours and works once.</p>
{% else %}
<p>If <code>{% email %}</code> has an account, a reset link is on its way.
It lasts 24 hours and works once.</p>
{% end %}
{% if dev_link %}
<p class="notice quiet">Development mode, so here is the link the mail
carries: <a href="{% dev_link %}">{% dev_link %}</a></p>
{% end %}

root/templates/auth/verify.tmpl:

<h2>That link has been used</h2>
<p>A link works once and for a limited time. This one is spent, or it was
never valid.
{% if kind eq 'reset' %}<a href="/forgot">Ask for a new reset link.</a>
{% else %}<a href="/login">Sign in</a>, or <a href="/register">create an account</a>.{% end %}</p>



Forgotten passwords


The reset flow is the same two halves with a different kind. The request side answers every address identically:

# The same page whatever the address: an answer that differed would be a
# way to list accounts one address at a time.
sub forgot {
my ($c) = @_;
my $email = _clean_email($c->param('email'));
my $user  = _valid_email($email) ? $c->model('User')->get(email => $email) : undef;
my ($result, $link);
($result, $link) = $c->mail_token($user,
kind     => 'reset',
ttl      => 24 * 60 * 60,
path     => '/reset/%s',
template => 'reset',
subject  => 'Reset your AuthApp password',
) if $user;
return $c->page('auth/sent', {
kind     => 'reset',
email    => $email,
sent     => ($result && $result->accepted) ? 1 : 0,
dev_link => $c->app->env eq 'development' ? ($link // '') : '',
});
}

The redeeming side is split in two, and the split matters. GET /reset/:token renders the form and leaves the token alone; only the POST with a new password spends it. Mail clients and link scanners fetch URLs they find in messages, and a link that died on the first GET would die in the preview pane before the user ever saw it:

sub reset {
my ($c) = @_;
my $token = $c->param('token');
my $pw    = $c->param('password') // '';
return $c->page('auth/reset',
{ token => $token, error => "Use at least $MIN_PASSWORD characters." })
if length $pw < $MIN_PASSWORD;

my $row = $c->take_token($token, 'reset')
or return $c->page('auth/verify', { ok => 0, kind => 'reset' }, status => 410);
my $user = $c->model('User')->update({
id            => $row->{user_id},
password_hash => Punk::Auth::Password::hash($pw),
verified      => 1,                 # the mail reached them
});
$c->flash(kind => 'ok', notice => 'Your password is changed.');
return $c->sign_in($user);
}

The password is checked before the token is taken, for the same reason: a typo should cost a second attempt, not a second mail.

The mail, and the two forms:

root/mail/reset.txt.tmpl:

Somebody - hopefully you - asked to reset the AuthApp password for
{% user.email %}. Choose a new one here:

{% link %}

The link lasts 24 hours and works once. If it was not you, ignore this
mail - your password does not change until the link is used.

root/templates/auth/forgot.tmpl:

<h2>Reset your password</h2>
<form method="post" action="/forgot" class="stack">
{% raw csrf %}
<label>Email <input name="email" type="email" autofocus required></label>
<button>Send a reset link</button>
</form>

root/templates/auth/reset.tmpl:

<h2>Choose a new password</h2>
{% if error %}<p class="notice bad">{% error %}</p>{% end %}
<form method="post" action="/reset/{% token %}" class="stack">
{% raw csrf %}
<label>New password <input name="password" type="password" minlength="10" autofocus required></label>
<button>Change password</button>
</form>



One chokepoint for signing in


Every handler above ends in $c->sign_in, not $c->login. That is the third helper, and it is the most important dozen lines in the application:

# THE chokepoint: the one place an identity becomes a session. Password
# login, a verification link, a reset link and a social login all return
# through here, so a user who enrolled a second factor meets it whichever
# door they came in by - a mailed link cannot bypass the factor.
helper sign_in => sub {
my ($c, $user, %opt) = @_;
my $to = $opt{to} // '/account';
return $c->totp_challenge($user, to => $to) if $user->{totp_enabled};
$c->login($user);
return $c->redirect($to);
};

A second factor gated only on the password form is a second factor with a side entrance: a reset link, a verification link and a social login each produce a signed-in session too, and each is a way round the factor unless they all funnel through the same decision. The TOTP plugin's own documentation says exactly this, and the application's test proves it - a reset link presented by an enrolled user lands on the challenge, not the account.



A second factor


The plugin is configured in the file, since nothing about it is code:

plugins:
TOTP:
issuer:         AuthApp          # names the account in the authenticator app
login_path:     /login           # an unauthenticated step-up goes here
render:         totp_page        # the challenge page, as a helper of ours
recovery_model: AuthToken        # recovery codes share the token table

Two of those lines are about the tables. recovery_model names where recovery codes go - the plugin defaults to a model called Token, and ours is AuthToken, so it is said. And the three totp_ columns on the user row are the plugin's: the secret, the last accepted counter, and the enabled flag.

render names the challenge page, and it has to. The plugin ships a self-contained form so it works out of the box, but that form carries no CSRF token, and with csrf on the plugin's POST /login/totp is checked like any other. So the page is ours, drawn through the same page helper as everything else:

helper totp_page => sub {
my ($c, $error) = @_;
return $c->page('auth/totp', { error => $error ? 1 : 0 });
};

Enrolment lives on the account page, which is one of two pages depending on state:

sub home {
my ($c) = @_;
my $user = $c->current_user;

if (!$user->{totp_enabled}) {
# the pending secret lives in the session until a code proves it
my $secret = $c->session->{totp_enrolling} //= $c->totp_secret;
return $c->page('account/enrol', {
secret => $secret,
qr     => $c->totp_qr($secret),
});
}
return $c->page('account/home', {
passed => $c->session->{totp_at} ? 1 : 0,
});
}

totp_qr renders the otpauth:// URI as an SVG through QR::Code, at the highest error correction level - an enrolment QR is scanned once, from a screen, by every phone the user will ever own. The secret is not on the user row yet. It sits in the session until a code proves the phone has it:

sub enrol {
my ($c) = @_;
my $user   = $c->current_user;
my $secret = $c->session->{totp_enrolling} or return $c->redirect('/account');
my $code   = $c->param('code') // '';
$code =~ s/\s+//g;

unless ($c->totp_verify({ %$user, totp_secret => $secret }, $code)) {
$c->flash(kind => 'bad', notice => 'That code did not match. Try the next one.');
return $c->redirect('/account');
}
$c->model('User')->update({ id => $user->{id},
totp_secret => $secret, totp_enabled => 1 });
delete $c->session->{totp_enrolling};
$c->flash(kind => 'ok', notice => 'Second factor enabled. Keep some recovery codes.');
return $c->redirect('/account');
}

totp_verify is replay-safe by construction: on success it writes the matched counter back through the User model before returning true, so the code that just enrolled the phone is already spent when the challenge page sees it thirty seconds later. The test checks that too.

The vault is the page that needs both halves:

# Signed in AND this session passed the second factor. A session that has
# not is stepped up to the challenge and comes back here afterwards.
my $vault = under '/vault' => totp_guard();
$vault->get('/secrets' => 'Web::Vault#secrets');

totp_guard() - with parentheses, because the plugin installs the keyword when it registers, which is after the package has been parsed - passes when this session recorded a passed factor since it was signed in. A signed-in session that has not is sent to the challenge with the page it wanted remembered, and returns there afterwards. A session that enrolled a phone a moment ago has not passed the challenge either; it is stepped up like any other, which is how the test reaches the vault.

Recovery codes are five single-use codes shown once, issued by totp_recovery_codes into the same AuthToken table under their own kind. They are deliberately not issue_token: that keyword revokes a user's older tokens of the same kind when it issues a new one, which is right for a mailed link and wrong for a drawer of codes that must all stay good until each is used.

The account controller in full - home and enrol from above, plus the recovery action - the vault controller, and the four templates they render:

lib/AuthApp/Controller/Web/Account.pm:

package AuthApp::Controller::Web::Account;

use strict;
use warnings;
use parent 'Punk::Controller';

our $VERSION = '0.01';

# Behind auth_guard, so $c->current_user is always a row here.

# Two pages by state: enrolment until a phone has proved it holds the
# secret, the account page afterwards.
sub home {
my ($c) = @_;
my $user = $c->current_user;

if (!$user->{totp_enabled}) {
# the pending secret lives in the session until a code proves it
my $secret = $c->session->{totp_enrolling} //= $c->totp_secret;
return $c->page('account/enrol', {
secret => $secret,
qr     => $c->totp_qr($secret),
});
}
return $c->page('account/home', {
passed => $c->session->{totp_at} ? 1 : 0,
});
}

# Verified against the pending secret. On success the plugin has already
# written the matched counter to the row - the replay floor - so storing
# the secret and flipping the flag completes the enrolment. Until then the
# account has no second factor, whatever the session holds.
sub enrol {
my ($c) = @_;
my $user   = $c->current_user;
my $secret = $c->session->{totp_enrolling} or return $c->redirect('/account');
my $code   = $c->param('code') // '';
$code =~ s/\s+//g;

unless ($c->totp_verify({ %$user, totp_secret => $secret }, $code)) {
$c->flash(kind => 'bad', notice => 'That code did not match. Try the next one.');
return $c->redirect('/account');
}
$c->model('User')->update({ id => $user->{id},
totp_secret => $secret, totp_enabled => 1 });
delete $c->session->{totp_enrolling};
$c->flash(kind => 'ok', notice => 'Second factor enabled. Keep some recovery codes.');
return $c->redirect('/account');
}

# Five single-use codes, shown once. A new set revokes the old one.
sub recovery {
my ($c) = @_;
my $user  = $c->current_user;
my $codes = $c->totp_recovery_codes($user, count => 5);
return $c->page('account/home', {
passed => $c->session->{totp_at} ? 1 : 0,
codes  => $codes,
notice => 'Recovery codes issued - any older set is revoked.',
kind   => 'ok',
});
}

1;

__END__

=head1 NAME

AuthApp::Controller::Web::Account - the account page, enrolment, recovery codes

=cut

lib/AuthApp/Controller/Web/Vault.pm:

package AuthApp::Controller::Web::Vault;

use strict;
use warnings;
use parent 'Punk::Controller';

our $VERSION = '0.01';

# Reached only through totp_guard: this session passed the second factor.
sub secrets {
my ($c) = @_;
return $c->page('vault/secrets', { title => 'The vault' });
}

1;

__END__

=head1 NAME

AuthApp::Controller::Web::Vault - the page behind totp_guard

=cut

root/templates/auth/totp.tmpl - the challenge page render: totp_page names, with the CSRF field the plugin's own form lacks:

<h2>Second factor</h2>
<p>The password passed. Enter the code your authenticator shows, or one of
your recovery codes.</p>
{% if error %}
<p class="notice bad">That code did not work. Try again, or use a recovery
code - repeated misses drop you back to the password.</p>
{% end %}
<form method="post" action="/login/totp" class="row">
{% raw csrf %}
<input name="code" autofocus autocomplete="one-time-code"
inputmode="numeric" pattern="[0-9A-Za-z -]*"
placeholder="000000 or a recovery code" aria-label="code">
<button>Verify</button>
</form>

root/templates/account/enrol.tmpl:

<h2>Enrol a phone</h2>
<p>No second factor yet. Scan with Google Authenticator, 1Password, Aegis -
anything. Or add the secret by hand: <code>{% secret %}</code></p>

<div class="qr">{% raw qr %}</div>

<form method="post" action="/account/totp" class="row">
{% raw csrf %}
<input name="code" autofocus autocomplete="one-time-code"
inputmode="numeric" pattern="[0-9]*" placeholder="000000"
aria-label="code">
<button>Confirm</button>
</form>
<p><small>Enrolment is not complete until one code proves the phone has the
secret - the account's second factor switches on only then.</small></p>

root/templates/account/home.tmpl:

<h2>Your account</h2>
<p>Second factor on. Every sign-in - password, a mailed link, GitHub or
Google - now goes through the challenge.</p>

<ul class="status">
{% if passed %}
<li class="ok">this session passed the factor - <a href="/vault/secrets">the
vault</a> opens</li>
{% else %}
<li class="quiet">this session has not passed the factor yet -
<a href="/vault/secrets">the vault</a> will ask for a code first</li>
{% end %}
{% if codes %}
<li class="ok">recovery codes, shown once - put them somewhere safe:

{% for code in codes %}<code>{% code %}</code> {% end %}</li>
{% end %}
</ul>

<form method="post" action="/account/recovery" class="row">
{% raw csrf %}
<button>Issue 5 recovery codes</button>
<small>a new set revokes the old one</small>
</form>

root/templates/vault/secrets.tmpl:

<h2>The vault</h2>
<p>You are reading this because this session holds both halves: a signed-in
user, and a second factor passed since it was signed in.
<a href="/account">Back to the account.</a></p>



Sign in with GitHub and Google


The plugin installs two keywords at compile time, so the use line matters:

use Punk;
use Punk::Plugin::OAuth2;      # compile time: installs the oauth2 / oauth2_login keywords

and the declarations are the provider presets plus your credentials:

plugin 'OAuth2';

oauth2 github => {
preset        => 'github',
client_id     => secret('oauth.github_id'),
client_secret => secret('oauth.github_secret'),
};
oauth2 google => {
preset        => 'google',
client_id     => secret('oauth.google_id'),
client_secret => secret('oauth.google_secret'),
};

oauth2_login '/auth' => {
on_login => 'Web::Auth#on_login',
base_url => host(),
};

secret('oauth.github_id') reaches the oauth: block of punk.yml, where each value is an { $env: ... } reference like the session key. host() with no argument reads the origin declared in the file back, so the callback URLs the plugin registers - /auth/github/callback and /auth/google/callback on that origin - are built from the same value the mail links are, and a request's Host header is consulted for neither.

Both providers need an application registered on their side. At GitHub, an OAuth App under developer settings with the callback URL http://localhost:5000/auth/github/callback; at Google, an OAuth client in the Cloud console with http://localhost:5000/auth/google/callback as an authorised redirect URI. The presets already ask for the scopes the identity needs - GitHub's read:user user:email, Google's openid email profile - and GitHub's identity is read from the emails endpoint, because a private primary address is GitHub's default.

oauth2_login mounts the hop and the callback, runs the authorization code flow with PKCE and single-use signed state, verifies Google's id_token against its JWKS, and calls your handler with a normalised identity. The handler is where a stranger becomes a user:

# Called by the OAuth2 plugin with a verified identity. Only an address the
# provider has itself verified may claim an account here; a returned
# reference becomes the response, so the redirect decision is sign_in's.
sub on_login {
my ($c, $identity, $tokens) = @_;
return $c->redirect('/login?error=oauth')
unless $identity->{email_verified} && $identity->{email};

my $email = _clean_email($identity->{email});
my $users = $c->model('User');
my $user  = $users->get(email => $email) // $users->create({
email        => $email,
verified     => 1,
totp_enabled => 0,
created      => _now(),
});
return $c->sign_in($user);
}

Find-or-create by a verified email is the composition Punk::Auth documents for federated sign-in, and the email_verified check is what makes it safe: an address the provider has not verified is an address anyone could have typed into a profile. Returning $c->sign_in($user) - a reference - makes it the response, which is how an enrolled user arriving from GitHub still meets the challenge. The tokens are handed over and then discarded; nothing token-shaped is kept in the session.



The application class and the auth controller, complete


Every keyword in the class and every handler in the auth controller has now been explained in its own section; here are both files whole, in the order the lines run. The class reads top to bottom as the boot does: the file is applied, auth is declared, the tables are ensured, the two plugins register, the three helpers are installed, and the routes are listed last.

lib/AuthApp.pm:

package AuthApp;

use strict;
use warnings;

use Punk;
use Punk::Plugin::OAuth2;      # compile time: installs the oauth2 / oauth2_login keywords
use AuthApp::Schema ();

our $VERSION = '0.01';

# Views, the static mount, the origin, the database, sessions, CSRF and
# the TOTP plugin all come from config/punk.yml. This file is the routing
# table and the wiring that has to be code.
config 'config/punk.yml';

# The authentication battery: who the signed-in user is (User), where
# single-use tokens live (AuthToken - verification and reset links, and the
# TOTP plugin's recovery codes), and where a guard sends a stranger.
auth model       => 'User',
token_model => 'AuthToken',
login_path  => '/login';

# The tables, created if they are missing. Punk ships no migrations.
AuthApp::Schema->ensure(punk_app->config_object->get('database.dsn'));

# Outbound mail. With RESEND_API_KEY in the environment every message goes
# through Resend; without it the capture transport keeps each one under
# var/mail/new/ as a .eml file - what `punk dev` and the tests read. Links
# in every mail are built on the `host` declared in punk.yml.
my $resend = $ENV{RESEND_API_KEY};
plugin 'Mailer' => {
transport => $resend ? 'resend' : 'capture',
from      => $ENV{AUTHAPP_MAIL_FROM} // 'AuthApp <[email protected]>',
mail_dir  => 'root/mail',                  # base defaults to `host`
$resend ? (resend  => { api_key => $resend })
: (capture => { dir => 'var/mail' }),
};

# Sign in with GitHub or Google. The presets carry the endpoints; the
# credentials come from the secrets system; the callback URLs are built
# from the one declared origin.
plugin 'OAuth2';

oauth2 github => {
preset        => 'github',
client_id     => secret('oauth.github_id'),
client_secret => secret('oauth.github_secret'),
};
oauth2 google => {
preset        => 'google',
client_id     => secret('oauth.google_id'),
client_secret => secret('oauth.google_secret'),
};

oauth2_login '/auth' => {
on_login => 'Web::Auth#on_login',
base_url => host(),
};

# Every page goes through the layout with the same four things: who is
# signed in, the one-request notice the last action left in the flash, and
# the CSRF field every form prints.
helper page => sub {
my ($c, $template, $vars, %opt) = @_;
my $flash = $c->flash || {};
return $c->render($template, {
user   => $c->current_user,
notice => $flash->{notice},
kind   => $flash->{kind} // 'ok',
csrf   => $c->csrf_field,
%{ $vars || {} },
}, %opt);
};

# The second-factor challenge page, named by `render: totp_page` in
# punk.yml. The plugin's own default form carries no CSRF token, and with
# `csrf` on its POST is checked like any other - so the page is ours.
helper totp_page => sub {
my ($c, $error) = @_;
return $c->page('auth/totp', { error => $error ? 1 : 0 });
};

# THE chokepoint: the one place an identity becomes a session. Password
# login, a verification link, a reset link and a social login all return
# through here, so a user who enrolled a second factor meets it whichever
# door they came in by - a mailed link cannot bypass the factor.
helper sign_in => sub {
my ($c, $user, %opt) = @_;
my $to = $opt{to} // '/account';
return $c->totp_challenge($user, to => $to) if $user->{totp_enabled};
$c->login($user);
return $c->redirect($to);
};

get  '/'               => 'Web::Root#index';

get  '/register'       => 'Web::Auth#register_form';
post '/register'       => 'Web::Auth#register';
get  '/verify/:token'  => 'Web::Auth#verify';
get  '/login'          => 'Web::Auth#login_form';
post '/login'          => 'Web::Auth#login';
post '/logout'         => 'Web::Auth#logout';
get  '/forgot'         => 'Web::Auth#forgot_form';
post '/forgot'         => 'Web::Auth#forgot';
get  '/reset/:token'   => 'Web::Auth#reset_form';
post '/reset/:token'   => 'Web::Auth#reset';

# Signed in: the account page, enrolment and recovery codes.
my $account = under '/account' => auth_guard;
$account->get ('/'         => 'Web::Account#home');
$account->post('/totp'     => 'Web::Account#enrol');
$account->post('/recovery' => 'Web::Account#recovery');

# Signed in AND this session passed the second factor. A session that has
# not is stepped up to the challenge and comes back here afterwards.
my $vault = under '/vault' => totp_guard();
$vault->get('/secrets' => 'Web::Vault#secrets');

1;

__END__

=head1 NAME

AuthApp - passwords, mail, a second factor and social login on Punk

=head1 DESCRIPTION

The application built in the guide beside this directory. Start it from
the application root:

punk dev

=cut

lib/AuthApp/Controller/Web/Auth.pm:

package AuthApp::Controller::Web::Auth;

use strict;
use warnings;
use parent 'Punk::Controller';
use POSIX ();
use Punk::Auth::Password ();

our $VERSION = '0.01';

my $MIN_PASSWORD = 10;

sub _clean_email {
my ($email) = @_;
$email = lc($email // '');
$email =~ s/\A\s+|\s+\z//g;
return $email;
}

sub _valid_email { $_[0] =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/ }

sub _now { POSIX::strftime('%Y-%m-%dT%H:%M:%SZ', gmtime) }

# ---- sign up, then prove the address ---------------------------------------

sub register_form {
my ($c) = @_;
return $c->page('auth/register');
}

sub register {
my ($c) = @_;
my $email = _clean_email($c->param('email'));
my $pw    = $c->param('password') // '';

return $c->page('auth/register',
{ email => $email, error => 'Enter a valid email address.' })
unless _valid_email($email);
return $c->page('auth/register',
{ email => $email, error => "Use at least $MIN_PASSWORD characters." })
if length $pw < $MIN_PASSWORD;

# An address that already has an account gets the same page and no
# mail: the response must not say which addresses are registered.
my $users = $c->model('User');
my ($result, $link);
unless ($users->get(email => $email)) {
my $user = $users->create({
email         => $email,
password_hash => Punk::Auth::Password::hash($pw),
verified      => 0,
totp_enabled  => 0,
created       => _now(),
});
($result, $link) = $c->mail_token($user,
kind     => 'verify',
ttl      => 2 * 24 * 60 * 60,
path     => '/verify/%s',
template => 'verify',
subject  => 'Verify your AuthApp account',
);
}
return $c->page('auth/sent', {
kind     => 'verify',
email    => $email,
sent     => ($result && $result->accepted) ? 1 : 0,
dev_link => $c->app->env eq 'development' ? ($link // '') : '',
});
}

# The token is spent the moment it is presented, valid or not: a second
# click on the same link is a 410, not a second sign-in.
sub verify {
my ($c) = @_;
my $row = $c->take_token($c->param('token'), 'verify')
or return $c->page('auth/verify', { ok => 0 }, status => 410);
my $user = $c->model('User')->update({ id => $row->{user_id}, verified => 1 });
$c->flash(kind => 'ok', notice => 'Your address is verified.');
return $c->sign_in($user);
}

# ---- the password door --------------------------------------------------------

sub login_form {
my ($c) = @_;
return $c->page('auth/login', { to => $c->safe_path($c->param('to'), '') });
}

sub login {
my ($c) = @_;
my $email = _clean_email($c->param('email'));
my $pw    = $c->param('password') // '';
my $user  = $c->model('User')->get(email => $email);

# check_password burns the same work when there is no user, so the
# response time does not say whether the address exists
return $c->page('auth/login',
{ email => $email, error => 'Wrong email or password.' })
unless $c->check_password($user, $pw);
return $c->page('auth/login',
{ email => $email, error => 'Verify your address first - the link is in your mail.' })
unless $user->{verified};

if (Punk::Auth::Password::needs_rehash($user->{password_hash})) {
$c->model('User')->update({ id => $user->{id},
password_hash => Punk::Auth::Password::hash($pw) });
}
return $c->sign_in($user, to => $c->safe_path($c->param('to'), '/account'));
}

sub logout {
my ($c) = @_;
$c->logout;
return $c->redirect('/');
}

# ---- forgotten passwords --------------------------------------------------------

sub forgot_form {
my ($c) = @_;
return $c->page('auth/forgot');
}

# The same page whatever the address: an answer that differed would be a
# way to list accounts one address at a time.
sub forgot {
my ($c) = @_;
my $email = _clean_email($c->param('email'));
my $user  = _valid_email($email) ? $c->model('User')->get(email => $email) : undef;
my ($result, $link);
($result, $link) = $c->mail_token($user,
kind     => 'reset',
ttl      => 24 * 60 * 60,
path     => '/reset/%s',
template => 'reset',
subject  => 'Reset your AuthApp password',
) if $user;
return $c->page('auth/sent', {
kind     => 'reset',
email    => $email,
sent     => ($result && $result->accepted) ? 1 : 0,
dev_link => $c->app->env eq 'development' ? ($link // '') : '',
});
}

# GET shows the form and leaves the token alone; only a submitted password
# spends it, so a preview fetch by a mail client cannot burn the link.
sub reset_form {
my ($c) = @_;
return $c->page('auth/reset', { token => $c->param('token') });
}

sub reset {
my ($c) = @_;
my $token = $c->param('token');
my $pw    = $c->param('password') // '';
return $c->page('auth/reset',
{ token => $token, error => "Use at least $MIN_PASSWORD characters." })
if length $pw < $MIN_PASSWORD;

my $row = $c->take_token($token, 'reset')
or return $c->page('auth/verify', { ok => 0, kind => 'reset' }, status => 410);
my $user = $c->model('User')->update({
id            => $row->{user_id},
password_hash => Punk::Auth::Password::hash($pw),
verified      => 1,                 # the mail reached them
});
$c->flash(kind => 'ok', notice => 'Your password is changed.');
return $c->sign_in($user);
}

# ---- social login -------------------------------------------------------------

# Called by the OAuth2 plugin with a verified identity. Only an address the
# provider has itself verified may claim an account here; a returned
# reference becomes the response, so the redirect decision is sign_in's.
sub on_login {
my ($c, $identity, $tokens) = @_;
return $c->redirect('/login?error=oauth')
unless $identity->{email_verified} && $identity->{email};

my $email = _clean_email($identity->{email});
my $users = $c->model('User');
my $user  = $users->get(email => $email) // $users->create({
email        => $email,
verified     => 1,
totp_enabled => 0,
created      => _now(),
});
return $c->sign_in($user);
}

1;

__END__

=head1 NAME

AuthApp::Controller::Web::Auth - sign-up, sign-in, mailed links and on_login

=cut



Testing the whole flow in process


t/01-basic.t drives every door without a browser, a mail server or a provider. Here it is whole; the walk-through follows it.

t/01-basic.t:

#!perl
use 5.010;
use strict;
use warnings;
use FindBin ();
use lib "$FindBin::Bin/../lib";
use Test::More;

# The whole flow, in process: sign-up, the mailed link, sign-in, reset,
# the second factor, recovery codes, and the first hop of a social login.
# One Punk::Test object is one browser: its cookie jar carries the session
# across every request below, and `csrf => 1` sends the token the last
# page minted.

# The test's environment: its own database layer, a session key, and
# placeholder OAuth credentials - every `{ $env }` in punk.yml has to
# resolve, or boot refuses.
BEGIN {
$ENV{PUNK_ENV}            //= 'test';
$ENV{AUTHAPP_SESSION_KEY} //= 'test-only-session-key-' x 2;
$ENV{"AUTHAPP_$_"}        //= "placeholder-\L$_" for qw(GITHUB_ID GITHUB_SECRET GOOGLE_ID GOOGLE_SECRET);
delete $ENV{RESEND_API_KEY};                   # always the capture transport here
}

use Punk::Test;
use Punk::TOTP ();

chdir "$FindBin::Bin/.." or die "cannot chdir to the application root: $!\n";
unlink 'var/test.db';                              # a fresh database every run

my $t = Punk::Test->new('AuthApp');
my $mail = sub { Punk::Plugin::Mailer->engine_for('AuthApp')->transport->messages };
my $link_in = sub {                                # the link a captured mail carries
my ($kind) = @_;
my ($link) = $mail->()->[-1]{spec}{text} =~ m{(http://localhost:5000/\Q$kind\E/[\w~.-]+)};
(my $path = $link // '') =~ s{^http://localhost:5000}{};
return $path;
};

# ---- signed out ------------------------------------------------------------
$t->get_ok('/')->status_is(200)->content_like(qr/Create an account/, 'the front page');
# auth_guard negotiates on Accept: a browser is redirected to the login
# with a way back, anything else gets a 401 - so ask as a browser does.
$t->get_ok('/account', headers => { Accept => 'text/html' })->status_is(302)
->header_like(Location => qr{^/login\?to=}, 'auth_guard sends a stranger to the login, with a way back');
$t->get_ok('/account')->status_is(401, 'and answers a non-browser client with a 401');
$t->get_ok('/register')->status_is(200)->content_like(qr/name="_csrf"/, 'forms carry the CSRF field');

# ---- sign up, verify by mail ------------------------------------------------
$t->post_ok('/register', form => { email => '[email protected]', password => 'short' }, csrf => 1)
->status_is(200)->content_like(qr/at least 10 characters/, 'a short password is refused');
$t->post_ok('/register', form => { email => '[email protected]', password => 'correct horse battery' }, csrf => 1)
->status_is(200)->content_like(qr/Check your mail/, 'a sign-up is acknowledged');
is scalar @{ $mail->() }, 1, 'one verification mail went out';
ok $mail->()->
  • {result}->accepted, 'and the transport accepted it';
is $mail->()->my $verify = $link_in->('verify');
ok $verify, "the mail carries the link: $verify";

$t->post_ok('/register', form => { email => '[email protected]', password => 'correct horse battery' }, csrf => 1)
->status_is(200)->content_like(qr/Check your mail/, 'the same address again gets the same page');
is scalar @{ $mail->() }, 1, 'and no second mail - the response does not say which addresses exist';

$t->post_ok('/login', form => { email => '[email protected]', password => 'correct horse battery' }, csrf => 1)
->status_is(200)->content_like(qr/Verify your address first/, 'signing in before verifying is refused');

$t->get_ok($verify)->status_is(302)->header_is(Location => '/account', 'the link verifies and signs in');
$t->get_ok($verify)->status_is(410, 'the same link a second time is spent');
$t->get_ok('/account')->status_is(200)->content_like(qr/Enrol a phone/, 'signed in: enrolment is offered');
$t->post_ok('/logout', csrf => 1)->status_is(302);

# ---- the password door --------------------------------------------------------
$t->get_ok('/login')->status_is(200)
->content_like(qr{href="/auth/github"}, 'the social buttons are on the login page');
$t->post_ok('/login', form => { email => '[email protected]', password => 'wrong wrong wrong' }, csrf => 1)
->status_is(200)->content_like(qr/Wrong email or password/, 'a wrong password is refused');
$t->post_ok('/login', form => { email => '[email protected]', password => 'wrong wrong wrong' }, csrf => 1)
->status_is(200)->content_like(qr/Wrong email or password/, 'with the same answer for an unknown address');
$t->post_ok('/login', form => { email => '[email protected]', password => 'correct horse battery' }, csrf => 1)
->status_is(302)->header_is(Location => '/account', 'the right password signs in');
$t->post_ok('/logout', csrf => 1)->status_is(302);

# ---- forgotten password -------------------------------------------------------
$t->get_ok('/forgot')->status_is(200);
$t->post_ok('/forgot', form => { email => '[email protected]' }, csrf => 1)
->status_is(200)->content_like(qr/Check your mail/, 'an unknown address gets the same page');
is scalar @{ $mail->() }, 1, 'and no mail';
$t->post_ok('/forgot', form => { email => '[email protected]' }, csrf => 1)->status_is(200);
my $reset1 = $link_in->('reset');
$t->post_ok('/forgot', form => { email => '[email protected]' }, csrf => 1)->status_is(200);
my $reset2 = $link_in->('reset');
is scalar @{ $mail->() }, 3, 'two reset mails';
isnt $reset1, $reset2, 'with different links';

$t->get_ok($reset2)->status_is(200)->content_like(qr/Choose a new password/, 'GET shows the form without spending the token');
$t->post_ok($reset1, form => { password => 'a brand new password' }, csrf => 1)
->status_is(410, 'the earlier link died when the later one was issued');
$t->post_ok($reset2, form => { password => 'tiny' }, csrf => 1)
->status_is(200)->content_like(qr/at least 10 characters/, 'a bad password does not spend the link');
$t->post_ok($reset2, form => { password => 'a brand new password' }, csrf => 1)
->status_is(302)->header_is(Location => '/account', 'the reset signs in');
$t->post_ok('/logout', csrf => 1);
$t->get_ok('/login');                              # logout emptied the session, token included
$t->post_ok('/login', form => { email => '[email protected]', password => 'a brand new password' }, csrf => 1)
->status_is(302, 'and the new password works');

# ---- the second factor --------------------------------------------------------
$t->get_ok('/account')->status_is(200);
my ($secret) = $t->body =~ m{by hand: <code>([A-Z2-7]+)</code>};
ok $secret, 'the enrolment page shows the pending secret';
like $t->body, qr/<svg/, 'and the QR';
my $now = time;
$t->post_ok('/account/totp', form => { code => '000000' }, csrf => 1)->status_is(302);
$t->get_ok('/account')->content_like(qr/did not match/, 'a wrong code is refused');
$t->post_ok('/account/totp', form => { code => Punk::TOTP->code($secret, time => $now) }, csrf => 1)->status_is(302);
$t->get_ok('/account')->content_like(qr/Second factor enabled/, 'a right code completes enrolment')
->content_like(qr/has not passed the factor yet/, 'but this session has not met the challenge');

$t->get_ok('/vault/secrets')->status_is(302)->header_is(Location => '/login/totp', 'totp_guard steps up');
$t->get_ok('/login/totp')->status_is(200)
->content_like(qr/Second factor/, 'the challenge page is ours')
->content_like(qr/name="_csrf"/, 'and carries the CSRF field the plugin form lacks');
$t->post_ok('/login/totp', form => { code => Punk::TOTP->code($secret, time => $now) }, csrf => 1)
->status_is(200)->content_like(qr/did not work/, 'the enrolment code is a replay - refused by the floor');
$t->post_ok('/login/totp', form => { code => Punk::TOTP->code($secret, time => $now + 30) }, csrf => 1)
->status_is(302)->header_is(Location => '/vault/secrets', 'the next window passes and returns to the stepped-up page');
$t->get_ok('/vault/secrets')->status_is(200)->content_like(qr/The vault/);

$t->post_ok('/account/recovery', csrf => 1)->status_is(200);
my @codes = $t->body =~ m{<code>([A-Z2-7]{8}-[A-Z2-7]{8})</code>}g;
is scalar @codes, 5, 'five recovery codes, shown once';

# ---- every door now leads to the challenge ------------------------------------
$t->post_ok('/logout', csrf => 1);
$t->get_ok('/login');
$t->post_ok('/login', form => { email => '[email protected]', password => 'a brand new password' }, csrf => 1)
->status_is(302)->header_is(Location => '/login/totp', 'enrolled: the password leads to the challenge');
$t->get_ok('/account')->status_is(401, 'pending is not a login - the account page is still guarded');
$t->get_ok('/login/totp');
$t->post_ok('/login/totp', form => { code => lc $codes[0] }, csrf => 1)
->status_is(302)->header_is(Location => '/account', 'a recovery code passes, case folded');
$t->get_ok('/account')->status_is(200)->content_like(qr/this session passed the factor/);

$t->post_ok('/forgot', form => { email => '[email protected]' }, csrf => 1);
my $reset3 = $link_in->('reset');
$t->post_ok('/logout', csrf => 1);
$t->get_ok($reset3);
$t->post_ok($reset3, form => { password => 'yet another password' }, csrf => 1)
->status_is(302)->header_is(Location => '/login/totp', 'a mailed link cannot bypass the factor either');

# ---- social login: the first hop --------------------------------------------
$t->get_ok('/auth/github')->status_is(302)
->header_like(Location => qr{^https://github\.com/login/oauth/authorize\?}, 'GitHub: off to the provider')
->header_like(Location => qr{code_challenge_method=S256}, 'with PKCE')
->header_like(Location => qr{redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fauth%2Fgithub%2Fcallback}, 'and the callback built from the declared host');
$t->get_ok('/auth/google')->status_is(302)
->header_like(Location => qr{^https://accounts\.google\.com/}, 'Google: off to the provider');
$t->get_ok('/auth/nope')->status_is(404, 'an unknown provider is a 404');

$t->get_ok('/no-such-page')->status_is(404);
done_testing();

The setup supplies what the configuration references, because a missing variable is a boot error here exactly as it is in production. PUNK_ENV=test selects config/punk.test.yml, whose one block points the database at var/test.db, so the suite never touches the file punk dev is using:

config/punk.test.yml:

# Loaded over punk.yml when PUNK_ENV=test, which t/01-basic.t sets: the
# suite gets its own database file and never touches the one `punk dev`
# is using.
database:
dsn: dbi:SQLite:dbname=var/test.db

One Punk::Test object is one browser - its cookie jar carries the session across every request - and csrf => 1 on a POST sends the token the last page minted. The mailed links come out of the capture transport: Punk::Plugin::Mailer->engine_for('AuthApp') is the engine the plugin built, its transport holds every message it was handed, and $link_in pulls the link out of the last one's text.

From there the test reads like the guide: sign up, assert one mail to the lowercased address, sign in before verifying and be refused, follow the link and land on /account, follow it again and get a 410. Two reset requests, and the first link is dead because the second revoked it. Then the factor: the secret comes off the enrolment page, the codes are computed with the engine underneath the plugin, Punk::TOTP, and the code that enrolled the phone is refused at the challenge thirty seconds later as a replay. Social login cannot complete without a provider, so the suite asserts the first hop and what it carries - the provider's authorize URL, PKCE, and a callback built from the declared host.



Running it


export AUTHAPP_SESSION_KEY=$(punk secret)
export AUTHAPP_GITHUB_ID=...      AUTHAPP_GITHUB_SECRET=...
export AUTHAPP_GOOGLE_ID=...      AUTHAPP_GOOGLE_SECRET=...

# optional: real mail through Resend; without it every message is
# captured under var/mail/new/ as a .eml file
export RESEND_API_KEY=re_...
export AUTHAPP_MAIL_FROM='AuthApp <[email protected]>'

punk dev

punk dev runs the application under Hyperman on port 5000 and restarts it when lib, config or root/templates change. Placeholder OAuth values are fine for everything except completing a social login. punk test runs the suite, punk routes prints the table at the top of this post, and punk config check resolves the configuration and reports every secret it could not find - the diagnostic for a boot that refused.


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: