Multiple users, all admins

The single admin account becomes a list of users; config.json moves to
version 2 and the old admin is migrated on first start. Every user is an
admin. Sessions are tied to a user and their password, so deleting a user
or resetting a password signs them out at once. API tokens belong to the
user who made them and go away with that user.

Admins add users with a temporary password and choose whether it must be
changed at first sign-in; until then the API refuses everything but the
password change. Settings gets My account and Users cards, and the token
table shows each token's owner. 'GHOSTWIRE passwd [username]' resets any
user's password. A failed update now also restores config.json, since the
new version may have upgraded it.
This commit is contained in:
Daniel Redetzke
2026-10-04 15:51:14 +03:00
parent f703618b9d
commit 4ecfddf06a
11 changed files with 825 additions and 109 deletions
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"net/http"
"slices"
"strings"
"time"
)
// Users of the web interface. Every user is an admin; the only rules are
// that nobody deletes themselves (so one user always remains) and that
// everyone changes their own password with the current one.
type userView struct {
ID string `json:"id"`
Username string `json:"username"`
Note string `json:"note"`
MustChangePassword bool `json:"mustChangePassword"`
Created time.Time `json:"created"`
LastLogin *tokenUse `json:"lastLogin"` // since the service started
Tokens int `json:"tokens"`
You bool `json:"you"`
}
func (a *App) userView(c *Config, u *User, me string) userView {
n := 0
for _, t := range c.APITokens {
if t.UserID == u.ID {
n++
}
}
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me}
}
// username names a user for lists, or "" if the ID is unknown.
func (a *App) username(c *Config, id string) string {
if _, u := c.userByID(id); u != nil {
return u.Username
}
return ""
}
func (a *App) listUsers(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
out := make([]userView, 0, len(cfg.Users))
for i := range cfg.Users {
out = append(out, a.userView(cfg, &cfg.Users[i], who(r).UserID))
}
writeJSON(w, http.StatusOK, map[string]any{"users": out})
}
// newPasswordHash checks and hashes a password an admin chose for someone.
func newPasswordHash(pw string) (string, error) {
if err := validatePassword(pw); err != nil {
return "", err
}
return hashPassword(pw)
}
func (a *App) createUser(w http.ResponseWriter, r *http.Request) {
var in struct {
Username string `json:"username"`
Note string `json:"note"`
Password string `json:"password"`
MustChangePassword *bool `json:"mustChangePassword"` // default true
}
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
hash, err := newPasswordHash(in.Password)
if err != nil {
writeErr(w, err)
return
}
u := User{
ID: newID(), Username: strings.TrimSpace(in.Username), Note: strings.TrimSpace(in.Note),
PasswordHash: hash, MustChangePassword: in.MustChangePassword == nil || *in.MustChangePassword,
Created: time.Now().UTC(),
}
if err := a.store.Update(func(c *Config) error {
if c.userByName(u.Username) != nil {
return badRequest("username %q is taken", u.Username)
}
c.Users = append(c.Users, u)
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "user created", "user", u.Username, "mustChangePassword", u.MustChangePassword)
cfg := a.store.Get()
_, saved := cfg.userByID(u.ID)
writeJSON(w, http.StatusCreated, map[string]any{"user": a.userView(cfg, saved, who(r).UserID)})
}
// patchUser renames a user, changes the note, or sets or clears the
// "must change password" flag.
func (a *App) patchUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
m, err := decodeFields(r)
if err != nil {
writeErr(w, err)
return
}
var name string
var changed []string
err = a.store.Update(func(c *Config) error {
_, u := c.userByID(id)
if u == nil {
return badRequest("no such user")
}
for _, f := range []struct {
key string
dst any
}{
{"username", &u.Username}, {"note", &u.Note}, {"mustChangePassword", &u.MustChangePassword},
} {
if raw, ok := m[f.key]; ok {
if err := json.Unmarshal(raw, f.dst); err != nil {
return badRequest("%s: %v", f.key, err)
}
changed = append(changed, f.key)
}
}
u.Username = strings.TrimSpace(u.Username)
u.Note = strings.TrimSpace(u.Note)
if other := c.userByName(u.Username); other != nil && other.ID != u.ID {
return badRequest("username %q is taken", u.Username)
}
name = u.Username
return nil
})
if err != nil {
writeErr(w, err)
return
}
a.audit(r, "user updated", "user", name, "fields", changed)
cfg := a.store.Get()
_, u := cfg.userByID(id)
writeJSON(w, http.StatusOK, map[string]any{"user": a.userView(cfg, u, who(r).UserID)})
}
// resetPassword sets a password for another user. Their sessions end,
// because sessions are tied to the password they started with.
func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == who(r).UserID {
writeErr(w, badRequest("change your own password under My account"))
return
}
var in struct {
Password string `json:"password"`
MustChangePassword *bool `json:"mustChangePassword"` // default true
}
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
hash, err := newPasswordHash(in.Password)
if err != nil {
writeErr(w, err)
return
}
must := in.MustChangePassword == nil || *in.MustChangePassword
var name string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(id)
if u == nil {
return badRequest("no such user")
}
u.PasswordHash, u.MustChangePassword = hash, must
name = u.Username
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "user password reset", "user", name, "mustChangePassword", must)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// deleteUser removes a user and their API tokens.
func (a *App) deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == who(r).UserID {
writeErr(w, badRequest("you cannot delete yourself"))
return
}
var name string
var tokens int
if err := a.store.Update(func(c *Config) error {
i, u := c.userByID(id)
if u == nil {
return badRequest("no such user")
}
name = u.Username
n := len(c.APITokens)
c.APITokens = slices.DeleteFunc(c.APITokens, func(t APIToken) bool { return t.UserID == id })
tokens = n - len(c.APITokens)
c.Users = slices.Delete(c.Users, i, i+1)
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "user deleted", "user", name, "tokensRevoked", tokens)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}