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
+76
View File
@@ -0,0 +1,76 @@
package session
import (
"sync"
"time"
)
// limiterBase and limiterCap bound the exponential backoff: 1s, 2s, 4s, ...
// capped at 30s.
const (
limiterBase = 1 * time.Second
limiterCap = 30 * time.Second
// maxShift keeps 1<<n from overflowing and is already well past the point
// backoff hits limiterCap, so failures beyond it just stay capped.
maxShift = 5
)
// Limiter throttles login attempts per key (typically the remote IP) with
// exponential backoff, so password guessing is slow without locking anyone
// out for long. It never expires idle keys on its own; a v1-scale accounts
// file does not make that worth the complexity.
type Limiter struct {
mu sync.Mutex
state map[string]*limiterState
}
type limiterState struct {
failures int
blockedUntil time.Time
}
// NewLimiter returns an empty limiter.
func NewLimiter() *Limiter {
return &Limiter{state: map[string]*limiterState{}}
}
// Allow reports whether key may attempt a login right now.
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.state[key]
if !ok {
return true
}
return !time.Now().Before(st.blockedUntil)
}
// Fail records a failed attempt for key and returns the backoff before the
// next one is allowed.
func (l *Limiter) Fail(key string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.state[key]
if !ok {
st = &limiterState{}
l.state[key] = st
}
st.failures++
shift := st.failures - 1
if shift > maxShift {
shift = maxShift
}
backoff := limiterBase * time.Duration(1<<shift)
if backoff > limiterCap {
backoff = limiterCap
}
st.blockedUntil = time.Now().Add(backoff)
return backoff
}
// Reset clears key's failure history after a successful login.
func (l *Limiter) Reset(key string) {
l.mu.Lock()
delete(l.state, key)
l.mu.Unlock()
}