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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user