Files
kawaandClaude Sonnet 5 8d20d9be84
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Feature 1: Local accounts, replacing shared token
- 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>
2026-08-24 15:13:54 +02:00

232 lines
6.1 KiB
Go

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
}