- Session login for the single user Sam: server-side session store with an HMAC-signed session cookie, POST /login, POST /logout, and double-submit CSRF on every form post. Unauthenticated requests to / and /partials/* redirect to /login?next=... - Empty dashboard shell in the Family Home Lab design system: left nav (category and folder placeholders), centre list, right detail pane. - Copy tokens.css and the self-hosted Inter fonts from the family-home-lab design system into web/static; vendor htmx 2.0.4. No cross-project imports at runtime. - Dark mode via prefers-color-scheme plus a persisted toggle; phone-first layout with 44px touch targets, hairline borders, 8px grid, pill CTAs. - Config: EMAIL_AGENT_PASSWORD for the login password, falling back to EMAIL_AGENT_SESSION_SECRET so one configured secret is enough. Co-Authored-By: Paperclip <noreply@paperclip.ing>
197 lines
4.5 KiB
Go
197 lines
4.5 KiB
Go
// 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
|
|
}
|