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 16:33:09 +00:00
parent a7aa967890
commit 48827f0eb5
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:
- 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
work again (see Encrypted URLs). In the container, the startup script gives
the directory to the user pixa runs as (uid 65532) and sets its mode to `750`;
outside it, that user must be able to write the directory.
cache and forgets the count of logouts, so login sessions that a logout ended
can work again (see Encrypted URLs). In the container, the startup script
gives the directory to the user pixa runs as (uid 65532) and sets its mode to
`750`; outside it, that user must be able to write the directory.
- `cache_max_bytes` limits the source and transformed images together. The
database, the metadata files, the `.meta` file beside each transformed image
and files still being written come on top, and eviction runs in the
@@ -189,10 +189,11 @@ path under `/v1/` answers 200, in maintenance mode too.
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
made.
- `GET /logout` — end every login session, in every browser (see Encrypted
URLs), and remove the session cookie. Needs: a login session; without one it
only removes the cookie. Answers: 303 to `/`; 500 when the logout cannot be
saved in the database.
- `POST /logout` — log out with the generator page's Logout button: end every
login session, in every browser (see Encrypted URLs), and remove the session
cookie. Needs: a login session and the generator page's form (below); without
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
`/v1/image/<host>/<path>/<size>` with no format — an image, fetched, resized
and converted (below). Needs: a signature, unless the host is allowlisted (see
@@ -240,7 +241,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
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
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
@@ -361,11 +362,14 @@ nor change what it asks for.
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
lasts 30 days, or until someone logs out at `/logout`. Every login is made
with the one signing key, so logging out ends every login session, in every
browser: pixa saves the time of the latest logout in its database and refuses
a login session made before it, a copy of the session cookie included, also
after a restart that keeps the state directory (see Deployment).
lasts 30 days, or until someone logs out with the generator page's Logout
button. Every login is made with the one signing key, so logging out ends
every login session, in every browser, a copy of the session cookie included.
pixa keeps a count of logouts in its database: each logout adds one, a login
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
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
+16 -12
View File
@@ -46,6 +46,22 @@ P2: security: per-origin rate limiting
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
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
#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
@@ -86,18 +102,6 @@ P2: security: per-origin rate limiting
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
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
`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
+5 -3
View File
@@ -177,9 +177,11 @@ CREATE TABLE IF NOT EXISTS cache_stats (
);
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
-- Time of the latest logout. A login session made before it is refused,
-- so logging out ends every login session. No row: nobody has logged out.
-- Count of logouts. Each logout adds one; a login session carries the count
-- at its login and is refused once the count has changed, so logging out ends
-- every login session.
CREATE TABLE IF NOT EXISTS logout (
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
// 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(
context.Background(), s.config.SigningKey, s.db.DB())
if err != nil {
+1 -2
View File
@@ -69,10 +69,9 @@ func (s *Server) SetupRoutes() {
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)).
Post("/", s.h.HandleRoot())
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
// 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,
+35 -32
View File
@@ -1,6 +1,6 @@
// Package session provides encrypted session cookie management, and keeps the
// time of the latest logout in the database so that a logout ends every
// session made before it.
// Package session provides encrypted session cookie management, and keeps a
// count of logouts in the database so that a logout ends every session made
// before it.
package session
import (
@@ -38,28 +38,34 @@ type Data struct {
Authenticated bool `json:"auth"`
CreatedAt time.Time `json:"created"`
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.
//
// 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
// the latest logout in memory, so checking a session reads no database; a
// session made before it, in every browser. The Manager counts logouts: each
// 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
// it outlasts a restart.
type Manager struct {
sc *securecookie.SecureCookie
db *sql.DB
// mu guards latestLogout, and is held while a logout is saved, so that
// the database and latestLogout agree.
mu sync.Mutex
latestLogout time.Time
// mu guards logoutCount, and is held while a logout is saved, so that
// the database and logoutCount agree.
mu sync.Mutex
logoutCount int64
}
// NewManager creates a session manager with keys derived from the signing key.
// It keeps logouts in memory only, so a restart forgets them;
// NewManagerWithDatabase keeps them in the database.
// It keeps the count of logouts in memory only, so a restart forgets it;
// NewManagerWithDatabase keeps it in the database.
//
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
// 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
// each logout in db, which must have pixa's schema, and starts from the
// latest logout saved there.
// the count of logouts in db, which must have pixa's schema, and starts from
// the count saved there.
func NewManagerWithDatabase(
ctx context.Context, signingKey string, db *sql.DB,
) (*Manager, error) {
@@ -99,10 +105,10 @@ func NewManagerWithDatabase(
}
err = db.QueryRowContext(ctx,
"SELECT logged_out_at FROM logout WHERE id = 1",
).Scan(&m.latestLogout)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("failed to read the latest logout: %w", err)
"SELECT logout_count FROM logout WHERE id = 1",
).Scan(&m.logoutCount)
if err != nil {
return nil, fmt.Errorf("failed to read the count of logouts: %w", err)
}
m.db = db
@@ -117,6 +123,7 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
Authenticated: true,
CreatedAt: now,
ExpiresAt: now.Add(SessionTTL),
LogoutCount: m.currentLogoutCount(),
}
encoded, err := m.sc.Encode(CookieName, data)
@@ -165,8 +172,8 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
return nil, ErrInvalidSession
}
// A session made before the latest logout has ended.
if !data.CreatedAt.After(m.latestLogoutTime()) {
// A logout since the session was made has ended it.
if data.LogoutCount != m.currentLogoutCount() {
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
// every session made until now. A request without one ends no session:
// another site can make a browser request /logout, but the browser leaves
// the SameSite=Strict session cookie out of that request.
// every session made until now. A request without one ends no session, so
// someone who has not logged in cannot end the sessions of those who have.
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
if m.IsAuthenticated(r) {
err := m.saveLogout(r.Context())
@@ -211,33 +217,30 @@ func (m *Manager) IsAuthenticated(r *http.Request) bool {
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.
func (m *Manager) saveLogout(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UTC()
if m.db != nil {
_, err := m.db.ExecContext(ctx,
"INSERT OR REPLACE INTO logout (id, logged_out_at) VALUES (1, ?)",
now)
"UPDATE logout SET logout_count = ? WHERE id = 1",
m.logoutCount+1)
if err != nil {
return fmt.Errorf("failed to save the logout: %w", err)
}
}
m.latestLogout = now
m.logoutCount++
return nil
}
// latestLogoutTime returns the time of the latest logout, or the zero time
// when there has been none.
func (m *Manager) latestLogoutTime() time.Time {
// currentLogoutCount returns the count of logouts.
func (m *Manager) currentLogoutCount() int64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.latestLogout
return m.logoutCount
}
+10 -2
View File
@@ -122,12 +122,20 @@ header {
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;
color: #4b5563;
text-decoration: underline;
}
header a:hover {
header button:hover {
background: none;
color: #1f2937;
}
+6 -3
View File
@@ -10,9 +10,12 @@
<div class="page">
<header>
<h1>Pixa URL Generator</h1>
<a href="/logout">
Logout
</a>
<form method="POST" action="/logout">
{{ .CSRFField }}
<button type="submit">
Logout
</button>
</form>
</header>
{{if .GeneratedURL}}