Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user