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
+54 -23
View File
@@ -77,7 +77,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
return
}
if adminOnly && !p.IsAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the admin account can do this"})
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot do this; sign in to the web interface"})
return
}
if p.MustChangePassword && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"})
return
}
if p.Scope == "ro" && r.Method != http.MethodGet {
@@ -106,6 +110,11 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
g("GET /api/v1/auth/me", a.me)
adm("POST /api/v1/auth/password", a.changePassword)
adm("GET /api/v1/users", a.listUsers)
adm("POST /api/v1/users", a.createUser)
adm("PATCH /api/v1/users/{id}", a.patchUser)
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
adm("DELETE /api/v1/users/{id}", a.deleteUser)
g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats)
@@ -135,7 +144,7 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings and read logs.
// Password, tokens and backups stay with the admin account.
// Users, passwords, tokens and backups need a signed-in user.
g("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/restart", a.restart)
@@ -190,15 +199,18 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, code, map[string]string{"error": err.Error()})
return
}
cfg := a.store.Get()
http.SetCookie(w, &http.Cookie{
Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode, MaxAge: cfg.Web.SessionHours * 3600,
})
a.setSessionCookie(w, r, id)
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) setSessionCookie(w http.ResponseWriter, r *http.Request, id string) {
http.SetCookie(w, &http.Cookie{
Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode, MaxAge: a.store.Get().Web.SessionHours * 3600,
})
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName()); err == nil {
a.auth.Logout(c.Value)
@@ -209,19 +221,30 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) {
func (a *App) me(w http.ResponseWriter, r *http.Request) {
p := who(r)
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "version": version})
writeJSON(w, http.StatusOK, map[string]any{
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "version": version,
})
}
// changePassword changes the signed-in user's own password. Their other
// sessions end; this one continues with a new session id.
func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
var in struct{ Current, New string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
if !verifyPassword(a.store.Get().Admin.PasswordHash, in.Current) {
id := who(r).UserID
_, u := a.store.Get().userByID(id)
if u == nil || !verifyPassword(u.PasswordHash, in.Current) {
writeErr(w, badRequest("current password is wrong"))
return
}
if in.New == in.Current {
writeErr(w, badRequest("choose a password different from the current one"))
return
}
if err := validatePassword(in.New); err != nil {
writeErr(w, err)
return
@@ -231,12 +254,24 @@ func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
if err := a.store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; return nil }); err != nil {
var updated User
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, false
updated = *u
return nil
}); err != nil {
writeErr(w, err)
return
}
if c, err := r.Cookie(cookieName()); err == nil {
a.auth.Logout(c.Value)
}
a.setSessionCookie(w, r, a.auth.NewSession(&updated))
a.audit(r, "password changed")
a.auth.DropSessions()
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -921,7 +956,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"log": cfg.Log,
"stats": cfg.Stats,
"geo": a.geoStatus(),
"adminUsername": cfg.Admin.Username,
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath,
})
@@ -933,18 +968,12 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
if _, ok := m["adminUsername"]; ok && !who(r).IsAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the admin account can change the username"})
if _, ok := m["adminUsername"]; ok {
writeErr(w, badRequest("usernames are changed under /users"))
return
}
var restart bool
err = a.store.Update(func(c *Config) error {
if err := field(m, "adminUsername", &c.Admin.Username); err != nil {
return err
}
if strings.TrimSpace(c.Admin.Username) == "" {
return badRequest("username cannot be empty")
}
before, _ := json.Marshal(c.Web)
if err := field(m, "web", &c.Web); err != nil {
return err
@@ -978,14 +1007,16 @@ type tokenView struct {
ID string `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
Owner string `json:"owner"` // username
Created time.Time `json:"created"`
LastUsed *tokenUse `json:"lastUsed"`
}
func (a *App) listTokens(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
out := []tokenView{}
for _, t := range a.store.Get().APITokens {
out = append(out, tokenView{t.ID, t.Name, t.Scope, t.Created, a.auth.TokenUse(t.ID)})
for _, t := range cfg.APITokens {
out = append(out, tokenView{t.ID, t.Name, t.Scope, a.username(cfg, t.UserID), t.Created, a.auth.TokenUse(t.ID)})
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
@@ -1005,7 +1036,7 @@ func (a *App) createToken(w http.ResponseWriter, r *http.Request) {
in.Scope = "rw"
}
secret := tokenPrefix + randomString(32)
t := APIToken{ID: newID(), Name: in.Name, Hash: hashToken(secret), Scope: in.Scope, Created: time.Now().UTC()}
t := APIToken{ID: newID(), Name: in.Name, Hash: hashToken(secret), Scope: in.Scope, UserID: who(r).UserID, Created: time.Now().UTC()}
if err := a.store.Update(func(c *Config) error { c.APITokens = append(c.APITokens, t); return nil }); err != nil {
writeErr(w, err)
return