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
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"context"
"net"
"net/http"
"strings"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/store"
)
// Identity is the authenticated caller, attached to the request context by
// auth(). Every handler reached past auth() has one.
type Identity struct {
UserID string
Username string
}
type identityKey struct{}
func identityFrom(r *http.Request) Identity {
id, _ := r.Context().Value(identityKey{}).(Identity)
return id
}
// publicPaths need no authentication: they are how a fresh install creates
// its first account, how a session is established or torn down, and the
// health check the UI's own bootstrap (and Docker's HEALTHCHECK) depend on.
var publicPaths = map[string]bool{
"/api/setup": true,
"/api/login": true,
"/api/logout": true,
"/api/me": true,
"/api/health": true,
}
// auth gates every /api/* request behind a session cookie or a personal API
// token.
//
// Static assets are served without it: see the comment on spaHandler. Nothing
// sensitive lives in the bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || publicPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
s.serveAs(w, r, next, Identity{UserID: sess.UserID, Username: sess.Username})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
s.serveAs(w, r, next, Identity{UserID: usr.ID, Username: usr.Username})
return
}
}
}
writeError(w, http.StatusUnauthorized, "authentication required")
})
}
func (s *Server) serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, id Identity) {
ctx := context.WithValue(r.Context(), identityKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
}
func bearerToken(r *http.Request) string {
if got := r.Header.Get("X-Auth-Token"); got != "" {
return got
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
// isHTTPS reports whether the request reached us over TLS, directly or
// through a reverse proxy that sets the standard forwarded-proto header. It
// decides the session cookie's Secure attribute.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sess session.Session) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// remoteIP strips the port from RemoteAddr, for the login rate limiter.
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleSetup creates the first account. It only succeeds while no account
// exists yet; main.go also refuses to bind non-loopback until then, so this
// endpoint being open to anyone is not a standing risk.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.NeedsSetup() {
writeError(w, http.StatusConflict, "setup has already been completed")
return
}
var req setupRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
s.completeLogin(w, r, usr)
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := remoteIP(r)
if !s.logins.Allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many attempts; try again shortly")
return
}
var req loginRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Verify(req.Username, req.Password)
if err != nil {
s.logins.Fail(ip)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.logins.Reset(ip)
_ = s.users.TouchLastLogin(usr.ID)
s.completeLogin(w, r, usr)
}
func (s *Server) completeLogin(w http.ResponseWriter, r *http.Request, usr store.User) {
sess, err := s.sessions.Create(usr.ID, usr.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
setSessionCookie(w, r, *sess)
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(session.CookieName); err == nil {
s.sessions.Delete(c.Value)
}
clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
// meResponse is also what /api/setup and /api/login return on success.
type meResponse struct {
ID string `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Authenticated bool `json:"authenticated"`
NeedsSetup bool `json:"needsSetup,omitempty"`
}
// handleMe reports the caller's identity, or that setup is still needed. It
// is public so the UI can decide, on load, whether to show the setup screen,
// a login form, or the app itself.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if s.NeedsSetup() {
writeJSON(w, http.StatusOK, meResponse{NeedsSetup: true})
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
writeJSON(w, http.StatusOK, meResponse{ID: sess.UserID, Username: sess.Username, Authenticated: true})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
return
}
}
}
writeJSON(w, http.StatusOK, meResponse{})
}
+4 -5
View File
@@ -25,11 +25,10 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
defer cancel()
body := map[string]any{
"ok": true,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
"ok": true,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
}
// Health is also what connects to the selected source on a fresh start, so
+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()
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"net/http"
"github.com/arescom/dockmv/internal/store"
)
// handleListTokens lists only the caller's own tokens; there is no way to see
// another account's.
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.tokens.List(identityFrom(r).UserID))
}
type createTokenRequest struct {
Name string `json:"name"`
}
// createTokenResponse embeds the stored token plus the plaintext secret,
// which is only ever shown this once.
type createTokenResponse struct {
store.APIToken
Token string `json:"token"`
}
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
tok, plain, err := s.tokens.Create(identityFrom(r).UserID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
writeJSON(w, http.StatusCreated, createTokenResponse{APIToken: tok, Token: plain})
}
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
if err := s.tokens.Revoke(identityFrom(r).UserID, r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"net/http"
"github.com/arescom/dockmv/internal/store"
)
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.users.List())
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleCreateUser adds another account. v1 has no roles: any authenticated
// account can create or remove any other.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusCreated, usr)
}
// handleDeleteUser removes an account, along with its sessions and personal
// API tokens so nothing left behind still authenticates as it.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.users.Delete(id); err != nil {
status := http.StatusNotFound
if err == store.ErrLastAccount {
status = http.StatusBadRequest
}
writeError(w, status, "%v", err)
return
}
s.sessions.DeleteAllForUser(id)
_ = s.tokens.RevokeAllForUser(id)
w.WriteHeader(http.StatusNoContent)
}