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:
+46
-47
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user