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)
}
+1
View File
@@ -39,6 +39,7 @@ const (
KindSSH Kind = "ssh"
KindPackage Kind = "package"
KindRestore Kind = "restore"
KindBackup Kind = "backup"
)
// Level classifies a log line.
+76
View File
@@ -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()
}
+95
View File
@@ -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
}
+197
View File
@@ -0,0 +1,197 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// ErrTokenNotFound is returned for an unknown, revoked, or not-owned token.
var ErrTokenNotFound = errors.New("token not found")
// APIToken is a personal access token, scoped to the account that created it.
// The plaintext is only ever returned once, by Create.
type APIToken struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Hint string `json:"hint"` // last 4 characters, for telling tokens apart in a list
Hash string `json:"hash"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// Tokens is a JSON-backed collection of personal API tokens.
type Tokens struct {
path string
mu sync.RWMutex
// items holds every token by id.
items map[string]APIToken
// byHash maps a token's sha256 hex digest to its id, for authentication
// lookups without ever storing the plaintext.
byHash map[string]string
}
// NewTokens loads (or creates) the tokens file at path.
func NewTokens(path string) (*Tokens, error) {
t := &Tokens{path: path, items: map[string]APIToken{}, byHash: map[string]string{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return t, nil
}
if err != nil {
return nil, fmt.Errorf("read tokens: %w", err)
}
var list []APIToken
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse tokens file %s: %w", path, err)
}
for _, tok := range list {
t.items[tok.ID] = tok
t.byHash[tok.Hash] = tok.ID
}
return t, nil
}
// List returns every token owned by userID, hash stripped, newest first.
func (t *Tokens) List(userID string) []APIToken {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
if tok.UserID == userID {
out = append(out, redactToken(tok))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// Create mints a new token for userID and returns it (hash stripped) plus the
// plaintext secret, which is never stored and never retrievable again.
func (t *Tokens) Create(userID, name string) (APIToken, string, error) {
plain, err := newTokenSecret()
if err != nil {
return APIToken{}, "", fmt.Errorf("generate token: %w", err)
}
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
tok := APIToken{
ID: newID(),
UserID: userID,
Name: name,
Hint: plain[len(plain)-4:],
Hash: hash,
CreatedAt: time.Now(),
}
t.items[tok.ID] = tok
t.byHash[hash] = tok.ID
if err := t.flush(); err != nil {
return APIToken{}, "", err
}
return redactToken(tok), plain, nil
}
// Authenticate looks up the token behind a plaintext secret and records it as
// used. It does not check ownership: any valid token authenticates as its
// owner, which the caller then treats as the request's identity.
func (t *Tokens) Authenticate(plain string) (APIToken, error) {
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
id, ok := t.byHash[hash]
if !ok {
return APIToken{}, ErrTokenNotFound
}
tok := t.items[id]
now := time.Now()
tok.LastUsedAt = &now
t.items[id] = tok
if err := t.flush(); err != nil {
return APIToken{}, err
}
return redactToken(tok), nil
}
// Revoke deletes a token, refusing if it is not owned by userID.
func (t *Tokens) Revoke(userID, id string) error {
t.mu.Lock()
defer t.mu.Unlock()
tok, ok := t.items[id]
if !ok || tok.UserID != userID {
return ErrTokenNotFound
}
delete(t.items, id)
delete(t.byHash, tok.Hash)
return t.flush()
}
// RevokeAllForUser deletes every token owned by userID, e.g. when the account
// itself is deleted.
func (t *Tokens) RevokeAllForUser(userID string) error {
t.mu.Lock()
defer t.mu.Unlock()
for id, tok := range t.items {
if tok.UserID == userID {
delete(t.items, id)
delete(t.byHash, tok.Hash)
}
}
return t.flush()
}
// flush writes the file. The caller must hold the write lock.
func (t *Tokens) flush() error {
list := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
list = append(list, tok)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := t.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write tokens: %w", err)
}
if err := os.Rename(tmp, t.path); err != nil {
return fmt.Errorf("replace tokens file: %w", err)
}
return nil
}
func redactToken(tok APIToken) APIToken {
tok.Hash = ""
return tok
}
// newTokenSecret generates a token in the form dmv_<48 hex characters>.
func newTokenSecret() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "dmv_" + hex.EncodeToString(b), nil
}
func hashToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
+145
View File
@@ -0,0 +1,145 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func newTestTokens(t *testing.T) (*Tokens, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.json")
tk, err := NewTokens(path)
if err != nil {
t.Fatalf("NewTokens: %v", err)
}
return tk, path
}
func TestTokensCreateAndAuthenticate(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "ci token")
if err != nil {
t.Fatalf("Create: %v", err)
}
if tok.Hash != "" {
t.Fatal("Create must return a redacted token")
}
if !strings.HasPrefix(plain, "dmv_") {
t.Fatalf("plaintext = %q, want dmv_ prefix", plain)
}
if tok.Hint != plain[len(plain)-4:] {
t.Fatalf("hint = %q, want last 4 chars of %q", tok.Hint, plain)
}
got, err := tk.Authenticate(plain)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.UserID != "user-1" {
t.Fatalf("UserID = %q, want user-1", got.UserID)
}
if got.Hash != "" {
t.Fatal("Authenticate must return a redacted token")
}
if _, err := tk.Authenticate("dmv_deadbeef"); err != ErrTokenNotFound {
t.Fatalf("Authenticate with bogus token = %v, want ErrTokenNotFound", err)
}
}
func TestTokensAuthenticateRecordsLastUsed(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "ci token")
if err != nil {
t.Fatalf("Create: %v", err)
}
if tok.LastUsedAt != nil {
t.Fatal("a fresh token should have no last-used time")
}
got, err := tk.Authenticate(plain)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.LastUsedAt == nil {
t.Fatal("Authenticate should record LastUsedAt")
}
}
func TestTokensListScopedToOwner(t *testing.T) {
tk, _ := newTestTokens(t)
if _, _, err := tk.Create("user-1", "a"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-2", "b"); err != nil {
t.Fatalf("Create: %v", err)
}
list := tk.List("user-1")
if len(list) != 1 || list[0].Name != "a" {
t.Fatalf("List(user-1) = %+v, want just token a", list)
}
}
func TestTokensRevokeOwnershipChecked(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "a")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := tk.Revoke("user-2", tok.ID); err != ErrTokenNotFound {
t.Fatalf("Revoke by non-owner = %v, want ErrTokenNotFound", err)
}
if err := tk.Revoke("user-1", tok.ID); err != nil {
t.Fatalf("Revoke by owner: %v", err)
}
if _, err := tk.Authenticate(plain); err != ErrTokenNotFound {
t.Fatalf("Authenticate after revoke = %v, want ErrTokenNotFound", err)
}
}
func TestTokensRevokeAllForUser(t *testing.T) {
tk, _ := newTestTokens(t)
if _, _, err := tk.Create("user-1", "a"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-1", "b"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-2", "c"); err != nil {
t.Fatalf("Create: %v", err)
}
if err := tk.RevokeAllForUser("user-1"); err != nil {
t.Fatalf("RevokeAllForUser: %v", err)
}
if len(tk.List("user-1")) != 0 {
t.Fatal("user-1 should have no tokens left")
}
if len(tk.List("user-2")) != 1 {
t.Fatal("user-2's token should be untouched")
}
}
func TestTokensPersistAcrossReload(t *testing.T) {
tk, path := newTestTokens(t)
_, plain, err := tk.Create("user-1", "a")
if err != nil {
t.Fatalf("Create: %v", err)
}
reopened, err := NewTokens(path)
if err != nil {
t.Fatalf("NewTokens: %v", err)
}
if _, err := reopened.Authenticate(plain); err != nil {
t.Fatalf("Authenticate after reload: %v", err)
}
}
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// ErrUsernameTaken is returned by Create when the username is already in use.
var ErrUsernameTaken = errors.New("username already taken")
// ErrInvalidCredentials is returned by Verify on an unknown username or a
// wrong password. The two cases are not distinguished, to avoid leaking which
// usernames exist.
var ErrInvalidCredentials = errors.New("invalid username or password")
// ErrLastAccount is returned by Delete when it would remove the only account.
var ErrLastAccount = errors.New("cannot delete the last account")
// ErrPasswordTooLong is returned when a password exceeds bcrypt's 72-byte
// limit. Passwords are rejected rather than silently truncated.
var ErrPasswordTooLong = errors.New("password must be 72 bytes or fewer")
// User is one local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
}
// Users is a JSON-backed collection of local accounts.
type Users struct {
path string
mu sync.RWMutex
// items holds every account by id.
items map[string]User
// byName maps a lowercased username to its account id, for case-insensitive
// uniqueness checks and login lookups.
byName map[string]string
}
// dummyHash is compared against on a lookup-miss in Verify, so a login attempt
// against a username that doesn't exist takes the same time as one that does.
var dummyHash = mustHash("dockmv-dummy-password-for-timing-only")
func mustHash(password string) []byte {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return h
}
// NewUsers loads (or creates) the users file at path.
func NewUsers(path string) (*Users, error) {
u := &Users{path: path, items: map[string]User{}, byName: map[string]string{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return u, nil
}
if err != nil {
return nil, fmt.Errorf("read users: %w", err)
}
var list []User
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse users file %s: %w", path, err)
}
for _, usr := range list {
u.items[usr.ID] = usr
u.byName[strings.ToLower(usr.Username)] = usr.ID
}
return u, nil
}
// Count returns the number of accounts.
func (u *Users) Count() int {
u.mu.RLock()
defer u.mu.RUnlock()
return len(u.items)
}
// List returns every account, password hashes stripped, username order.
func (u *Users) List() []User {
u.mu.RLock()
defer u.mu.RUnlock()
out := make([]User, 0, len(u.items))
for _, usr := range u.items {
out = append(out, redactUser(usr))
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// Get returns one account, password hash stripped.
func (u *Users) Get(id string) (User, error) {
u.mu.RLock()
defer u.mu.RUnlock()
usr, ok := u.items[id]
if !ok {
return User{}, ErrNotFound
}
return redactUser(usr), nil
}
// Create adds a new account. Usernames are unique case-insensitively.
func (u *Users) Create(username, password string) (User, error) {
username = strings.TrimSpace(username)
if username == "" {
return User{}, errors.New("username is required")
}
if password == "" {
return User{}, errors.New("password is required")
}
if len(password) > 72 {
return User{}, ErrPasswordTooLong
}
u.mu.Lock()
defer u.mu.Unlock()
key := strings.ToLower(username)
if _, ok := u.byName[key]; ok {
return User{}, ErrUsernameTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, fmt.Errorf("hash password: %w", err)
}
usr := User{ID: newID(), Username: username, PasswordHash: string(hash), CreatedAt: time.Now()}
u.items[usr.ID] = usr
u.byName[key] = usr.ID
if err := u.flush(); err != nil {
return User{}, err
}
return redactUser(usr), nil
}
// Verify checks a username/password pair and returns the account on success.
// A lookup-miss still runs a bcrypt comparison against a dummy hash, so the
// two failure cases take the same time.
func (u *Users) Verify(username, password string) (User, error) {
u.mu.RLock()
id, ok := u.byName[strings.ToLower(strings.TrimSpace(username))]
var usr User
if ok {
usr = u.items[id]
}
u.mu.RUnlock()
if !ok {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return User{}, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(usr.PasswordHash), []byte(password)); err != nil {
return User{}, ErrInvalidCredentials
}
return redactUser(usr), nil
}
// TouchLastLogin records the current time as an account's last login.
func (u *Users) TouchLastLogin(id string) error {
u.mu.Lock()
defer u.mu.Unlock()
usr, ok := u.items[id]
if !ok {
return ErrNotFound
}
now := time.Now()
usr.LastLoginAt = &now
u.items[id] = usr
return u.flush()
}
// Delete removes an account. The last remaining account cannot be deleted, so
// the instance never ends up with no way to log in.
func (u *Users) Delete(id string) error {
u.mu.Lock()
defer u.mu.Unlock()
usr, ok := u.items[id]
if !ok {
return ErrNotFound
}
if len(u.items) == 1 {
return ErrLastAccount
}
delete(u.items, id)
delete(u.byName, strings.ToLower(usr.Username))
return u.flush()
}
// flush writes the file. The caller must hold the write lock.
func (u *Users) flush() error {
list := make([]User, 0, len(u.items))
for _, usr := range u.items {
list = append(list, usr)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := u.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write users: %w", err)
}
if err := os.Rename(tmp, u.path); err != nil {
return fmt.Errorf("replace users file: %w", err)
}
return nil
}
func redactUser(usr User) User {
usr.PasswordHash = ""
return usr
}
+131
View File
@@ -0,0 +1,131 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func newTestUsers(t *testing.T) (*Users, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "users.json")
u, err := NewUsers(path)
if err != nil {
t.Fatalf("NewUsers: %v", err)
}
return u, path
}
func TestUsersCreateAndVerify(t *testing.T) {
u, _ := newTestUsers(t)
usr, err := u.Create("Alice", "correct-password")
if err != nil {
t.Fatalf("Create: %v", err)
}
if usr.PasswordHash != "" {
t.Fatal("Create must return a redacted user")
}
if usr.ID == "" {
t.Fatal("an id should have been generated")
}
if _, err := u.Verify("alice", "correct-password"); err != nil {
t.Fatalf("Verify with matching case-insensitive username: %v", err)
}
if _, err := u.Verify("Alice", "wrong-password"); err != ErrInvalidCredentials {
t.Fatalf("Verify with wrong password = %v, want ErrInvalidCredentials", err)
}
if _, err := u.Verify("nobody", "correct-password"); err != ErrInvalidCredentials {
t.Fatalf("Verify with unknown username = %v, want ErrInvalidCredentials", err)
}
}
func TestUsersUsernameUniqueCaseInsensitive(t *testing.T) {
u, _ := newTestUsers(t)
if _, err := u.Create("Bob", "password1"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := u.Create("bob", "password2"); err != ErrUsernameTaken {
t.Fatalf("Create with case-different duplicate = %v, want ErrUsernameTaken", err)
}
}
func TestUsersValidation(t *testing.T) {
u, _ := newTestUsers(t)
if _, err := u.Create("", "password"); err == nil {
t.Fatal("empty username should be rejected")
}
if _, err := u.Create("carol", ""); err == nil {
t.Fatal("empty password should be rejected")
}
if _, err := u.Create("dave", strings.Repeat("x", 73)); err != ErrPasswordTooLong {
t.Fatalf("73-byte password = %v, want ErrPasswordTooLong", err)
}
}
func TestUsersDeleteRefusesLastAccount(t *testing.T) {
u, _ := newTestUsers(t)
a, err := u.Create("alice", "password1")
if err != nil {
t.Fatalf("Create: %v", err)
}
b, err := u.Create("bob", "password2")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := u.Delete(a.ID); err != nil {
t.Fatalf("Delete first account: %v", err)
}
if err := u.Delete(b.ID); err != ErrLastAccount {
t.Fatalf("Delete last account = %v, want ErrLastAccount", err)
}
if u.Count() != 1 {
t.Fatalf("Count = %d, want 1", u.Count())
}
}
func TestUsersPersistAcrossReload(t *testing.T) {
u, path := newTestUsers(t)
if _, err := u.Create("alice", "correct-password"); err != nil {
t.Fatalf("Create: %v", err)
}
reopened, err := NewUsers(path)
if err != nil {
t.Fatalf("NewUsers: %v", err)
}
if reopened.Count() != 1 {
t.Fatalf("Count after reload = %d, want 1", reopened.Count())
}
if _, err := reopened.Verify("alice", "correct-password"); err != nil {
t.Fatalf("Verify after reload: %v", err)
}
}
func TestUsersTouchLastLogin(t *testing.T) {
u, _ := newTestUsers(t)
usr, err := u.Create("alice", "correct-password")
if err != nil {
t.Fatalf("Create: %v", err)
}
if usr.LastLoginAt != nil {
t.Fatal("a fresh account should have no last login")
}
if err := u.TouchLastLogin(usr.ID); err != nil {
t.Fatalf("TouchLastLogin: %v", err)
}
got, err := u.Get(usr.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.LastLoginAt == nil {
t.Fatal("LastLoginAt should be set after TouchLastLogin")
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>DockMV</title>
<script type="module" crossorigin src="/assets/index-qcSVszEj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
<script type="module" crossorigin src="/assets/index-4h69aJoO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjhDzpx2.css">
</head>
<body>
<div id="root"></div>