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
+46 -47
View File
@@ -2,7 +2,6 @@
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
@@ -15,6 +14,7 @@ import (
"time"
"github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
)
@@ -23,9 +23,7 @@ import (
type Config struct {
// Addr is the listen address, e.g. 127.0.0.1:8080.
Addr string
// Token, when set, must be presented on every API request.
Token string
// DataDir holds connections and the known-hosts file.
// DataDir holds accounts, connections and the known-hosts file.
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
@@ -42,13 +40,17 @@ type Config struct {
// Server ties the source daemon, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
sources *store.Sources
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
cfg Config
log *slog.Logger
sources *store.Sources
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
users *store.Users
tokens *store.Tokens
sessions *session.Manager
logins *session.Limiter
mux *http.ServeMux
// srcMu guards cur, the connection to the selected source. It is opened on
// first use and replaced when the operator picks another source.
@@ -92,14 +94,33 @@ func New(cfg Config) (*Server, error) {
}
}
users, err := store.NewUsers(filepath.Join(cfg.DataDir, "users.json"))
if err != nil {
return nil, err
}
tokens, err := store.NewTokens(filepath.Join(cfg.DataDir, "tokens.json"))
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg, log: cfg.Logger, sources: sources,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
conns: conns, hosts: hosts, jobs: job.NewManager(),
users: users, tokens: tokens,
sessions: session.NewManager(), logins: session.NewLimiter(),
mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// NeedsSetup reports whether no account exists yet. main.go refuses to bind
// non-loopback while this is true, and GET /api/me uses it to decide whether
// the UI should show the first-run setup screen instead of a login form.
func (s *Server) NeedsSetup() bool {
return s.users.Count() == 0
}
// Close releases the connection to the current source.
func (s *Server) Close() error {
s.invalidateSource("")
@@ -114,6 +135,19 @@ func (s *Server) Handler() http.Handler {
func (s *Server) routes() {
m := s.mux
m.HandleFunc("POST /api/setup", s.handleSetup)
m.HandleFunc("POST /api/login", s.handleLogin)
m.HandleFunc("POST /api/logout", s.handleLogout)
m.HandleFunc("GET /api/me", s.handleMe)
m.HandleFunc("GET /api/users", s.handleListUsers)
m.HandleFunc("POST /api/users", s.handleCreateUser)
m.HandleFunc("DELETE /api/users/{id}", s.handleDeleteUser)
m.HandleFunc("GET /api/tokens", s.handleListTokens)
m.HandleFunc("POST /api/tokens", s.handleCreateToken)
m.HandleFunc("DELETE /api/tokens/{id}", s.handleRevokeToken)
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
@@ -152,41 +186,6 @@ func (s *Server) routes() {
}
}
// auth enforces the shared token on the API. The token may also be passed as a
// query parameter, because EventSource cannot set headers and neither can a
// download link.
//
// Static assets are deliberately served without it. A browser opening
// /?token=… does not carry the query string over to /assets/app.js, so gating
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
// bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.Token == "" {
return next
}
want := []byte(s.cfg.Token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Auth-Token")
if got == "" {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got = strings.TrimPrefix(h, "Bearer ")
}
}
if got == "" {
got = r.URL.Query().Get("token")
}
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()