Log out with a form and count logouts instead of timing them (part of #256)

Logging out is now a POST in the CSRF-protected route group, sent by a
form on the generator page, so a link or image on another page of the
same site cannot end every login session. The session manager keeps a
count of logouts instead of the time of the latest one: each session
carries the count at its login and is refused once it has changed. No
clock is compared, so a login after a logout always works.

Model: opus-5-5
This commit is contained in:
2026-10-15 18:15:46 +00:00
committed by clawbot
parent 42f6b23244
commit a33cc6861b
8 changed files with 93 additions and 69 deletions
+18 -14
View File
@@ -71,10 +71,10 @@ The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
database and the disk cache: database and the disk cache:
- It needs a persistent volume: without one, every restart starts with an empty - It needs a persistent volume: without one, every restart starts with an empty
cache and forgets the latest logout, so the login sessions that logout ended cache and forgets the count of logouts, so login sessions that a logout ended
work again (see Encrypted URLs). In the container, the startup script gives can work again (see Encrypted URLs). In the container, the startup script
the directory to the user pixa runs as (uid 65532) and sets its mode to `750`; gives the directory to the user pixa runs as (uid 65532) and sets its mode to
outside it, that user must be able to write the directory. `750`; outside it, that user must be able to write the directory.
- `cache_max_bytes` limits the source and transformed images together. The - `cache_max_bytes` limits the source and transformed images together. The
database, the metadata files, the `.meta` file beside each transformed image database, the metadata files, the `.meta` file beside each transformed image
and files still being written come on top, and eviction runs in the and files still being written come on top, and eviction runs in the
@@ -197,10 +197,11 @@ path under `/v1/` answers 200, in maintenance mode too.
session it answers 303 to `/`. Answers: 200 with the page showing the URL; 400 session it answers 303 to `/`. Answers: 200 with the page showing the URL; 400
with the page naming a field that is not valid; 500 when the URL cannot be with the page naming a field that is not valid; 500 when the URL cannot be
made. made.
- `GET /logout` — end every login session, in every browser (see Encrypted - `POST /logout` — log out with the generator page's Logout button: end every
URLs), and remove the session cookie. Needs: a login session; without one it login session, in every browser (see Encrypted URLs), and remove the session
only removes the cookie. Answers: 303 to `/`; 500 when the logout cannot be cookie. Needs: a login session and the generator page's form (below); without
saved in the database. a login session it only removes the cookie. Answers: 303 to `/`; 500 when the
logout cannot be saved in the database.
- `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or - `GET` or `HEAD` `/v1/image/<host>/<path>/<size>.<format>`, or
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized `/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
and converted (below). Needs: a signature, unless the host is allowlisted (see and converted (below). Needs: a signature, unless the host is allowlisted (see
@@ -248,7 +249,7 @@ the request carries the same ID as `request_id`, and so do the lines it logs
when it fetches, converts and serves an image; the fetch sends it to the when it fetches, converts and serves an image; the fetch sends it to the
upstream host as `X-Request-ID`. upstream host as `X-Request-ID`.
Both `POST` routes accept only a form that pixa's own page served: the page puts The `POST` routes accept only a form that pixa's own page served: the page puts
a token in the form and sets a cookie to match, and a request without both is a token in the form and sets a cookie to match, and a request without both is
refused with 403, so another site cannot submit the form from a visitor's refused with 403, so another site cannot submit the form from a visitor's
browser. The login and generator pages are meant to be opened over HTTPS: while browser. The login and generator pages are meant to be opened over HTTPS: while
@@ -386,11 +387,14 @@ nor change what it asks for.
1. Open `/` in a browser over HTTPS (or over plain HTTP while `debug` is on, see 1. Open `/` in a browser over HTTPS (or over plain HTTP while `debug` is on, see
Routes) and log in with the signing key (`signing_key`). The login session Routes) and log in with the signing key (`signing_key`). The login session
lasts 30 days, or until someone logs out at `/logout`. Every login is made lasts 30 days, or until someone logs out with the generator page's Logout
with the one signing key, so logging out ends every login session, in every button. Every login is made with the one signing key, so logging out ends
browser: pixa saves the time of the latest logout in its database and refuses every login session, in every browser, a copy of the session cookie included.
a login session made before it, a copy of the session cookie included, also pixa keeps a count of logouts in its database: each logout adds one, a login
after a restart that keeps the state directory (see Deployment). session carries the count at its login, and a login session whose count is
not the current one is refused, also after a restart that keeps the state
directory (see Deployment). No time is compared, so a login after a logout
works whatever the host's clock says.
2. On the generator page, give the source image's URL, the width and height, the 2. On the generator page, give the source image's URL, the width and height, the
format, quality and fit, and how long the URL lasts, then submit the form format, quality and fit, and how long the URL lasts, then submit the form
(`POST /generate`). The format is JPEG XL unless another is chosen; a form (`POST /generate`). The format is JPEG XL unless another is chosen; a form
+16 -12
View File
@@ -76,6 +76,22 @@ P2: security: per-origin rate limiting
405, or a CORS preflight. The metrics library takes the label before the 405, or a CORS preflight. The metrics library takes the label before the
request is routed, so the metrics middleware asks the router which route request is routed, so the metrics middleware asks the router which route
matches. matches.
- 2026-10-15 logging out ends every login session (closes #256): a login session
was a cookie with nothing kept on the server, so `/logout` removed it from one
browser and a copy of it kept working for its 30 days. Every login is made
with the one signing key, so logging out now ends them all: the session
manager counts logouts, saves the count in the new `logout` table so that it
outlasts a restart, puts the count at login in each session, and refuses a
session whose count is not the current one. No time is compared, so a login
after a logout works whatever the host's clock does. A restart without a
persistent volume for the state directory forgets the count. Logging out is
now `POST /logout` from a form on the generator page, behind the CSRF check
the other forms have, so a link or image elsewhere on the same site cannot end
the sessions. A logout without a valid login session ends none and only
removes the cookie, as anyone can take a CSRF token from the login page. A
logout that cannot be saved answers 500. `session.NewManager`, which the tests
use, keeps the count in memory only; pixad uses
`session.NewManagerWithDatabase`.
- 2026-10-15 expired negative cache rows are removed on a schedule (closes - 2026-10-15 expired negative cache rows are removed on a schedule (closes
#244): the row of a failed fetch, which makes pixa answer 502 for that source #244): the row of a failed fetch, which makes pixa answer 502 for that source
URL for 5 minutes, was deleted only when the same URL was requested again URL for 5 minutes, was deleted only when the same URL was requested again
@@ -116,18 +132,6 @@ P2: security: per-origin rate limiting
and the query now have each `%` written as `%25` and each `:` as `%3A`; a path and the query now have each `%` written as `%25` and each `:` as `%3A`; a path
and query holding neither are signed and keyed as before. Signature and query holding neither are signed and keyed as before. Signature
Specification in `README.md` says so. Specification in `README.md` says so.
- 2026-10-15 logging out ends every login session (closes #256): a login session
was a cookie with nothing kept on the server, so `/logout` removed it from one
browser and a copy of it kept working for its 30 days. Every login is made
with the one signing key, so `/logout` now ends them all: the session manager
keeps the time of the latest logout, saves it in the new `logout` table so
that it outlasts a restart, and refuses a session made before it. A restart
without a persistent volume for the state directory forgets it. A `/logout`
without a valid login session, such as one another site makes a browser send
without the `SameSite=Strict` cookie, ends none and only removes the cookie. A
logout that cannot be saved answers 500. `session.NewManager`, which the tests
use, keeps logouts in memory only; pixad uses
`session.NewManagerWithDatabase`.
- 2026-10-15 the example config allowlists no host (closes #249): it allowlisted - 2026-10-15 the example config allowlists no host (closes #249): it allowlisted
`github.com` and `user-images.githubusercontent.com`, where anyone can put an `github.com` and `user-images.githubusercontent.com`, where anyone can put an
image, so a deployment set up as Getting Started says served those images to image, so a deployment set up as Getting Started says served those images to
+5 -3
View File
@@ -177,9 +177,11 @@ CREATE TABLE IF NOT EXISTS cache_stats (
); );
INSERT OR IGNORE INTO cache_stats (id) VALUES (1); INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
-- Time of the latest logout. A login session made before it is refused, -- Count of logouts. Each logout adds one; a login session carries the count
-- so logging out ends every login session. No row: nobody has logged out. -- at its login and is refused once the count has changed, so logging out ends
-- every login session.
CREATE TABLE IF NOT EXISTS logout ( CREATE TABLE IF NOT EXISTS logout (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
logged_out_at DATETIME NOT NULL logout_count INTEGER NOT NULL DEFAULT 0
); );
INSERT OR IGNORE INTO logout (id) VALUES (1);
+2 -1
View File
@@ -164,7 +164,8 @@ func (s *Handlers) initImageService() error {
// Initialize session manager (signing key is validated at config load // Initialize session manager (signing key is validated at config load
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict. // time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
// Logouts are saved in the database, so they outlast a restart. // The count of logouts is saved in the database, so it outlasts a
// restart.
sessMgr, err := session.NewManagerWithDatabase( sessMgr, err := session.NewManagerWithDatabase(
context.Background(), s.config.SigningKey, s.db.DB()) context.Background(), s.config.SigningKey, s.db.DB())
if err != nil { if err != nil {
+1 -2
View File
@@ -69,10 +69,9 @@ func (s *Server) SetupRoutes() {
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)). r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)).
Post("/", s.h.HandleRoot()) Post("/", s.h.HandleRoot())
r.Post("/generate", s.h.HandleGenerateURL()) r.Post("/generate", s.h.HandleGenerateURL())
r.Post("/logout", s.h.HandleLogout())
}) })
s.router.Get("/logout", s.h.HandleLogout())
// Image routes, the only ones that send CORS headers, as pages on other // Image routes, the only ones that send CORS headers, as pages on other
// sites read them. They are a subrouter rather than a group: a group's // sites read them. They are a subrouter rather than a group: a group's
// middleware runs only for a request that matches one of its routes, // middleware runs only for a request that matches one of its routes,
+35 -32
View File
@@ -1,6 +1,6 @@
// Package session provides encrypted session cookie management, and keeps the // Package session provides encrypted session cookie management, and keeps a
// time of the latest logout in the database so that a logout ends every // count of logouts in the database so that a logout ends every session made
// session made before it. // before it.
package session package session
import ( import (
@@ -38,28 +38,34 @@ type Data struct {
Authenticated bool `json:"auth"` Authenticated bool `json:"auth"`
CreatedAt time.Time `json:"created"` CreatedAt time.Time `json:"created"`
ExpiresAt time.Time `json:"expires"` ExpiresAt time.Time `json:"expires"`
// LogoutCount is the count of logouts when the session was made.
LogoutCount int64 `json:"logoutCount"`
} }
// Manager handles session creation and validation using encrypted cookies. // Manager handles session creation and validation using encrypted cookies.
// //
// Every login is made with the one signing key, so a logout ends every // Every login is made with the one signing key, so a logout ends every
// session made before it, in every browser. The Manager keeps the time of // session made before it, in every browser. The Manager counts logouts: each
// the latest logout in memory, so checking a session reads no database; a // logout adds one, a session carries the count at its login, and a session
// whose count is not the current one is refused. No clock is compared, so a
// login after a logout works whatever the host's clock does. The Manager
// keeps the count in memory, so checking a session reads no database; a
// Manager from NewManagerWithDatabase also saves it in the database, so that // Manager from NewManagerWithDatabase also saves it in the database, so that
// it outlasts a restart. // it outlasts a restart.
type Manager struct { type Manager struct {
sc *securecookie.SecureCookie sc *securecookie.SecureCookie
db *sql.DB db *sql.DB
// mu guards latestLogout, and is held while a logout is saved, so that // mu guards logoutCount, and is held while a logout is saved, so that
// the database and latestLogout agree. // the database and logoutCount agree.
mu sync.Mutex mu sync.Mutex
latestLogout time.Time logoutCount int64
} }
// NewManager creates a session manager with keys derived from the signing key. // NewManager creates a session manager with keys derived from the signing key.
// It keeps logouts in memory only, so a restart forgets them; // It keeps the count of logouts in memory only, so a restart forgets it;
// NewManagerWithDatabase keeps them in the database. // NewManagerWithDatabase keeps it in the database.
// //
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict // Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
// attributes; this cannot be configured. Browsers treat http://localhost as a // attributes; this cannot be configured. Browsers treat http://localhost as a
@@ -88,8 +94,8 @@ func NewManager(signingKey string) (*Manager, error) {
} }
// NewManagerWithDatabase creates a session manager like NewManager that saves // NewManagerWithDatabase creates a session manager like NewManager that saves
// each logout in db, which must have pixa's schema, and starts from the // the count of logouts in db, which must have pixa's schema, and starts from
// latest logout saved there. // the count saved there.
func NewManagerWithDatabase( func NewManagerWithDatabase(
ctx context.Context, signingKey string, db *sql.DB, ctx context.Context, signingKey string, db *sql.DB,
) (*Manager, error) { ) (*Manager, error) {
@@ -99,10 +105,10 @@ func NewManagerWithDatabase(
} }
err = db.QueryRowContext(ctx, err = db.QueryRowContext(ctx,
"SELECT logged_out_at FROM logout WHERE id = 1", "SELECT logout_count FROM logout WHERE id = 1",
).Scan(&m.latestLogout) ).Scan(&m.logoutCount)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil {
return nil, fmt.Errorf("failed to read the latest logout: %w", err) return nil, fmt.Errorf("failed to read the count of logouts: %w", err)
} }
m.db = db m.db = db
@@ -117,6 +123,7 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
Authenticated: true, Authenticated: true,
CreatedAt: now, CreatedAt: now,
ExpiresAt: now.Add(SessionTTL), ExpiresAt: now.Add(SessionTTL),
LogoutCount: m.currentLogoutCount(),
} }
encoded, err := m.sc.Encode(CookieName, data) encoded, err := m.sc.Encode(CookieName, data)
@@ -165,8 +172,8 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
return nil, ErrInvalidSession return nil, ErrInvalidSession
} }
// A session made before the latest logout has ended. // A logout since the session was made has ended it.
if !data.CreatedAt.After(m.latestLogoutTime()) { if data.LogoutCount != m.currentLogoutCount() {
return nil, ErrInvalidSession return nil, ErrInvalidSession
} }
@@ -174,9 +181,8 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
} }
// Logout removes the session cookie and, when r has a valid session, ends // Logout removes the session cookie and, when r has a valid session, ends
// every session made until now. A request without one ends no session: // every session made until now. A request without one ends no session, so
// another site can make a browser request /logout, but the browser leaves // someone who has not logged in cannot end the sessions of those who have.
// the SameSite=Strict session cookie out of that request.
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error { func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
if m.IsAuthenticated(r) { if m.IsAuthenticated(r) {
err := m.saveLogout(r.Context()) err := m.saveLogout(r.Context())
@@ -211,33 +217,30 @@ func (m *Manager) IsAuthenticated(r *http.Request) bool {
return err == nil && data != nil && data.Authenticated return err == nil && data != nil && data.Authenticated
} }
// saveLogout makes now the time of the latest logout, saving it in the // saveLogout adds one to the count of logouts, saving the new count in the
// database first when the Manager has one. // database first when the Manager has one.
func (m *Manager) saveLogout(ctx context.Context) error { func (m *Manager) saveLogout(ctx context.Context) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
now := time.Now().UTC()
if m.db != nil { if m.db != nil {
_, err := m.db.ExecContext(ctx, _, err := m.db.ExecContext(ctx,
"INSERT OR REPLACE INTO logout (id, logged_out_at) VALUES (1, ?)", "UPDATE logout SET logout_count = ? WHERE id = 1",
now) m.logoutCount+1)
if err != nil { if err != nil {
return fmt.Errorf("failed to save the logout: %w", err) return fmt.Errorf("failed to save the logout: %w", err)
} }
} }
m.latestLogout = now m.logoutCount++
return nil return nil
} }
// latestLogoutTime returns the time of the latest logout, or the zero time // currentLogoutCount returns the count of logouts.
// when there has been none. func (m *Manager) currentLogoutCount() int64 {
func (m *Manager) latestLogoutTime() time.Time {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
return m.latestLogout return m.logoutCount
} }
+10 -2
View File
@@ -122,12 +122,20 @@ header {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
header a { /* The logout button, drawn as a link. */
header button {
width: auto;
margin: 0;
padding: 0;
background: none;
font-size: 0.875rem; font-size: 0.875rem;
color: #4b5563; color: #4b5563;
text-decoration: underline;
} }
header a:hover { header button:hover {
background: none;
color: #1f2937; color: #1f2937;
} }
+6 -3
View File
@@ -10,9 +10,12 @@
<div class="page"> <div class="page">
<header> <header>
<h1>Pixa URL Generator</h1> <h1>Pixa URL Generator</h1>
<a href="/logout"> <form method="POST" action="/logout">
Logout {{ .CSRFField }}
</a> <button type="submit">
Logout
</button>
</form>
</header> </header>
{{if .GeneratedURL}} {{if .GeneratedURL}}