// 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 }