Dancer2-Session-Pg
view release on metacpan or search on metacpan
lib/Dancer2/Session/Pg.pm view on Meta::CPAN
1;
__END__
=encoding utf8
=for stopwords AEAD AES ChaCha DDL DSN GCM Kubernetes NIST OpenID Poly XHR unkeyed atomicity upsert upserting crashloops dbh dbpass dbschema dbtable dbuser decrypt decryptable decrypted decrypts deserialise deserialising diagnosable dsn encryptions n...
=head1 NAME
Dancer2::Session::Pg - PostgreSQL session backend for Dancer2
=head1 VERSION
version 0.001
=head1 STATUS
Package Dancer2::Session::Pg is under development so changes in the API
are possible, though not likely.
=head1 SYNOPSIS
use Dancer2::Session::Pg ();
my $engine = Dancer2::Session::Pg->new(
dsn => 'dbi:Pg:dbname=app;host=db',
dbuser => 'app_web',
dbpass => $ENV{'APP_DB_PASSWORD'},
dbtable => 'sessions', # required
dbschema => 'web', # optional; else search_path
session_duration => 900,
# One or more SLOTS, each pairing a key with the cipher that uses it.
# Exactly one is active: that is the one sessions are written with, and
# the rest stay to be read. See SECURITY for where the key comes from.
encryption_keys => {
0 => {
key => $ENV{'SESSION_KEY_0'},
alg => 'AES-256-GCM',
active => 1,
},
},
# Optional; see THE PRINCIPAL COLUMN for whether you want it at all.
principal_key => 'principal',
principal_column => 'account_id',
);
Most applications configure this from F<config.yml> rather than in Perl -- see
L<Dancer2::Session::Pg/A configuration file>. Installing the engine by hand is
for when C<dbh> has to
be a coderef, something YAML cannot express, and there is a trap in doing it
which L<Dancer2::Session::Pg/CONNECTIONS> describes.
=head1 DESCRIPTION
Stores Dancer2 sessions in PostgreSQL, and uses PostgreSQL's own features to
make that storage safer than a serialised blob in a table.
A web session is not ordinary data. It frequently carries the credentials that
prove who somebody is -- with OpenID Connect, an access token and a refresh
token -- so the store is worth more than the account it belongs to. Three
properties follow from that, and each is provided by the database rather than by
convention:
=over 4
=item Authenticated encryption at rest
The payload is encrypted with an AEAD cipher, so a dump, a backup or a support
copy of the table does not hand over the contents of a session, and a row that
has been altered fails to decrypt instead of deserialising into a structure the
application would then trust.
Nor does it hand over a way in. B<The session id is stored as a SHA-256 digest,
not verbatim>, because the id is the session cookie: a table full of raw ids
would be a table full of working credentials, usable against the live
application by anyone who read a backup, no key required. What a dump contains
is digests, which open nothing.
The id is also authenticated with the payload, so a sealed payload opens only
under the session it was written for and cannot be moved from one row to
another.
L<Dancer2::Session::Pg/SECURITY> says what that stops, where the key should
live, and when to rotate
it.
Which cipher is a property of the key it is used with, and both are
B<replaceable>: every payload records the key and the cipher that sealed it, so
a cipher found wanting next year is three deployments rather than a forced
logout. See L<Dancer2::Session::Pg/THE STORED PAYLOAD>,
L<Dancer2::Session::Pg/Rotating the key> and
L<Dancer2::Session::Pg::Cipher>.
=item Expiry decided by the server's clock
C<expires> is a C<timestamptz> and every read filters on it. Application clocks
drift; the database's clock is the one every process shares, so all of them
agree about whether a session is still alive.
The expiry is set when the row is created and B<is not moved by later writes>.
C<session_duration> is therefore an absolute cap measured from creation, which is
what L<Dancer2::Core::Role::SessionFactory> describes: a limit on session
validity, regardless of the cookie. An idle timeout is a different thing and is
the cookie's job -- see C<cookie_duration>, which slides.
This matters more than it sounds. A cap that every request pushes further away is
never reached by a session in continuous use, and a session in continuous use is
what somebody holding stolen cookies has.
=item Atomic writes
Sessions are written with C<INSERT ... ON CONFLICT DO UPDATE>, which is atomic.
Any number of workers may write one session id concurrently without producing a
duplicate row, a unique violation or a deadlock, and without moving the expiry
cap.
That is a guarantee about database integrity, not about every write succeeding:
concurrent writers to one row serialise on its lock, and a waiter that exceeds
C<statement_timeout> is cancelled on purpose rather than holding a worker. See
L<Dancer2::Session::Pg/A blocked write fails rather than waiting>.
It does B<not> mean two workers cannot lose each other's changes. The payload is
one encrypted blob, so a write replaces all of it and the last writer wins. See
L<Dancer2::Session::Pg/CONCURRENCY>, which says exactly what is and is not
promised, and is backed by
a test rather than by this paragraph.
=back
On top of that, an B<optional> clear column beside the encrypted payload makes it
possible to find and end every session belonging to one account without
decrypting anything -- see L<Dancer2::Session::Pg/destroy_for_principal>.
Suspending an account has
little effect while the suspended user's cookie still works. That column is off
by default and need not exist; L<Dancer2::Session::Pg/THE PRINCIPAL COLUMN> is
about whether you want
lib/Dancer2/Session/Pg.pm view on Meta::CPAN
is for the case where the session store is the most security-sensitive table in
the database, and you would rather the database enforced that than your
application remembered to.
=head1 REQUIREMENTS
PostgreSQL B<9.5> or later, for C<INSERT ... ON CONFLICT DO UPDATE> -- see
L<Dancer2::Session::Pg/Why this is PostgreSQL and not portable SQL> for why
that statement and not
the standard C<MERGE>.
Perl B<v5.14> or later (Dancer2's required Perl as per L<Dancer2> B<v1.0.0>).
=head1 THE TABLE
Create it yourself. The DDL below is a starting point, not a mandate: schema
name, ownership, tablespace, index names, whether C<IF NOT EXISTS> suits your
deployment and whether the table joins an existing migration scheme are local
decisions this module has no business making.
Three things are actually required: the column B<names> and B<types> of the
columns this module writes, C<id> unique so C<ON CONFLICT (id)> has an arbiter,
and privilege to C<SELECT>, C<INSERT>, C<UPDATE> and C<DELETE>.
Put the result under whatever migration scheme you already use. A session table
that exists only in somebody's shell history disappears the next time the schema
is rebuilt, and the symptom at the far end is a login that never completes.
There are three variants below, and B<the second is the smallest thing that
works>. Start there unless you know you want the principal column.
=head2 DDL
With the optional principal column, as free text -- the general case, where the
identifier in the session is not a key of any table in this database (a
federated C<sub> claim, a tenant-scoped id, an opaque token):
CREATE TABLE web.sessions (
id text PRIMARY KEY, -- SHA-256 hex of the session id
principal_id text,
session_data bytea NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
updated timestamptz NOT NULL DEFAULT now(),
expires timestamptz
);
COMMENT ON TABLE web.sessions IS
'Dancer2 session store (Dancer2::Session::Pg). Rows hold authenticated-encrypted session payloads; treat as credential material.';
COMMENT ON COLUMN web.sessions.id IS
'SHA-256 of the Dancer2 session id -- NOT the id itself, which is the session cookie and would be replayable from a dump. Arbiter for ON CONFLICT.';
COMMENT ON COLUMN web.sessions.principal_id IS
'OPTIONAL -- drop this column if principal_key is not configured. Clear copy of the value named by principal_key, so sessions can be found and revoked without decrypting. May be free text as here, or a foreign key; see "THE PRINCIPAL COLUMN" ...
COMMENT ON COLUMN web.sessions.session_data IS
'AEAD-encrypted session payload: version || cipher id || key id || iv || tag || ciphertext. Unreadable without the application key, and tamper-evident.';
COMMENT ON COLUMN web.sessions.created IS
'When the row was first written. Server clock.';
COMMENT ON COLUMN web.sessions.updated IS
'When the row was last written. Server clock.';
COMMENT ON COLUMN web.sessions.expires IS
'When the session stops being valid. Every read filters on it. NULL means no expiry, which is rarely right for a session holding credentials.';
CREATE INDEX sessions_principal_id_idx
ON web.sessions (principal_id) WHERE principal_id IS NOT NULL;
COMMENT ON INDEX web.sessions_principal_id_idx IS
'OPTIONAL. Supports destroy_for_principal and sessions_for_principal. Partial: sessions without a principal id are not indexed.';
CREATE INDEX sessions_expires_idx
ON web.sessions (expires) WHERE expires IS NOT NULL;
COMMENT ON INDEX web.sessions_expires_idx IS
'Supports reap(). Partial: rows without an expiry are never reaped.';
=head2 DDL without the principal column
B<The smallest table this module can use.> Leave C<principal_key> unset and the
column is never written, never read and need not exist:
CREATE TABLE web.sessions (
id text PRIMARY KEY, -- SHA-256 hex of the session id
session_data bytea NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
updated timestamptz NOT NULL DEFAULT now(),
expires timestamptz
);
CREATE INDEX sessions_expires_idx
ON web.sessions (expires) WHERE expires IS NOT NULL;
=head2 DDL with the principal column as a foreign key
When the identifier in the session B<is> a key of a table in this same database,
the column can be a real foreign key rather than free text. Name it after what
it references -- C<principal_column> exists for that -- and let the database
keep it honest:
-- Your accounts table already exists. It is shown here only so that the
-- example below is complete and can be applied as it stands.
CREATE TABLE web.accounts (
id bigint PRIMARY KEY,
email text NOT NULL
);
CREATE TABLE web.sessions (
id text PRIMARY KEY, -- SHA-256 hex of the session id
account_id bigint REFERENCES web.accounts(id) ON DELETE CASCADE,
session_data bytea NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
updated timestamptz NOT NULL DEFAULT now(),
expires timestamptz
);
COMMENT ON COLUMN web.sessions.account_id IS
'OPTIONAL. Foreign key to web.accounts(id), written by Dancer2::Session::Pg from the session key named by principal_key. ON DELETE CASCADE: deleting an account ends its sessions in the same statement.';
CREATE INDEX sessions_account_id_idx
ON web.sessions (account_id) WHERE account_id IS NOT NULL;
CREATE INDEX sessions_expires_idx
ON web.sessions (expires) WHERE expires IS NOT NULL;
=head2 Choosing a foreign key
lib/Dancer2/Session/Pg.pm view on Meta::CPAN
excluded from the conflict update, so after any number of concurrent writes
C<expires> is still exactly C<created> plus C<session_duration>. This is the
invariant worth the most: a race that moved the cap would hand an attacker with
stolen cookies a session that never expires.
=item *
B<The payload is never torn.> A reader sees one writer's complete, decryptable
payload. It cannot see half of one write and half of another, because the blob is
written as a single value in a single statement.
=back
=head2 What is NOT promised
B<Two workers can lose each other's changes, and will.> The session is one
encrypted blob, so a write replaces the whole of it:
worker A worker B
read { cart => [apple] }
read { cart => [apple] }
write { cart => [apple, pear] }
write { cart => [apple], step => address }
# the result holds B's step and has lost A's pear
Last writer wins, outright. That is not a defect of this module -- it is what any
session store holding a serialised blob does, including
L<Dancer2::Session::Simple> and L<Dancer2::Session::YAML> -- but it is worth
knowing before putting something in the session that two concurrent requests
might both change. A counter, a running total or a list that several requests
append to belongs in a table of its own, where the database can do the arithmetic.
In practice this bites hardest with parallel XHR from one page.
B<Which worker wins is a race>, so nothing should depend on it.
=head2 A blocked write fails rather than waiting
A write that cannot proceed -- because some other transaction holds that session's
row and has not committed -- is cancelled by C<statement_timeout> and the request
fails. It does not wait for the commit. That is deliberate: a session is not worth
waiting on, and a worker blocked indefinitely is worse than a failed request. The
usual causes are outside the application: an administrative query, a migration, a
pool connection somebody left in a transaction.
The failure is transient and leaves nothing behind; once the other transaction
ends, the same session id writes normally. This is tested too.
Note that this applies only to a connection B<this module opened>. On a handle you
supplied, C<statement_timeout> is yours to set -- see L</SHARING A HANDLE> -- and
if you have not set one, a blocked session write waits as long as the database
makes it wait.
=head1 SECURITY
=head2 The threat this module is built for
Somebody reads the database and does not have the application. A backup, a
replica, a support copy of a table, a dump in a ticket, a stolen disk. The
payload is encrypted with an AEAD cipher, so what they get is the shape of your
session traffic and not the credentials in it.
B<It is not built for an attacker who has the application's memory or its
configuration.> The key is in the process, so anyone who can read the process or
the file the key came from can read every session. Encryption at rest moves the
secret from the database to the key store; it does not remove it.
=head2 Why the session id is stored as a digest
Encrypting the payload would be half a job. B<The session id is itself a bearer
token> -- L<Dancer2> reads it from the cookie and this module hands back
whatever it unlocks -- so a table storing ids verbatim would contain a working
credential for every unexpired session. Someone who read a backup could replay
any of them against a reachable instance of the application and be logged in as
that user, B<without the encryption key and without decrypting anything>. The
most carefully sealed payload in the world does not help if the key to the front
door is in the same dump.
So the C<id> column holds C<SHA-256> of the session id. The cookie is unchanged,
every lookup hashes first, and what a dump yields is digests.
The digest is B<unkeyed>, on purpose. A keyed digest would additionally stop an
attacker confirming a guessed id, but Dancer2's ids are long and random, which is
the same reason an API token needs no salt. More importantly a key here could
never be rotated -- rotating it would orphan every live session -- and the whole
design of L</Rotating the key> is that keys rotate. A key that cannot rotate,
sitting among keys that must, would be a worse trap than the attack it prevents.
Two consequences worth knowing:
=over 4
=item *
C<_sessions> returns what is in the column, so the values it hands back are
B<digests and not session ids>. They are useful for counting and for nothing
else; you cannot turn one back into a cookie, which is the point.
=item *
The column is 64 hex characters rather than Dancer2's id, so size your index
accordingly if you are tuning.
=back
=head2 Where the key should live
B<Not in a configuration file you commit.> A key in F<config.yml> is a key in
your version control history permanently, readable by everyone who can clone the
repository and by every CI job that ever checked it out -- and rotating it then
means rotating something that is still in the history.
Keep the key in the environment and have the configuration B<refer> to it.
L<Dancer2::ConfigReader::Config::Any> describes the mechanism in its own
C<DESCRIPTION>: a config reader that extends it and expands C<${ENV:NAME}>
placeholders as the configuration is read. The configuration then names the
variable and never holds the value:
# config.yml -- committed, and contains no secret
engines:
session:
lib/Dancer2/Session/Pg.pm view on Meta::CPAN
that does not decrypt. It is tested, in F<t/cipher.t> and against a real
database in F<t/session_pg.t>.
The cost is that renaming a row is no longer a rename: L</destroy_for_principal>
is unaffected, but session-fixation protection -- C<change_id> on login -- has to
re-seal the payload under the new id, which is one extra round trip per login.
A cipher of your own must pass the additional data through. L<Dancer2::Session::Pg::Cipher>
tests that it does, because a cipher that accepts it and silently drops it would
remove this protection while everything appeared to work.
=head2 What the clear column discloses
If you use the principal column, understand what a database dump then contains:
B<which accounts had sessions, how many, and when they were created and last
used.> That is the trade. The ability to revoke an account's sessions without
decrypting anything is the same property as the ability to read that pattern off
a backup.
Nothing else leaves the payload. If even that pattern is too much, leave
C<principal_key> unset and revoke by expiry.
=head2 Configuration is trusted input
A slot's C<alg> and C<json_module> name a class that this module loads. A deployment that
lets an untrusted party influence its session configuration has already lost, but
to be explicit: these are not safe to take from a request, a database row, or a
file anybody else can write.
=head2 What is logged, and why it is so little
B<The session id is a bearer token.> L<Dancer2> takes it straight from the
session cookie, so an id written to a log is a live credential written to a log,
and read access to that log becomes session hijacking. The principal is out for
the same reason and for a second one: it names a person.
So this module logs B<nothing per session> -- no ids on a retrieve, a write, a
destroy, or a failure to decrypt. What it does log is per process and per
configuration, each message once, through the engine's C<log_cb>:
warning a stored session could not be read, and which of the
reasons it was. No id, so a run of these means "the key
changed" or "somebody is poking at cookies" without
saying whose session.
warning principal_key produced a reference rather than a value,
so the principal column was left NULL and
destroy_for_principal will not find those sessions. The
value is NOT logged; it is session content.
warning a supplied dbh has AutoCommit off, which is also carped
so that it reaches an engine built outside Dancer2.
info the connection had gone away and was reopened. One after
a database restart is the system working; a stream of
them is something killing connections.
Each is reported once per engine rather than once per request, because anything
on the request path otherwise produces a log nobody can read -- and because a
repeated message is how an attacker poking at cookies fills your disk.
If you want per-session audit logging, do it in the application, where you have
the request, the user and a policy about retention. A session store is the wrong
layer to decide that credentials belong in a log file.
=head1 WHAT AN UNREADABLE ROW DOES
A row that cannot be decrypted -- wrong key, a cipher this engine cannot read, a
payload somebody has edited -- is treated as B<a session that does not exist>.
Dancer2 then starts a fresh one, so the user is logged out rather than shown an
error. That is deliberate: the alternative is handing the application a structure
whose integrity has not been established.
It is also reported, once per engine per distinct reason, through the engine's
C<log_cb>. Fail-closed without a trace is how a changed key in C<encryption_keys> becomes
"users keep getting logged out" with nothing written down anywhere; once per
reason rather than once per request is so the log stays readable when somebody is
poking at cookies.
=head1 LIMITATIONS
No cross-process invalidation. A logout deletes the row, so another process sees
it gone on its next read, but there is no push: PostgreSQL C<LISTEN>/C<NOTIFY>
would allow one, and C<_destroy> and C<_change_id> are where it would be emitted.
Key rotation needs three deployments and some patience, but it costs no
sessions: see L</Rotating the key>. The old key has to stay in C<encryption_keys>
until everything written under it has expired, so the whole exercise takes
longer than C<session_duration> from start to finish, and there is no way to
shorten that without logging those sessions out.
Changing the key B<length>, or the cipher, works the same way and in the same
step; see L</Changing the key length> and L</Replacing a cipher>.
The test suite needs a PostgreSQL cluster the test user may create databases on.
Where there is none it skips, which means a smoke-test report of "pass" from such
a machine has exercised the unit tests only.
=head1 SEE ALSO
=over 4
=item * L<Dancer2::Session::Pg::Cipher> -- the cipher contract, if you are writing one
=item * L<Dancer2::Session::Pg::Cipher::AESGCM>
=item * L<Dancer2::Session::Pg::Cipher::ChaCha20Poly1305>
=item * L<Dancer2::Core::Role::SessionFactory>
=item * L<Dancer2::Session::DBI>
=item * L<Dancer2::Plugin::Database>
=item * L<Dancer2::ConfigReader::Config::Any> -- how to keep the key out of the config file
=item * L<CryptX>
=back
=begin Pod::Coverage
BUILD
FORMAT_VERSION
( run in 1.234 second using v1.01-cache-2.11-cpan-3f962061493 )