Log out with a form and count logouts instead of timing them (part of #256)
check / check (push) Waiting to run

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 14:09:16 +00:00
parent fe3a32c63d
commit 1d2411dd2e
8 changed files with 93 additions and 69 deletions
+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
}