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>
232 lines
6.1 KiB
Go
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
|
|
}
|