// Package session implements the Email Agent's server-side session store and // the signed cookie format used to reference a session. // // Sessions live in the process (there is one user and one container), so the // cookie only ever carries an opaque, HMAC-signed identifier. Nothing about the // user or the session state is stored client-side. package session import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "strings" "sync" "time" ) // CookieName is the name of the session cookie. const CookieName = "email_agent_session" // DefaultTTL is how long a session stays valid. const DefaultTTL = 30 * 24 * time.Hour // Session is a single logged-in session. type Session struct { ID string User string CreatedAt time.Time ExpiresAt time.Time } // Store is an in-memory, concurrency-safe session store with lazy expiry. type Store struct { mu sync.Mutex sessions map[string]Session ttl time.Duration now func() time.Time } // NewStore returns an empty store. A non-positive ttl falls back to DefaultTTL. func NewStore(ttl time.Duration) *Store { if ttl <= 0 { ttl = DefaultTTL } return &Store{ sessions: make(map[string]Session), ttl: ttl, now: time.Now, } } // Create makes a new session for user and returns it. func (s *Store) Create(user string) (Session, error) { id, err := RandomToken(32) if err != nil { return Session{}, err } now := s.now() sess := Session{ ID: id, User: user, CreatedAt: now, ExpiresAt: now.Add(s.ttl), } s.mu.Lock() defer s.mu.Unlock() s.gcLocked() s.sessions[id] = sess return sess, nil } // Get returns the session for id if it exists and has not expired. func (s *Store) Get(id string) (Session, bool) { s.mu.Lock() defer s.mu.Unlock() sess, ok := s.sessions[id] if !ok { return Session{}, false } if s.now().After(sess.ExpiresAt) { delete(s.sessions, id) return Session{}, false } return sess, true } // Delete removes a session. func (s *Store) Delete(id string) { s.mu.Lock() defer s.mu.Unlock() delete(s.sessions, id) } // Len returns the number of live (not-yet-swept) sessions. func (s *Store) Len() int { s.mu.Lock() defer s.mu.Unlock() return len(s.sessions) } func (s *Store) gcLocked() { now := s.now() for id, sess := range s.sessions { if now.After(sess.ExpiresAt) { delete(s.sessions, id) } } } // Manager pairs a session store with the HMAC key that signs cookie values. type Manager struct { store *Store key []byte } // NewManager builds a Manager. An empty key is replaced with a random key so // the manager is always safe to use; the caller should normally supply one. func NewManager(key []byte, ttl time.Duration) (*Manager, error) { if len(key) == 0 { k, err := randomBytes(32) if err != nil { return nil, err } key = k } return &Manager{store: NewStore(ttl), key: key}, nil } // New creates a session and returns it with the signed cookie value to set. func (m *Manager) New(user string) (Session, string, error) { sess, err := m.store.Create(user) if err != nil { return Session{}, "", err } return sess, m.sign(sess.ID), nil } // Lookup resolves a signed cookie value to a live session. func (m *Manager) Lookup(cookieValue string) (Session, bool) { id, ok := m.verify(cookieValue) if !ok { return Session{}, false } return m.store.Get(id) } // Destroy deletes the session referenced by a signed cookie value. func (m *Manager) Destroy(cookieValue string) { if id, ok := m.verify(cookieValue); ok { m.store.Delete(id) } } func (m *Manager) sign(id string) string { mac := hmac.New(sha256.New, m.key) mac.Write([]byte(id)) return id + "." + hex.EncodeToString(mac.Sum(nil)) } func (m *Manager) verify(value string) (string, bool) { i := strings.LastIndexByte(value, '.') if i <= 0 || i == len(value)-1 { return "", false } id, sigHex := value[:i], value[i+1:] sig, err := hex.DecodeString(sigHex) if err != nil { return "", false } mac := hmac.New(sha256.New, m.key) mac.Write([]byte(id)) if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 { return "", false } return id, true } // RandomToken returns a URL-safe random string carrying n bytes of entropy. func RandomToken(n int) (string, error) { b, err := randomBytes(n) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func randomBytes(n int) ([]byte, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return nil, err } return b, nil }