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

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package users
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"errors"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
)
// ErrTotpNotEnabled is returned when a second-factor action addresses an
// account that never finished enrolment.
var ErrTotpNotEnabled = errors.New("two-factor authentication is not enabled for that account")
// GenerateTotpSecret returns a fresh authenticator secret, base32
// without padding, the shape the otpauth URI and every application use.
func GenerateTotpSecret() string {
buf := make([]byte, 20)
rand.Read(buf)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
}
// GenerateRecoveryCodes returns count one-time codes, grouped for
// reading, and their SHA-256 digests for storage. The codes are shown
// once and survive only as hashes.
func GenerateRecoveryCodes(count int) (codes, hashes []string) {
for range count {
buf := make([]byte, 10)
rand.Read(buf)
code := hex.EncodeToString(buf)
codes = append(codes, code[:5]+"-"+code[5:10]+"-"+code[10:15]+"-"+code[15:20])
hashes = append(hashes, RecoveryHash(codes[len(codes)-1]))
}
return codes, hashes
}
// RecoveryHash is the stored form of a recovery code.
func RecoveryHash(code string) string {
sum := sha256.Sum256([]byte(normaliseCode(code)))
return hex.EncodeToString(sum[:])
}
// EnableTotp turns the second factor on in one write: the verified
// secret, a zero replay floor and the hashed recovery codes.
func (u *Users) EnableTotp(username, secret string, recoveryHashes []string) (*User, error) {
return u.mutate(username, func(user *User) {
user.TotpSecret = secret
user.TotpStep = 0
user.Recovery = append([]string(nil), recoveryHashes...)
})
}
// ReplaceRecovery swaps the recovery codes and nothing else: the
// secret and the replay floor stay, the old codes stop working.
func (u *Users) ReplaceRecovery(username string, recoveryHashes []string) (*User, error) {
return u.mutate(username, func(user *User) {
user.Recovery = append([]string(nil), recoveryHashes...)
})
}
// ClearTotp turns the second factor off: the secret, the replay floor
// and every remaining recovery code go together, so nothing of the old
// factor survives to answer a future prompt.
func (u *Users) ClearTotp(username string) (*User, error) {
return u.mutate(username, func(user *User) {
user.TotpSecret = ""
user.TotpStep = 0
user.Recovery = nil
})
}
// VerifyTotp checks a code against the account's secret and, on
// success, advances the replay floor in the same locked write. A wrong
// code changes nothing, and a code already used is refused.
func (u *Users) VerifyTotp(username, code string, now time.Time) bool {
user := u.Find(username)
if user == nil || user.TotpSecret == "" {
return false
}
secret, err := decodeTotpSecret(user.TotpSecret)
if err != nil {
return false
}
ok, step := totp.Validate(secret, code, now, user.TotpStep)
if !ok {
return false
}
if _, err := u.mutate(username, func(user *User) { user.TotpStep = step }); err != nil {
return false
}
return true
}
// ConsumeRecovery spends one recovery code. The code is matched against
// the stored hashes in constant time and removed in the same locked
// write, so a code works exactly once.
func (u *Users) ConsumeRecovery(username, code string) bool {
want := RecoveryHash(code)
user := u.Find(username)
if user == nil || len(user.Recovery) == 0 {
return false
}
index := -1
for i, have := range user.Recovery {
if subtle.ConstantTimeCompare([]byte(have), []byte(want)) == 1 {
index = i
break
}
}
if index < 0 {
return false
}
_, err := u.mutate(username, func(user *User) {
user.Recovery = append(user.Recovery[:index], user.Recovery[index+1:]...)
})
return err == nil
}
func decodeTotpSecret(encoded string) ([]byte, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
}
// normaliseCode strips the shapes a human types around a recovery code.
func normaliseCode(code string) string {
return strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(code))
}
// readTotpRecovery converts the stored TOML array back to strings.
func readTotpRecovery(entry map[string]any) []string {
out := tomlfile.Strings(entry["recovery"])
if len(out) == 0 {
return nil
}
return out
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package users
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
)
func totpStore(t *testing.T) *Users {
t.Helper()
return New(filepath.Join(t.TempDir(), "users.toml"))
}
func TestTotpLifecycle(t *testing.T) {
u := totpStore(t)
if _, err := u.Add("petr", "correct-horse-9", "admin"); err != nil {
t.Fatal(err)
}
secret := GenerateTotpSecret()
if len(secret) != 32 { // 20 bytes as base32
t.Fatalf("secret length = %d", len(secret))
}
now := time.Unix(1_000_000_000, 0)
code := totpCode(t, secret, now)
if u.VerifyTotp("petr", code, now) {
t.Fatal("unenabled account accepted a code")
}
codes, hashes := GenerateRecoveryCodes(10)
if len(codes) != 10 || len(hashes) != 10 {
t.Fatalf("recovery = %d/%d", len(codes), len(hashes))
}
if strings.Contains(strings.Join(codes, " "), "-") == false {
t.Fatal("codes are not grouped for reading")
}
if _, err := u.EnableTotp("petr", secret, hashes); err != nil {
t.Fatal(err)
}
stored := u.Find("petr")
if stored.TotpSecret != secret || len(stored.Recovery) != 10 {
t.Fatalf("stored = %+v", stored)
}
// A current code works and advances the floor; the same code is a
// replay and is refused.
if !u.VerifyTotp("petr", code, now) {
t.Fatal("current code refused")
}
if u.VerifyTotp("petr", code, now) {
t.Fatal("replayed code accepted")
}
later := now.Add(2 * totp.Step)
if !u.VerifyTotp("petr", totpCode(t, secret, later), later) {
t.Fatal("next window refused")
}
// A recovery code works exactly once.
if !u.ConsumeRecovery("petr", codes[0]) {
t.Fatal("recovery code refused")
}
if u.ConsumeRecovery("petr", codes[0]) {
t.Fatal("recovery code accepted twice")
}
if u.ConsumeRecovery("petr", "not-a-code") {
t.Fatal("unknown recovery code accepted")
}
// Replacement drops the old codes and keeps the secret.
fresh, freshHashes := GenerateRecoveryCodes(10)
if _, err := u.ReplaceRecovery("petr", freshHashes); err != nil {
t.Fatal(err)
}
if u.ConsumeRecovery("petr", codes[1]) {
t.Fatal("old recovery code survived replacement")
}
if !u.ConsumeRecovery("petr", fresh[0]) {
t.Fatal("fresh recovery code refused")
}
// Clearing removes everything of the factor.
if _, err := u.ClearTotp("petr"); err != nil {
t.Fatal(err)
}
if u.VerifyTotp("petr", totpCode(t, secret, later.Add(2*totp.Step)), later.Add(2*totp.Step)) {
t.Fatal("cleared account still accepts codes")
}
}
// TestTotpPersistsAcrossReopen proves the file carries the factor: a
// reopened store answers with the same secret, floor and codes.
func TestTotpPersistsAcrossReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
if _, err := u.Add("petr", "correct-horse-9", "admin"); err != nil {
t.Fatal(err)
}
secret := GenerateTotpSecret()
codes, hashes := GenerateRecoveryCodes(10)
if _, err := u.EnableTotp("petr", secret, hashes); err != nil {
t.Fatal(err)
}
now := time.Unix(1_000_000_000, 0)
if !u.VerifyTotp("petr", totpCode(t, secret, now), now) {
t.Fatal("first window refused")
}
reopened := New(path)
stored := reopened.Find("petr")
if stored.TotpSecret != secret {
t.Fatalf("secret lost: %q", stored.TotpSecret)
}
if stored.TotpStep == 0 {
t.Fatal("replay floor lost")
}
if len(stored.Recovery) != 10 {
t.Fatalf("recovery codes lost: %d", len(stored.Recovery))
}
if reopened.VerifyTotp("petr", totpCode(t, secret, now), now) {
t.Fatal("replay floor lost across reopen")
}
// The written file holds hashes, never the codes themselves.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), codes[0]) {
t.Fatal("a recovery code is stored in the clear")
}
}
func totpCode(t *testing.T, secret string, at time.Time) string {
t.Helper()
key, err := decodeTotpSecret(secret)
if err != nil {
t.Fatalf("decode: %v", err)
}
return totp.Code(key, at)
}
+588
View File
@@ -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
}
+408
View File
@@ -0,0 +1,408 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package users
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestAddFirst(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
user, err := u.AddFirst("admin", "a-very-good-passphrase", "cs", "plasma", "Petr Balvín")
if err != nil {
t.Fatalf("AddFirst: %v", err)
}
if user.Role != "admin" || user.Language != "cs" || user.Theme != "plasma" || user.Name != "Petr Balvín" {
t.Fatalf("first account = %+v", user)
}
if _, err := u.AddFirst("other", "another-good-passphrase", "", "", ""); !errors.Is(err, ErrUsersExist) {
t.Fatalf("second first account: err = %v", err)
}
if len(u.All()) != 1 {
t.Fatalf("accounts = %v", u.All())
}
}
// The wizard claim is serialised: two concurrent AddFirst calls create
// exactly one account, never two admins racing for the installation.
func TestAddFirstIsSerialised(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
var wg sync.WaitGroup
var mu sync.Mutex
ok := 0
for range 4 {
wg.Go(func() {
if _, err := u.AddFirst("claim", "a-very-good-passphrase", "", "", ""); err == nil {
mu.Lock()
ok++
mu.Unlock()
}
})
}
wg.Wait()
if ok != 1 {
t.Fatalf("%d claims won", ok)
}
if len(u.All()) != 1 {
t.Fatalf("accounts = %v", u.All())
}
}
func stat(path string) (os.FileInfo, error) { return os.Stat(path) }
// The helpers below keep the tests readable now that every mutation
// reports why it failed.
func addSucceeded(u *Users, username, secret, role string) bool {
_, err := u.Add(username, secret, role)
return err == nil
}
func addFailed(u *Users, username, secret, role string) bool {
return !addSucceeded(u, username, secret, role)
}
func mustAdd(t *testing.T, u *Users, username, secret, role string) {
t.Helper()
if _, err := u.Add(username, secret, role); err != nil {
t.Fatalf("Add(%q): %v", username, err)
}
}
func renameSucceeded(u *Users, from, to string) bool {
_, err := u.Rename(from, to)
return err == nil
}
func renameFailed(u *Users, from, to string) bool { return !renameSucceeded(u, from, to) }
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
}
func TestNoAccountsBeforeFirstAdd(t *testing.T) {
u := New(filepath.Join(t.TempDir(), "users.toml"))
if u.Any() {
t.Fatal("a missing file must hold no accounts: the first-run wizard is the only creator")
}
}
func TestAddAndPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
added, err := u.Add("petr", "heslo12345", "admin")
if err != nil {
t.Fatalf("Add: %v", err)
}
if added == nil {
t.Fatal("Add failed")
}
if added.Role != "admin" {
t.Fatalf("role = %q", added.Role)
}
// Invalid role falls back to the default.
if second, err := u.Add("joe", "heslo12345", "root"); err != nil || second.Role != DefaultRole {
t.Fatalf("second = %v", second)
}
// Duplicate and empty names are rejected.
if addSucceeded(u, "petr", "x", "admin") {
t.Fatal("duplicate accepted")
}
if addSucceeded(u, "", "x", "admin") {
t.Fatal("empty name accepted")
}
reopened := New(path)
if len(reopened.All()) != 2 {
t.Fatalf("reopened users = %v", reopened.All())
}
if reopened.Authenticate("petr", "heslo12345") == nil {
t.Fatal("authenticate failed after reopen")
}
info, err := stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v, want 0600", info.Mode().Perm())
}
}
func TestUpdates(t *testing.T) {
u := New(filepath.Join(t.TempDir(), "users.toml"))
if addFailed(u, "petr", "heslo12345", "admin") {
t.Fatal("Add failed")
}
if got, err := u.UpdateName("petr", "Petr Balvín"); err != nil || got.Name != "Petr Balvín" {
t.Fatalf("UpdateName = %v", got)
}
if got, err := u.UpdateName("petr", ""); err != nil || got.Name != "" {
t.Fatalf("clear name = %v", got)
}
if got, err := u.UpdateFediverseCreator("petr", "@petr@social"); err != nil || got.FediverseCreator != "@petr@social" {
t.Fatalf("UpdateFediverseCreator = %v", got)
}
if got, err := u.UpdatePhoto("petr", "/media/p.webp"); err != nil || got.Photo != "/media/p.webp" {
t.Fatalf("UpdatePhoto = %v", got)
}
if _, err := u.UpdatePassword("petr", "noveheslo123"); err != nil {
t.Fatal("UpdatePassword failed")
}
if u.Authenticate("petr", "noveheslo123") == nil {
t.Fatal("new password does not verify")
}
if _, err := u.UpdateName("missing", "x"); err == nil {
t.Fatal("update of missing user succeeded")
}
}
func TestUpdateLanguage(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
mustAdd(t, u, "petr", "heslo12345", "admin")
if got, err := u.UpdateLanguage("petr", "cs"); err != nil || got.Language != "cs" {
t.Fatalf("UpdateLanguage = %v, %v", got, err)
}
// The choice survives the round trip through the users file, and an
// unknown language is refused rather than stored.
if u.Find("petr").Language != "cs" {
t.Fatal("language did not persist")
}
if _, err := u.UpdateLanguage("petr", "de"); err == nil {
t.Fatal("unsupported language accepted")
}
}
func TestUpdateTheme(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
mustAdd(t, u, "petr", "heslo12345", "admin")
if got, err := u.UpdateTheme("petr", "plasma"); err != nil || got.Theme != "plasma" {
t.Fatalf("UpdateTheme = %v, %v", got, err)
}
// The choice survives the round trip through the users file, and an
// unknown scheme is refused rather than stored.
if u.Find("petr").Theme != "plasma" {
t.Fatal("theme did not persist")
}
if _, err := u.UpdateTheme("petr", "sepia"); err == nil {
t.Fatal("unsupported colour scheme accepted")
}
}
func TestUpdateOrcid(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
mustAdd(t, u, "petr", "heslo12345", "admin")
// A valid iD persists, normalised to its upper-case form.
if got, err := u.UpdateOrcid("petr", "0000-0002-1825-0097"); err != nil ||
got.Orcid != "0000-0002-1825-0097" {
t.Fatalf("UpdateOrcid = %v, %v", got, err)
}
if u.Find("petr").Orcid != "0000-0002-1825-0097" {
t.Fatal("orcid did not persist")
}
// A broken check digit is refused and the old value stays.
if _, err := u.UpdateOrcid("petr", "0000-0002-1825-0098"); err == nil {
t.Fatal("invalid orcid accepted")
}
if u.Find("petr").Orcid != "0000-0002-1825-0097" {
t.Fatal("refused orcid overwrote the stored one")
}
// Empty clears it.
if got, err := u.UpdateOrcid("petr", ""); err != nil || got.Orcid != "" {
t.Fatalf("UpdateOrcid clear = %v, %v", got, err)
}
}
func TestRename(t *testing.T) {
u := New(filepath.Join(t.TempDir(), "users.toml"))
mustAdd(t, u, "petr", "heslo12345", "admin")
mustAdd(t, u, "joe", "heslo12345", "author")
if got, err := u.Rename("petr", "balvin"); err != nil || got.Username != "balvin" {
t.Fatalf("Rename = %v", got)
}
if u.Find("petr") != nil {
t.Fatal("old name still present")
}
if renameSucceeded(u, "balvin", "joe") {
t.Fatal("rename onto existing user succeeded")
}
if renameSucceeded(u, "balvin", "") {
t.Fatal("rename to empty succeeded")
}
}
func TestLastAdminProtection(t *testing.T) {
u := New(filepath.Join(t.TempDir(), "users.toml"))
mustAdd(t, u, "petr", "heslo12345", "admin")
mustAdd(t, u, "joe", "heslo12345", "author")
if got, err := u.SetRole("petr", "author"); err == nil && got != nil {
t.Fatal("demoting the last admin succeeded")
}
if got, err := u.SetRole("petr", "wizard"); err == nil && got != nil {
t.Fatal("invalid role accepted")
}
if got, err := u.Delete("petr"); err == nil || got != "" {
t.Fatal("deleting the last admin succeeded")
}
if got, err := u.Delete("joe"); err != nil || got != "joe" {
t.Fatalf("Delete = %q", got)
}
if got, err := u.Delete("joe"); err == nil || got != "" {
t.Fatal("deleting the last user succeeded")
}
// With two admins, demotion and deletion are allowed.
mustAdd(t, u, "second", "heslo12345", "admin")
if got, err := u.SetRole("petr", "author"); err != nil || got == nil {
t.Fatal("demoting with two admins failed")
}
if got, err := u.SetRole("petr", "admin"); err != nil || got == nil {
t.Fatal("re-promoting failed")
}
if got, err := u.Delete("second"); err != nil || got != "second" {
t.Fatalf("Delete = %q", got)
}
}
func TestCacheInvalidationOnOutOfBandEdit(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
mustAdd(t, u, "petr", "heslo12345", "admin")
if len(u.All()) != 1 {
t.Fatal("want 1 user")
}
other := New(path)
other.Add("joe", "heslo12345", "author")
if got := len(u.All()); got != 2 {
t.Fatalf("cache not invalidated: %d users", got)
}
}
func TestUnreadableFileYieldsNoUsers(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
writeFile(t, path, "not = valid = toml")
u := New(path)
if len(u.All()) != 0 {
t.Fatalf("users = %v, want none", u.All())
}
}
func TestWeakStoredHashRejected(t *testing.T) {
// N=1024 hash: below the policy floor, must not authenticate.
path := filepath.Join(t.TempDir(), "users.toml")
weak := `[[users]]
username = "old"
password_hash = "scrypt$1024$8$1$c2FsdHNhbHRzYWx0c2E=$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA=="
role = "admin"
`
writeFile(t, path, weak)
u := New(path)
if len(u.All()) != 1 {
t.Fatalf("users = %v", u.All())
}
if u.Authenticate("old", "anything") != nil {
t.Fatal("weak hash authenticated")
}
if !strings.Contains(u.All()[0].PasswordHash, "scrypt$1024") {
t.Fatal("hash not loaded")
}
}
// A users file that exists but cannot be parsed fails closed: no
// account is served, a mutation refuses rather than write the empty
// list back, and the unreadable file is left untouched. The first-run
// wizard gate reads the health separately, so a corrupted file never
// turns into an open setup page.
func TestUnreadableFileFailsClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
if u.Any() {
t.Fatal("no account is expected while the file is absent")
}
if err := os.WriteFile(path, []byte("this is not TOML [[["), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
u.Invalidate()
if got := u.All(); len(got) != 0 {
t.Fatalf("a corrupted file yielded %d account(s)", len(got))
}
if err := u.Health(); err == nil {
t.Fatal("Health reported no problem with a corrupted file")
}
// A mutation must refuse rather than write the empty list back.
if addSucceeded(u, "joe", "joes-good-passphrase", "author") {
t.Fatal("Add wrote over an unreadable file")
}
raw, err := os.ReadFile(path)
if err != nil || string(raw) != "this is not TOML [[[" {
t.Fatalf("the unreadable file was overwritten: %q", raw)
}
}
func TestRenameIsSerialised(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
if addFailed(u, "first", "first-good-passphrase", "admin") {
t.Fatal("Add failed")
}
if addFailed(u, "second", "second-good-passphrase", "author") {
t.Fatal("Add failed")
}
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
u.Rename("first", "merged")
u.Rename("second", "merged")
})
}
wg.Wait()
merged := 0
for _, user := range u.All() {
if user.Username == "merged" {
merged++
}
}
if merged > 1 {
t.Fatalf("%d accounts share one username", merged)
}
}
// TestWritersDoNotRaceReaders exercises a mutation against concurrent
// readers: loadLocked writes the cache fields readers read under u.mu,
// so a writer that called it without u.mu would be a data race.
func TestWritersDoNotRaceReaders(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.toml")
u := New(path)
if addFailed(u, "admin", "admin-good-passphrase", "admin") {
t.Fatal("Add failed")
}
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
u.UpdateName("admin", fmt.Sprintf("name-%d", i))
})
}
for range 4 {
wg.Go(func() {
u.All()
u.Health()
u.Find("admin")
})
}
wg.Wait()
}