Files
ntfywui/internal/app/handlers_admins.go
2026-01-12 13:51:52 +01:00

200 lines
5.5 KiB
Go

package app
import (
"net/http"
"strings"
"time"
"github.com/yourorg/ntfywui/internal/security"
"github.com/yourorg/ntfywui/internal/store"
)
func (s *Server) handleAdmins(w http.ResponseWriter, r *http.Request) {
admin, _ := s.currentAdmin(r)
switch r.Method {
case http.MethodGet:
csrf, _ := s.csrfEnsure(w, r)
flash := s.popFlash(w, r)
s.renderer.Render(w, "admins.html", PageData{
Title: "Admins",
Admin: admin.Username,
Role: string(admin.Role),
CSRF: csrf,
Flash: flash,
Admins: s.admins.List(),
})
case http.MethodPost:
_ = r.ParseForm()
action := r.Form.Get("action")
username := cleanUser(r.Form.Get("username"))
switch action {
case "create":
pass := r.Form.Get("password")
role := store.Role(strings.TrimSpace(r.Form.Get("role")))
if role == "" {
role = store.RoleOperator
}
if username == "" || pass == "" {
s.setFlash(w, r, "Username und Passwort erforderlich")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
if role != store.RoleViewer && role != store.RoleOperator && role != store.RoleAdmin {
s.setFlash(w, r, "Ungültige Rolle")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
if _, ok := s.admins.Get(username); ok {
s.setFlash(w, r, "Admin existiert bereits")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
salt := make([]byte, 16)
_, _ = randRead(salt)
hash := security.HashPasswordPBKDF2(pass, salt, 120_000)
a := store.Admin{
Username: username,
Role: role,
PassHash: hash,
CreatedAt: time.Now().Unix(),
}
if err := s.admins.Set(a); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_create", username, map[string]string{"role": string(role)})
s.setFlash(w, r, "Admin erstellt")
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
case "set-role":
role := store.Role(strings.TrimSpace(r.Form.Get("role")))
a, ok := s.admins.Get(username)
if !ok {
http.NotFound(w, r)
return
}
if role != store.RoleViewer && role != store.RoleOperator && role != store.RoleAdmin {
s.setFlash(w, r, "Ungültige Rolle")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
a.Role = role
if err := s.admins.Set(a); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_set_role", username, map[string]string{"role": string(role)})
s.setFlash(w, r, "Rolle geändert")
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
case "set-pass":
pass := r.Form.Get("password")
if pass == "" {
s.setFlash(w, r, "Passwort erforderlich")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
a, ok := s.admins.Get(username)
if !ok {
http.NotFound(w, r)
return
}
salt := make([]byte, 16)
_, _ = randRead(salt)
a.PassHash = security.HashPasswordPBKDF2(pass, salt, 120_000)
if err := s.admins.Set(a); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_set_pass", username, nil)
s.setFlash(w, r, "Passwort geändert")
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
case "toggle-disable":
a, ok := s.admins.Get(username)
if !ok {
http.NotFound(w, r)
return
}
// Prevent self-lockout
if username == admin.Username {
s.setFlash(w, r, "Du kannst dich nicht selbst deaktivieren")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
a.Disabled = !a.Disabled
if err := s.admins.Set(a); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_toggle_disable", username, map[string]string{"disabled": boolStr(a.Disabled)})
s.setFlash(w, r, "Aktualisiert")
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
case "2fa-enable":
a, ok := s.admins.Get(username)
if !ok {
http.NotFound(w, r)
return
}
secret, err := security.GenerateTOTPSecret()
if err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
a.TOTPSecret = secret
if err := s.admins.Set(a); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_2fa_enable", username, nil)
s.setFlash(w, r, "2FA Secret (speichern!): "+secret)
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
case "2fa-disable":
a, ok := s.admins.Get(username)
if !ok {
http.NotFound(w, r)
return
}
a.TOTPSecret = ""
if err := s.admins.Set(a); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_2fa_disable", username, nil)
s.setFlash(w, r, "2FA deaktiviert")
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
case "delete":
// Prevent deleting self
if username == admin.Username {
s.setFlash(w, r, "Du kannst dich nicht selbst löschen")
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
return
}
if err := s.admins.Delete(username); err != nil {
s.setFlash(w, r, "Fehler: "+err.Error())
} else {
s.auditEvent(r, "webui_admin_delete", username, nil)
s.setFlash(w, r, "Admin gelöscht")
}
http.Redirect(w, r, s.abs("/admins"), http.StatusFound)
default:
http.Error(w, "bad request", http.StatusBadRequest)
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}