Replace the count of logouts with a random value (part of #256)
check / check (push) Waiting to run

The session manager now keeps a random value from crypto/rand in the
logout table, made when the table has none and replaced by each logout.
A session carries the value current at its login and is refused when it
differs. A lost state directory now ends every login session, and no
logout can make an ended one work again, as a count starting from 0
could. README.md and the Manager doc comment say the check for a logout
compares no time, while a session's expiry still compares the clock.

Model: opus-5-5
This commit is contained in:
2026-10-15 17:00:58 +00:00
parent fd4517c6d9
commit f63ebf57ba
5 changed files with 82 additions and 66 deletions
+49 -34
View File
@@ -1,10 +1,11 @@
// 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 provides encrypted session cookie management, and keeps in
// the database a random value that each logout replaces, so that a logout
// ends every session made before it.
package session
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
@@ -39,33 +40,35 @@ type Data struct {
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"`
// LogoutValue is the Manager's random value, which each logout
// replaces, when the session was made.
LogoutValue string `json:"logoutValue"`
}
// 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 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.
// session made before it, in every browser. The Manager keeps a random
// value, which each logout replaces: a session carries the value current at
// its login, and a session carrying another value is refused. The check for
// a logout compares no time, so a login after a logout works whatever the
// host's clock does; the session's expiry is still checked against the
// clock. The Manager keeps the value 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 logoutCount, and is held while a logout is saved, so that
// the database and logoutCount agree.
// mu guards logoutValue, and is held while a logout is saved, so that
// the database and logoutValue agree.
mu sync.Mutex
logoutCount int64
logoutValue string
}
// NewManager creates a session manager with keys derived from the signing key.
// It keeps the count of logouts in memory only, so a restart forgets it;
// NewManagerWithDatabase keeps it in the database.
// It makes a new random value and keeps it in memory only, so a restart ends
// every session; NewManagerWithDatabase keeps the value in the database.
//
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
// attributes; this cannot be configured. Browsers treat http://localhost as a
@@ -89,13 +92,15 @@ func NewManager(signingKey string) (*Manager, error) {
sc.MaxAge(int(SessionTTL.Seconds()))
return &Manager{
sc: sc,
sc: sc,
logoutValue: rand.Text(),
}, nil
}
// NewManagerWithDatabase creates a session manager like NewManager that saves
// the count of logouts in db, which must have pixa's schema, and starts from
// the count saved there.
// NewManagerWithDatabase creates a session manager like NewManager that keeps
// its random value in db, which must have pixa's schema: it saves the value
// NewManager made when db has none, and otherwise starts from the one saved
// there.
func NewManagerWithDatabase(
ctx context.Context, signingKey string, db *sql.DB,
) (*Manager, error) {
@@ -104,11 +109,20 @@ func NewManagerWithDatabase(
return nil, err
}
err = db.QueryRowContext(ctx,
"SELECT logout_count FROM logout WHERE id = 1",
).Scan(&m.logoutCount)
_, err = db.ExecContext(ctx,
"INSERT OR IGNORE INTO logout (id, logout_value) VALUES (1, ?)",
m.logoutValue)
if err != nil {
return nil, fmt.Errorf("failed to read the count of logouts: %w", err)
return nil, fmt.Errorf(
"failed to save the random value in the logout table: %w", err)
}
err = db.QueryRowContext(ctx,
"SELECT logout_value FROM logout WHERE id = 1",
).Scan(&m.logoutValue)
if err != nil {
return nil, fmt.Errorf(
"failed to read the random value in the logout table: %w", err)
}
m.db = db
@@ -123,7 +137,7 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
Authenticated: true,
CreatedAt: now,
ExpiresAt: now.Add(SessionTTL),
LogoutCount: m.currentLogoutCount(),
LogoutValue: m.currentLogoutValue(),
}
encoded, err := m.sc.Encode(CookieName, data)
@@ -173,7 +187,7 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
}
// A logout since the session was made has ended it.
if data.LogoutCount != m.currentLogoutCount() {
if data.LogoutValue != m.currentLogoutValue() {
return nil, ErrInvalidSession
}
@@ -217,30 +231,31 @@ func (m *Manager) IsAuthenticated(r *http.Request) bool {
return err == nil && data != nil && data.Authenticated
}
// saveLogout adds one to the count of logouts, saving the new count in the
// saveLogout replaces the random value with a new one, saving it in the
// database first when the Manager has one.
func (m *Manager) saveLogout(ctx context.Context) error {
value := rand.Text()
m.mu.Lock()
defer m.mu.Unlock()
if m.db != nil {
_, err := m.db.ExecContext(ctx,
"UPDATE logout SET logout_count = ? WHERE id = 1",
m.logoutCount+1)
"UPDATE logout SET logout_value = ? WHERE id = 1", value)
if err != nil {
return fmt.Errorf("failed to save the logout: %w", err)
}
}
m.logoutCount++
m.logoutValue = value
return nil
}
// currentLogoutCount returns the count of logouts.
func (m *Manager) currentLogoutCount() int64 {
// currentLogoutValue returns the Manager's random value.
func (m *Manager) currentLogoutValue() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.logoutCount
return m.logoutValue
}