A login session was a cookie with nothing kept on the server, so /logout removed it from one browser and a copy kept working for 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 it outlasts a restart, and refuses a session made before it. A /logout without a valid session ends none and only removes the cookie, so another site cannot log everyone out. A logout that cannot be saved answers 500. session.NewManager, which existing tests use, keeps logouts in memory only; pixad uses NewManagerWithDatabase. Model: opus-5-5
242 lines
6.1 KiB
Go
242 lines
6.1 KiB
Go
// Package session provides encrypted session cookie management.
|
|
package session
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/securecookie"
|
|
|
|
"sneak.berlin/go/pixa/internal/seal"
|
|
)
|
|
|
|
// Session configuration constants.
|
|
const (
|
|
CookieName = "pixa_session"
|
|
SessionTTL = 30 * 24 * time.Hour // 30 days
|
|
|
|
// HKDF salts for key derivation
|
|
hashKeySalt = "pixa-session-hash-v1"
|
|
blockKeySalt = "pixa-session-block-v1"
|
|
)
|
|
|
|
// Errors returned by session operations.
|
|
var (
|
|
ErrInvalidSession = errors.New("invalid or expired session")
|
|
ErrNoSession = errors.New("no session cookie present")
|
|
)
|
|
|
|
// Data contains the session payload stored in the encrypted cookie.
|
|
type Data struct {
|
|
Authenticated bool `json:"auth"`
|
|
CreatedAt time.Time `json:"created"`
|
|
ExpiresAt time.Time `json:"expires"`
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
|
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
|
// trustworthy origin and accept Secure cookies there, so local development
|
|
// keeps working.
|
|
func NewManager(signingKey string) (*Manager, error) {
|
|
masterKey := []byte(signingKey)
|
|
|
|
// Derive separate keys for HMAC (hash) and encryption (block)
|
|
hashKey, err := seal.DeriveKey(masterKey, hashKeySalt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blockKey, err := seal.DeriveKey(masterKey, blockKeySalt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sc := securecookie.New(hashKey[:], blockKey[:])
|
|
sc.MaxAge(int(SessionTTL.Seconds()))
|
|
|
|
return &Manager{
|
|
sc: sc,
|
|
}, nil
|
|
}
|
|
|
|
// 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.
|
|
func NewManagerWithDatabase(
|
|
ctx context.Context, signingKey string, db *sql.DB,
|
|
) (*Manager, error) {
|
|
m, err := NewManager(signingKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
m.db = db
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// CreateSession creates a new authenticated session and sets the cookie.
|
|
func (m *Manager) CreateSession(w http.ResponseWriter) error {
|
|
now := time.Now()
|
|
data := &Data{
|
|
Authenticated: true,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(SessionTTL),
|
|
}
|
|
|
|
encoded, err := m.sc.Encode(CookieName, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieName,
|
|
Value: encoded,
|
|
Path: "/",
|
|
MaxAge: int(SessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateSession checks if the request has a valid session cookie.
|
|
// Returns the session data if valid, or an error if invalid/missing.
|
|
func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
|
|
cookie, err := r.Cookie(CookieName)
|
|
if err != nil {
|
|
if errors.Is(err, http.ErrNoCookie) {
|
|
return nil, ErrNoSession
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
var data Data
|
|
|
|
err = m.sc.Decode(CookieName, cookie.Value, &data)
|
|
if err != nil {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
// Check if session has expired (defense in depth - cookie MaxAge should handle this)
|
|
if time.Now().After(data.ExpiresAt) {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
if !data.Authenticated {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
// A session made before the latest logout has ended.
|
|
if !data.CreatedAt.After(m.latestLogoutTime()) {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
// 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.
|
|
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
|
|
if m.IsAuthenticated(r) {
|
|
err := m.saveLogout(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
m.ClearSession(w)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearSession removes the session cookie.
|
|
func (m *Manager) ClearSession(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1, // Delete immediately
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// IsAuthenticated is a convenience method that returns true if the request
|
|
// has a valid authenticated session.
|
|
func (m *Manager) IsAuthenticated(r *http.Request) bool {
|
|
data, err := m.ValidateSession(r)
|
|
|
|
return err == nil && data != nil && data.Authenticated
|
|
}
|
|
|
|
// saveLogout makes now the time of the latest logout, saving it 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)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save the logout: %w", err)
|
|
}
|
|
}
|
|
|
|
m.latestLogout = now
|
|
|
|
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 {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
return m.latestLogout
|
|
}
|