Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s

- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:13:54 +02:00
co-authored by Claude Sonnet 5
parent 17a83e737f
commit 8d20d9be84
28 changed files with 1635 additions and 137 deletions
+95
View File
@@ -0,0 +1,95 @@
// Package session tracks logged-in browser sessions in memory. There is no
// persistence and no signing: state lives only for the life of the process,
// so a restart already invalidates every session, and signing would protect
// nothing that a restart doesn't already.
package session
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// CookieName is the session cookie set on a successful login.
const CookieName = "dockmv_session"
// TTL is how long a session survives without activity. Every successful
// lookup slides the expiry forward by this much.
const TTL = 7 * 24 * time.Hour
// Session is one logged-in browser tab's worth of state.
type Session struct {
ID string
UserID string
Username string
ExpiresAt time.Time
}
// Manager holds every live session.
type Manager struct {
mu sync.Mutex
byID map[string]*Session
}
// NewManager returns an empty session store.
func NewManager() *Manager {
return &Manager{byID: map[string]*Session{}}
}
// Create starts a new session for a logged-in account.
func (m *Manager) Create(userID, username string) (*Session, error) {
id, err := newSessionID()
if err != nil {
return nil, err
}
s := &Session{ID: id, UserID: userID, Username: username, ExpiresAt: time.Now().Add(TTL)}
m.mu.Lock()
m.byID[id] = s
m.mu.Unlock()
return s, nil
}
// Get returns the session for id, sliding its expiry forward. ok is false for
// an unknown or expired id.
func (m *Manager) Get(id string) (Session, bool) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.byID[id]
if !ok {
return Session{}, false
}
if time.Now().After(s.ExpiresAt) {
delete(m.byID, id)
return Session{}, false
}
s.ExpiresAt = time.Now().Add(TTL)
return *s, true
}
// Delete ends one session, e.g. on logout.
func (m *Manager) Delete(id string) {
m.mu.Lock()
delete(m.byID, id)
m.mu.Unlock()
}
// DeleteAllForUser ends every session belonging to an account, e.g. when the
// account itself is deleted.
func (m *Manager) DeleteAllForUser(userID string) {
m.mu.Lock()
for id, s := range m.byID {
if s.UserID == userID {
delete(m.byID, id)
}
}
m.mu.Unlock()
}
func newSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}