Add inactivity-based session timeout (closes #66)
All checks were successful
check / check (push) Successful in 3m6s

Sessions had only a 7-day absolute lifetime, and that cap was enforced
only by the cookie's MaxAge -- i.e. only by the browser. An abandoned
session stayed usable for the full week.

Sessions are now bounded by two independent, server-enforced clocks,
and end at whichever expires first:

- absolute: created_at + 7 days, stamped once by SetUser and never
  rewritten, so no amount of activity can extend it
- idle: last_seen + SESSION_IDLE_TIMEOUT (default 24h), pushed forward
  by the new Session.Touch

Both deadlines are checked in Session.expired, which IsAuthenticated
now consults, so every existing authentication decision honours them
without each call site having to remember.

Activity means a request that passes RequireAuth, which is the only
place Touch is called; an unauthenticated request carrying the cookie
cannot keep a session alive. Touch re-checks authentication itself so
that guarantee does not depend on the call site.

To avoid re-issuing the session cookie on every authenticated request,
Touch rewrites last_seen only once it is older than a tenth of the idle
window. The session therefore expires up to 10% early relative to the
user's true last request, never late.

An authenticated session carrying no timestamps (a cookie minted before
this change) is treated as expired, so the failure mode of the upgrade
is one forced re-login rather than an unbounded session.

Tests use an injected clock rather than sleeps and cover idle expiry,
refresh on activity, an actively used session still dying at the
absolute cap, refusal to refresh unauthenticated or expired sessions,
disabled idle expiry, and startup aborting on an unparseable
SESSION_IDLE_TIMEOUT.
This commit is contained in:
2026-08-09 05:59:54 +00:00
parent 4f5ecb18e5
commit b04bc2cc7b
9 changed files with 881 additions and 22 deletions

View File

@@ -92,6 +92,27 @@ TTY detection, and security headers are always applied.
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
Sessions are bounded by two independent clocks, and end at whichever
one runs out first:
- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding
window. Every authenticated request pushes it forward, so a session
in continuous use never hits it, while an abandoned one expires a day
after its last use. Set it to `0` to disable idle expiry entirely;
the absolute cap below still applies. A set-but-unparseable value
aborts startup rather than silently falling back to the default.
- **Absolute expiry** is a fixed 7 days from login. Activity does
**not** extend it: after a week, every session ends and the user
authenticates again.
Only requests that authenticate with the session count as activity, so
an unauthenticated request carrying the cookie cannot keep a session
alive. The idle timestamp is rewritten at most once per tenth of the
idle window rather than on every request, which means a session may
expire up to 10% early relative to the user's true last request, but
never late.
On first startup, webhooker automatically generates a cryptographically
secure session encryption key and stores it in the database. This key