// 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 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"` // LogoutCount is the count of logouts when the session was made. LogoutCount int64 `json:"logoutCount"` } // 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. 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 sync.Mutex logoutCount int64 } // 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. // // 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 // the count of logouts in db, which must have pixa's schema, and starts from // the count 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 logout_count FROM logout WHERE id = 1", ).Scan(&m.logoutCount) if err != nil { return nil, fmt.Errorf("failed to read the count of logouts: %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), LogoutCount: m.currentLogoutCount(), } 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.LogoutCount != m.currentLogoutCount() { 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 adds one to the count of logouts, saving the new count in the // database first when the Manager has one. func (m *Manager) saveLogout(ctx context.Context) error { 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) if err != nil { return fmt.Errorf("failed to save the logout: %w", err) } } m.logoutCount++ return nil } // currentLogoutCount returns the count of logouts. func (m *Manager) currentLogoutCount() int64 { m.mu.Lock() defer m.mu.Unlock() return m.logoutCount }