Replace the count of logouts with a random value (part of #256)
check / check (push) Waiting to run
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:
@@ -177,11 +177,10 @@ CREATE TABLE IF NOT EXISTS cache_stats (
|
||||
);
|
||||
INSERT OR IGNORE INTO cache_stats (id) VALUES (1);
|
||||
|
||||
-- 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.
|
||||
-- A random value that a login session carries from its login. pixa makes it
|
||||
-- when the table has no row, and each logout replaces it; a login session
|
||||
-- carrying another value is refused, so logging out ends every login session.
|
||||
CREATE TABLE IF NOT EXISTS logout (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
logout_count INTEGER NOT NULL DEFAULT 0
|
||||
logout_value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO logout (id) VALUES (1);
|
||||
|
||||
@@ -164,8 +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.
|
||||
// The count of logouts is saved in the database, so it outlasts a
|
||||
// restart.
|
||||
// The random value that each logout replaces 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 {
|
||||
|
||||
+49
-34
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user