feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
+243
-41
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||
@@ -36,9 +37,12 @@ type Server struct {
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
}
|
||||
|
||||
type viewData struct {
|
||||
@@ -74,6 +78,7 @@ type viewData struct {
|
||||
ErrorPiHoles []model.PiHoleIntegration
|
||||
ErrorCount int
|
||||
PiHoleView *piHoleView
|
||||
SoftwareUpdate appupdate.State
|
||||
}
|
||||
|
||||
type flashMessage struct {
|
||||
@@ -91,12 +96,12 @@ type accessZone struct {
|
||||
ManagedTargetIDs map[int64]bool
|
||||
}
|
||||
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) {
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
||||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||||
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
||||
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
|
||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||
mux.Handle("GET /security", s.authenticated(s.securityPage))
|
||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||
mux.Handle("POST /account/password", s.authenticated(s.passwordChange))
|
||||
mux.Handle("GET /security", s.authenticated(s.securityRedirect))
|
||||
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
|
||||
mux.Handle("GET /users", s.adminOnly(s.usersPage))
|
||||
mux.Handle("GET /access", s.adminOnly(s.usersPage))
|
||||
mux.Handle("GET /users", s.adminOnly(s.usersRedirect))
|
||||
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
|
||||
mux.Handle("POST /users", s.adminOnly(s.userCreate))
|
||||
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
|
||||
@@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
|
||||
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
|
||||
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
|
||||
mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI))
|
||||
return s.securityHeaders(mux)
|
||||
}
|
||||
|
||||
@@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
|
||||
if errors.Is(err, store.ErrAdminAlreadyConfigured) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
username, usernameErr := normalizeUsername(r.FormValue("username"))
|
||||
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
|
||||
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
lookupOK := usernameErr == nil && err == nil
|
||||
if !lookupOK {
|
||||
hash = s.dummyHash
|
||||
}
|
||||
passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password"))
|
||||
if !lookupOK || !user.Active || !passwordOK {
|
||||
s.limiter.Fail(clientIP)
|
||||
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
|
||||
s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
|
||||
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientIP)
|
||||
@@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirectNotice(w, r, "/", "Angemeldet.")
|
||||
}
|
||||
|
||||
func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool {
|
||||
if s.authSlots == nil {
|
||||
return secure.VerifyPassword(hash, password)
|
||||
}
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case s.authSlots <- struct{}{}:
|
||||
defer func() { <-s.authSlots }()
|
||||
return secure.VerifyPassword(hash, password)
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
@@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
|
||||
return
|
||||
}
|
||||
@@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
|
||||
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error")
|
||||
return
|
||||
}
|
||||
if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil {
|
||||
s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username)
|
||||
s.updates.TriggerCheck()
|
||||
s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
err := s.updates.CheckNow(checkCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, appupdate.ErrCredentialMissing) {
|
||||
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(r.FormValue("version"))
|
||||
startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
err := s.updates.RequestUpdate(startCtx, version)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)})
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username)
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "version": version})
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
status, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
state := s.updates.State()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"agent_available": statusErr == nil,
|
||||
"phase": status.Phase,
|
||||
"message": status.Message,
|
||||
"target_version": status.TargetVersion,
|
||||
"current_version": state.CurrentVersion,
|
||||
"latest_version": state.LatestVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||
if err != nil {
|
||||
@@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
|
||||
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
|
||||
}
|
||||
|
||||
func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||
func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
http.Redirect(w, r, "/account", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||
return
|
||||
}
|
||||
if r.FormValue("new_password") != r.FormValue("confirm_password") {
|
||||
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||
s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||
return
|
||||
}
|
||||
newHash, err := secure.HashPassword(r.FormValue("new_password"))
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, "/security", err.Error(), "error")
|
||||
s.redirectNotice(w, r, "/account", err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
|
||||
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
|
||||
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
|
||||
s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||
s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||
}
|
||||
|
||||
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
@@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S
|
||||
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
||||
"running": s.scheduler.IsRunning(),
|
||||
"enabled": settings.UpdaterEnabled,
|
||||
"last_ipv4": settings.LastIPv4,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||
s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
http.Redirect(w, r, "/access", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
|
||||
return
|
||||
}
|
||||
hash, err := secure.HashPassword(r.FormValue("password"))
|
||||
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.renderUserNewError(w, session, err.Error(), form)
|
||||
return
|
||||
@@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
|
||||
}
|
||||
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
|
||||
}
|
||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
|
||||
return
|
||||
}
|
||||
if managedUser.IsAdmin() && (role != "admin" || !active) {
|
||||
count, countErr := s.store.CountActiveAdmins(r.Context())
|
||||
if countErr != nil {
|
||||
s.serverError(w, countErr)
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||
if errors.Is(err, store.ErrLastActiveAdmin) {
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
if role != managedUser.Role || active != managedUser.Active {
|
||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
|
||||
}
|
||||
@@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||||
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
|
||||
}
|
||||
@@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
|
||||
if settings, err := s.store.GetSettings(context.Background()); err == nil {
|
||||
data.UpdaterEnabled = settings.UpdaterEnabled
|
||||
}
|
||||
if data.IsAdmin && s.updates != nil {
|
||||
data.SoftwareUpdate = s.updates.State()
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
|
||||
}
|
||||
|
||||
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) serverError(w http.ResponseWriter, err error) {
|
||||
@@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func loadTemplates() (map[string]*template.Template, error) {
|
||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
|
||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"}
|
||||
functions := template.FuncMap{
|
||||
"formatTime": func(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
@@ -2043,15 +2202,30 @@ func remoteIP(r *http.Request) string {
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
loginAttemptLimit = 5
|
||||
loginAttemptWindow = 15 * time.Minute
|
||||
loginLimiterCleanupEvery = time.Minute
|
||||
loginLimiterMaxTrackedIPs = 4096
|
||||
)
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()}
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
|
||||
func (l *loginLimiter) Allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := time.Now().Add(-15 * time.Minute)
|
||||
now := time.Now()
|
||||
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery {
|
||||
l.cleanupLocked(now)
|
||||
}
|
||||
cutoff := now.Add(-loginAttemptWindow)
|
||||
values := l.attempts[ip]
|
||||
kept := values[:0]
|
||||
for _, value := range values {
|
||||
@@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
l.attempts[ip] = kept
|
||||
return len(kept) < 5
|
||||
if len(kept) == 0 {
|
||||
delete(l.attempts, ip)
|
||||
} else {
|
||||
l.attempts[ip] = kept
|
||||
}
|
||||
if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||
return false
|
||||
}
|
||||
return len(kept) < loginAttemptLimit
|
||||
}
|
||||
func (l *loginLimiter) Fail(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||
return
|
||||
}
|
||||
l.attempts[ip] = append(l.attempts[ip], time.Now())
|
||||
}
|
||||
func (l *loginLimiter) Reset(ip string) {
|
||||
@@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) {
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) cleanupLocked(now time.Time) {
|
||||
cutoff := now.Add(-loginAttemptWindow)
|
||||
for ip, values := range l.attempts {
|
||||
kept := values[:0]
|
||||
for _, value := range values {
|
||||
if value.After(cutoff) {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.attempts, ip)
|
||||
} else {
|
||||
l.attempts[ip] = kept
|
||||
}
|
||||
}
|
||||
l.lastCleanup = now
|
||||
}
|
||||
Reference in new issue
Block a user