Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
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:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user