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:00:58 +00:00
parent fe3a32c63d
commit 5b63b932fe
8 changed files with 93 additions and 69 deletions
+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:"logout_count"`
}
// 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}}