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 }