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
262 lines
6.9 KiB
Go
262 lines
6.9 KiB
Go
// 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"
|
|
"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"`
|
|
|
|
// 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 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 logoutValue, and is held while a logout is saved, so that
|
|
// the database and logoutValue agree.
|
|
mu sync.Mutex
|
|
logoutValue string
|
|
}
|
|
|
|
// NewManager creates a session manager with keys derived from the signing key.
|
|
// 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
|
|
// 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,
|
|
logoutValue: rand.Text(),
|
|
}, nil
|
|
}
|
|
|
|
// 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) {
|
|
m, err := NewManager(signingKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, 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 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
|
|
|
|
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),
|
|
LogoutValue: m.currentLogoutValue(),
|
|
}
|
|
|
|
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 logout since the session was made has ended it.
|
|
if data.LogoutValue != m.currentLogoutValue() {
|
|
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, 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())
|
|
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 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_value = ? WHERE id = 1", value)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save the logout: %w", err)
|
|
}
|
|
}
|
|
|
|
m.logoutValue = value
|
|
|
|
return nil
|
|
}
|
|
|
|
// currentLogoutValue returns the Manager's random value.
|
|
func (m *Manager) currentLogoutValue() string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
return m.logoutValue
|
|
}
|