212 lines
6.1 KiB
Go
212 lines
6.1 KiB
Go
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"`
|
|
MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
|
|
}
|
|
|
|
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, mfaSummary(u)}
|
|
}
|
|
|
|
// 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})
|
|
}
|