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