Files
volumen/internal/users/users.go
T

589 lines
17 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package users is a file-backed set of admin users stored as a TOML
// [[users]] array. The first account is created by the admin first-run
// wizard, never by an implicit identity in the configuration.
package users
import (
"errors"
"fmt"
"log/slog"
"os"
"slices"
"sync"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/password"
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Roles accepted for user accounts.
var Roles = []string{"admin", "author"}
// The reasons a user change can be refused, so a caller can report which
// one happened rather than guessing from a nil result.
var (
// ErrNameTaken is returned when another account already has the name.
ErrNameTaken = errors.New("that username is taken")
// ErrEmptyName is returned when the name is blank.
ErrEmptyName = errors.New("the username must not be empty")
// ErrNoSuchUser is returned when the account is not in the file.
ErrNoSuchUser = errors.New("no such user")
// ErrLastAdmin is returned when the change would remove the last
// admin, or the last account.
ErrLastAdmin = errors.New("the last admin cannot be removed or demoted")
// ErrUsersExist is returned by AddFirst when the file already
// holds accounts: the first-run wizard may create exactly one.
ErrUsersExist = errors.New("accounts already exist")
)
// DefaultRole is assigned when an unknown role is requested.
const DefaultRole = "author"
// User is one admin/author account.
type User struct {
Username string
PasswordHash string
Role string
Name string
FediverseCreator string
// Orcid is the account's ORCID iD, the author identity that
// pre-fills a post's orcid field and names the person in citations.
Orcid string
Photo string
// Language is the admin interface language the account picked
// ("en" or "cs"); empty inherits the site language.
Language string
// Theme is the admin colour scheme the account picked; empty
// inherits the default scheme.
Theme string
// TotpSecret is the account's authenticator secret, base32; empty
// means the second factor is off. TotpStep is the highest time
// step already answered, the replay floor.
TotpSecret string
TotpStep int64
// Recovery holds the SHA-256 digests of the one-time codes that
// open the account when the authenticator is lost.
Recovery []string
}
// Users is the file-backed collection of admin/authors. Read-modify-
// write transactions are serialised with an internal lock; an mtime
// snapshot of the file invalidates the cache on out-of-band edits.
type Users struct {
path string
mu sync.Mutex
cached []*User
snapshot fileSnapshot
haveCache bool
lock sync.Mutex
}
type fileSnapshot struct {
present bool
mtime int64
size int64
}
// New opens the users file.
func New(path string) *Users {
return &Users{path: path}
}
// All returns every account. A missing file holds no accounts; a file
// that exists but cannot be read or parsed yields no accounts either,
// which fails closed: the server never serves an identity the operator
// cannot account for.
func (u *Users) All() []*User {
u.mu.Lock()
defer u.mu.Unlock()
loaded, err := u.loadLocked()
if err != nil {
slog.Error("users: cannot read the users file", "path", u.path, "error", err)
return nil
}
return cloneList(loaded)
}
// Any reports whether at least one user exists.
func (u *Users) Any() bool { return len(u.All()) > 0 }
// Find returns a copy of the user with the given username, or nil.
func (u *Users) Find(username string) *User {
for _, user := range u.All() {
if user.Username == username {
return user
}
}
return nil
}
// Authenticate verifies the password and returns the user on success.
// An unknown username is verified against a dummy hash as well, so the
// response time does not reveal whether the account exists.
func (u *Users) Authenticate(username, secret string) *User {
user := u.Find(username)
if user == nil {
password.Verify(secret, password.Dummy())
return nil
}
if password.Verify(secret, user.PasswordHash) {
return user
}
return nil
}
// Add creates a user. It fails with ErrNameTaken, ErrEmptyName or the
// error the write returned, so a caller can say which of the three
// happened rather than repeating a guess.
func (u *Users) Add(username, secret, role string) (*User, error) {
u.lock.Lock()
defer u.lock.Unlock()
users, err := u.reload()
if err != nil {
slog.Error("users: refusing to add, the users file is unreadable", "path", u.path, "error", err)
return nil, err
}
if username == "" {
return nil, ErrEmptyName
}
if findIn(users, username) != nil {
return nil, ErrNameTaken
}
if !slices.Contains(Roles, role) {
role = DefaultRole
}
hash, err := password.Hash(secret)
if err != nil {
slog.Warn("users: cannot hash password", "error", err)
return nil, err
}
user := &User{Username: username, PasswordHash: hash, Role: role}
if err := u.persist(append(cloneList(users), user)); err != nil {
return nil, err
}
return user, nil
}
// AddFirst creates the very first admin account in one write: the
// language and the theme the first-run wizard picked are stored with
// it, so the founding record never exists half-set-up. It is refused
// with ErrUsersExist once any account exists, and the check sits under
// the store lock, so two claims arriving at once cannot both open an
// identity: one wins, the other is refused and goes to sign in.
func (u *Users) AddFirst(username, secret, language, theme, name string) (*User, error) {
u.lock.Lock()
defer u.lock.Unlock()
users, err := u.reload()
if err != nil {
slog.Error("users: refusing the first account, the users file is unreadable", "path", u.path, "error", err)
return nil, err
}
if len(users) > 0 {
return nil, ErrUsersExist
}
if username == "" {
return nil, ErrEmptyName
}
if findIn(users, username) != nil {
return nil, ErrNameTaken
}
hash, err := password.Hash(secret)
if err != nil {
slog.Warn("users: cannot hash password", "error", err)
return nil, err
}
user := &User{
Username: username,
PasswordHash: hash,
Role: "admin",
Language: language,
Theme: theme,
Name: name,
}
if err := u.persist([]*User{user}); err != nil {
return nil, err
}
return user, nil
}
// UpdateName sets the display name (empty clears it).
func (u *Users) UpdateName(username, name string) (*User, error) {
return u.mutate(username, func(user *User) {
if name == "" {
user.Name = ""
return
}
user.Name = name
})
}
// UpdateFediverseCreator sets the fediverse handle (empty clears it).
func (u *Users) UpdateFediverseCreator(username, value string) (*User, error) {
return u.mutate(username, func(user *User) {
if value == "" {
user.FediverseCreator = ""
return
}
user.FediverseCreator = value
})
}
// UpdatePhoto sets the profile photo URL (empty clears it).
func (u *Users) UpdatePhoto(username, photo string) (*User, error) {
return u.mutate(username, func(user *User) { user.Photo = photo })
}
// UpdateOrcid sets the account's ORCID iD (empty clears it). Only a
// well-formed iD with a correct check digit is accepted; anything else
// is refused with an error rather than stored broken.
func (u *Users) UpdateOrcid(username, value string) (*User, error) {
value = identifiers.NormalizeORCID(value)
if value != "" && !identifiers.ValidORCID(value) {
return nil, fmt.Errorf("invalid ORCID %q", value)
}
return u.mutate(username, func(user *User) { user.Orcid = value })
}
// UpdateLanguage stores the admin interface language the account
// picked. Only the shipped languages are accepted; anything else is
// refused with an error rather than silently resetting to the default.
func (u *Users) UpdateLanguage(username, lang string) (*User, error) {
if !i18n.Valid(lang) {
return nil, fmt.Errorf("unsupported language %q", lang)
}
return u.mutate(username, func(user *User) { user.Language = lang })
}
// UpdateTheme stores the admin colour scheme the account picked. Only
// the shipped schemes are accepted; anything else is refused with an
// error rather than silently resetting to the default.
func (u *Users) UpdateTheme(username, theme string) (*User, error) {
if !web.ValidTheme(theme) {
return nil, fmt.Errorf("unsupported colour scheme %q", theme)
}
return u.mutate(username, func(user *User) { user.Theme = theme })
}
// UpdatePassword re-hashes and stores a new password.
func (u *Users) UpdatePassword(username, secret string) (*User, error) {
hash, err := password.Hash(secret)
if err != nil {
slog.Warn("users: cannot hash password", "error", err)
return nil, err
}
return u.mutate(username, func(user *User) { user.PasswordHash = hash })
}
// Rename changes the username. It fails with ErrEmptyName, ErrNameTaken
// or ErrNoSuchUser when the new name is blank, taken or the current
// account is missing, and with the write error when the file cannot be
// written.
func (u *Users) Rename(currentName, newName string) (*User, error) {
if newName == "" {
return nil, ErrEmptyName
}
u.lock.Lock()
defer u.lock.Unlock()
users, err := u.reload()
if err != nil {
slog.Error("users: refusing to rename, the users file is unreadable", "path", u.path, "error", err)
return nil, err
}
if findIn(users, newName) != nil {
return nil, ErrNameTaken
}
users = cloneList(users)
user := findIn(users, currentName)
if user == nil {
return nil, ErrNoSuchUser
}
user.Username = newName
if err := u.persist(users); err != nil {
return nil, err
}
return user, nil
}
// cloneList deep-copies the user list so mutations never write to
// objects a concurrent reader may hold.
func cloneList(users []*User) []*User {
out := make([]*User, len(users))
for i, user := range users {
clone := *user
out[i] = &clone
}
return out
}
// SetRole changes the role, refusing to demote the last admin.
func (u *Users) SetRole(username, role string) (*User, error) {
if !slices.Contains(Roles, role) {
return nil, fmt.Errorf("unknown role %q", role)
}
u.lock.Lock()
defer u.lock.Unlock()
users, err := u.reload()
if err != nil {
slog.Error("users: refusing to change a role, the users file is unreadable", "path", u.path, "error", err)
return nil, err
}
users = cloneList(users)
user := findIn(users, username)
if user == nil {
return nil, ErrNoSuchUser
}
if user.Role == "admin" && role != "admin" && adminCount(users) <= 1 {
return nil, ErrLastAdmin
}
user.Role = role
if err := u.persist(users); err != nil {
return nil, err
}
return user, nil
}
// Delete removes a user, refusing to remove the last user or the last
// admin. Returns the removed username, or "".
func (u *Users) Delete(username string) (string, error) {
u.lock.Lock()
defer u.lock.Unlock()
users, err := u.reload()
if err != nil {
slog.Error("users: refusing to delete, the users file is unreadable", "path", u.path, "error", err)
return "", err
}
users = cloneList(users)
target := findIn(users, username)
if target == nil {
return "", ErrNoSuchUser
}
if len(users) <= 1 || (target.Role == "admin" && adminCount(users) <= 1) {
return "", ErrLastAdmin
}
remaining := make([]*User, 0, len(users)-1)
for _, user := range users {
if user.Username != username {
remaining = append(remaining, user)
}
}
if err := u.persist(remaining); err != nil {
return "", err
}
return username, nil
}
func (u *Users) mutate(username string, apply func(*User)) (*User, error) {
u.lock.Lock()
defer u.lock.Unlock()
users, err := u.reload()
if err != nil {
slog.Error("users: refusing to update, the users file is unreadable", "path", u.path, "error", err)
return nil, err
}
users = cloneList(users)
user := findIn(users, username)
if user == nil {
return nil, ErrNoSuchUser
}
apply(user)
if err := u.persist(users); err != nil {
return nil, err
}
return user, nil
}
func findIn(users []*User, username string) *User {
for _, user := range users {
if user.Username == username {
return user
}
}
return nil
}
func adminCount(users []*User) int {
count := 0
for _, user := range users {
if user.Role == "admin" {
count++
}
}
return count
}
// Health reports why the users file cannot be read, or nil when it is
// fine or absent. A diagnostic uses it to say that accounts are
// unreachable rather than reporting a count of zero.
func (u *Users) Health() error {
u.mu.Lock()
defer u.mu.Unlock()
if _, err := u.loadLocked(); err != nil {
return err
}
return nil
}
// Invalidate drops the cache so the next read re-reads the file.
func (u *Users) Invalidate() {
u.mu.Lock()
defer u.mu.Unlock()
u.cached = nil
u.snapshot = fileSnapshot{}
u.haveCache = false
}
// reload re-reads the file under u.mu, dropping the cache first so the
// writer works from what is on disk right now. The caller must hold
// u.lock; taking u.mu inside is the same order persist uses, so the two
// locks never invert.
func (u *Users) reload() ([]*User, error) {
u.mu.Lock()
defer u.mu.Unlock()
u.cached = nil
u.snapshot = fileSnapshot{}
u.haveCache = false
return u.loadLocked()
}
// loadLocked returns the cached account list, rebuilding it when the
// file changed. It returns an error only when the file exists and
// cannot be read or parsed; a missing file is not an error. The caller
// must hold u.mu.
func (u *Users) loadLocked() ([]*User, error) {
snapshot := u.buildSnapshot()
if u.haveCache && snapshot == u.snapshot {
return u.cached, nil
}
users, err := u.readFile()
if err != nil {
return nil, err
}
u.snapshot = snapshot
u.cached = users
u.haveCache = true
return users, nil
}
func (u *Users) buildSnapshot() fileSnapshot {
info, err := os.Stat(u.path)
if err != nil {
return fileSnapshot{}
}
return fileSnapshot{present: true, mtime: info.ModTime().UnixNano(), size: info.Size()}
}
// readFile parses the users file. A missing file yields no users and no
// error; an unreadable or unparsable file yields an error, which the
// caller must surface rather than treat as "no users".
func (u *Users) readFile() ([]*User, error) {
raw, err := os.ReadFile(u.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read %s: %w", u.path, err)
}
data, err := interpres.ParseMap(raw)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", u.path, err)
}
var result []*User
for _, entry := range tomlfile.Tables(data["users"]) {
username := tomlfile.String(entry["username"])
hash := tomlfile.String(entry["password_hash"])
if username == "" {
continue
}
role := tomlfile.String(entry["role"])
if role == "" {
role = DefaultRole
}
result = append(result, &User{
Username: username,
PasswordHash: hash,
Role: role,
Name: tomlfile.String(entry["name"]),
FediverseCreator: tomlfile.String(entry["fediverse_creator"]),
Orcid: tomlfile.String(entry["orcid"]),
Photo: tomlfile.String(entry["photo"]),
Language: tomlfile.String(entry["language"]),
Theme: tomlfile.String(entry["theme"]),
TotpSecret: tomlfile.String(entry["totp_secret"]),
TotpStep: tomlfile.Int64(entry["totp_step"], 0),
Recovery: readTotpRecovery(entry),
})
}
for _, user := range result {
if password.NeedsRehash(user.PasswordHash) {
slog.Warn("users: stored hash uses parameters the login rejects; the password must be reset out of band",
"username", user.Username)
}
}
return result, nil
}
func (u *Users) persist(users []*User) error {
entries := make([]map[string]any, 0, len(users))
for _, user := range users {
entry := map[string]any{
"username": user.Username,
"password_hash": user.PasswordHash,
"role": user.Role,
}
if user.Name != "" {
entry["name"] = user.Name
}
if user.FediverseCreator != "" {
entry["fediverse_creator"] = user.FediverseCreator
}
if user.Orcid != "" {
entry["orcid"] = user.Orcid
}
if user.Photo != "" {
entry["photo"] = user.Photo
}
if user.Language != "" {
entry["language"] = user.Language
}
if user.Theme != "" {
entry["theme"] = user.Theme
}
if user.TotpSecret != "" {
entry["totp_secret"] = user.TotpSecret
entry["totp_step"] = user.TotpStep
}
if len(user.Recovery) > 0 {
recovery := make([]any, len(user.Recovery))
for i, hash := range user.Recovery {
recovery[i] = hash
}
entry["recovery"] = recovery
}
entries = append(entries, entry)
}
if err := tomlfile.Write(u.path, "users", entries); err != nil {
slog.Error("users: cannot persist", "path", u.path, "error", err)
return err
}
u.mu.Lock()
u.cached = nil
u.snapshot = fileSnapshot{}
u.haveCache = false
u.mu.Unlock()
return nil
}