200 lines
5.5 KiB
Go
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"
|
|
}
|