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
+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")
}
}