Phase 1.2: session login + empty HTMX dashboard
- 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>
This commit is contained in:
196
internal/session/session.go
Normal file
196
internal/session/session.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// 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
|
||||
}
|
||||
78
internal/session/session_test.go
Normal file
78
internal/session/session_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerRoundTrip(t *testing.T) {
|
||||
m, err := NewManager([]byte("test-key"), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager() error = %v", err)
|
||||
}
|
||||
|
||||
sess, cookie, err := m.New("Sam")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if sess.User != "Sam" {
|
||||
t.Errorf("User = %q, want Sam", sess.User)
|
||||
}
|
||||
|
||||
got, ok := m.Lookup(cookie)
|
||||
if !ok {
|
||||
t.Fatal("Lookup() = not ok, want a live session")
|
||||
}
|
||||
if got.ID != sess.ID {
|
||||
t.Errorf("Lookup ID = %q, want %q", got.ID, sess.ID)
|
||||
}
|
||||
|
||||
m.Destroy(cookie)
|
||||
if _, ok := m.Lookup(cookie); ok {
|
||||
t.Error("Lookup() after Destroy() = ok, want expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsTamperedCookie(t *testing.T) {
|
||||
m, err := NewManager([]byte("key-a"), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager() error = %v", err)
|
||||
}
|
||||
_, cookie, err := m.New("Sam")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
other, err := NewManager([]byte("key-b"), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager() error = %v", err)
|
||||
}
|
||||
if _, ok := other.Lookup(cookie); ok {
|
||||
t.Error("Lookup() with a different key = ok, want rejected")
|
||||
}
|
||||
|
||||
for _, bad := range []string{"", "garbage", "abc.", ".abc", "a.b.c", cookie + "x"} {
|
||||
if _, ok := m.Lookup(bad); ok {
|
||||
t.Errorf("Lookup(%q) = ok, want rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreExpiry(t *testing.T) {
|
||||
st := NewStore(time.Minute)
|
||||
base := time.Now()
|
||||
st.now = func() time.Time { return base }
|
||||
|
||||
sess, err := st.Create("Sam")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if _, ok := st.Get(sess.ID); !ok {
|
||||
t.Fatal("Get() = not ok before expiry")
|
||||
}
|
||||
|
||||
st.now = func() time.Time { return base.Add(2 * time.Minute) }
|
||||
if _, ok := st.Get(sess.ID); ok {
|
||||
t.Error("Get() = ok after expiry, want expired")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user