Files
pad/internal/server/session.go
T
xarmian a30655aa0e feat: add optional password authentication for web UI (#6)
When PAD_PASSWORD is set (env var) or password is configured in
~/.pad/config.toml, the server requires authentication:

Backend:
- SessionManager with HMAC-SHA256 signed cookies (7-day TTL)
- POST /api/v1/auth/login — validates password, sets session cookie
- GET /api/v1/auth/session — returns auth status (exempt from auth)
- POST /api/v1/auth/logout — destroys session, clears cookie
- PasswordAuth middleware gates all API/page requests
- API tokens still work independently (no change to CLI flow)
- Constant-time password comparison + 500ms delay on failure

Frontend:
- Login page at /login with password form and error handling
- Root layout checks auth status before loading app shell
- Global 401 handler in API client redirects to /login
- Login page renders without sidebar/app shell

When no password is configured, everything works exactly as before
(zero-friction localhost). This is a security requirement for any
deployment that exposes the server beyond localhost.
2026-03-28 11:36:10 -04:00

115 lines
2.4 KiB
Go

package server
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
"sync"
"time"
)
const (
sessionTTL = 7 * 24 * time.Hour // 7 days
sessionCookie = "pad_session"
)
// SessionManager handles in-memory session storage with HMAC-signed cookies.
type SessionManager struct {
mu sync.RWMutex
sessions map[string]time.Time // sessionID -> expiresAt
secret []byte // 32-byte HMAC signing key
}
// NewSessionManager creates a session manager with a random signing key.
func NewSessionManager() *SessionManager {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
panic("failed to generate session secret: " + err.Error())
}
sm := &SessionManager{
sessions: make(map[string]time.Time),
secret: secret,
}
// Background cleanup of expired sessions
go func() {
for {
time.Sleep(15 * time.Minute)
sm.cleanup()
}
}()
return sm
}
// Create generates a new session and returns the signed cookie value.
func (sm *SessionManager) Create() string {
id := make([]byte, 24)
if _, err := rand.Read(id); err != nil {
return ""
}
sessionID := base64.RawURLEncoding.EncodeToString(id)
sig := sm.sign(sessionID)
sm.mu.Lock()
sm.sessions[sessionID] = time.Now().Add(sessionTTL)
sm.mu.Unlock()
return sessionID + "." + sig
}
// Validate checks if a cookie value contains a valid, non-expired session.
func (sm *SessionManager) Validate(cookieValue string) bool {
parts := strings.SplitN(cookieValue, ".", 2)
if len(parts) != 2 {
return false
}
sessionID, sig := parts[0], parts[1]
// Verify HMAC signature
expected := sm.sign(sessionID)
if !hmac.Equal([]byte(sig), []byte(expected)) {
return false
}
// Check session exists and is not expired
sm.mu.RLock()
expiresAt, exists := sm.sessions[sessionID]
sm.mu.RUnlock()
return exists && time.Now().Before(expiresAt)
}
// Destroy removes a session.
func (sm *SessionManager) Destroy(cookieValue string) {
parts := strings.SplitN(cookieValue, ".", 2)
if len(parts) != 2 {
return
}
sm.mu.Lock()
delete(sm.sessions, parts[0])
sm.mu.Unlock()
}
func (sm *SessionManager) sign(data string) string {
mac := hmac.New(sha256.New, sm.secret)
mac.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (sm *SessionManager) cleanup() {
now := time.Now()
sm.mu.Lock()
for id, expiresAt := range sm.sessions {
if now.After(expiresAt) {
delete(sm.sessions, id)
}
}
sm.mu.Unlock()
}