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,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{})
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user