package backend import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "fmt" "html" "log" "net/http" "strings" "time" ) type contextKey string const ( sessionCookieName = "mail_graveyard_session" currentUserKey = contextKey("currentUser") roleUser = "user" roleManager = "verwalter" roleAdmin = "admin" passwordHashIterations = 120000 defaultSessionValidDays = 7 ) // InitAuth bereitet die Session-/Login-Verwaltung vor. // // Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und // kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen. // - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass. // - Wenn remote erreichbar (hinter Caddy): HTTPS und restriktive Caddy-Regeln // beibehalten. Rollen prueft die App serverseitig. func InitAuth() error { if err := CleanupSessions(); err != nil { return err } if err := CleanupPasswordResets(); err != nil { return err } count, err := CountAppUsers() if err != nil { return err } if count > 0 { return nil } if strings.TrimSpace(Cfg.AdminUser) == "" || Cfg.AdminPass == "" { return fmt.Errorf("admin_user/admin_pass fehlen fuer initialen Login-Benutzer") } hash, err := HashPassword(Cfg.AdminPass) if err != nil { return err } return SaveAppUser(strings.TrimSpace(Cfg.AdminUser), "Admin", hash, roleAdmin, true) } // AuthMiddleware schuetzt alle Routen ausser /login und /static. func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/password/") { next.ServeHTTP(w, r) return } cookie, err := r.Cookie(sessionCookieName) if err != nil || strings.TrimSpace(cookie.Value) == "" { redirectToLogin(w, r) return } user, err := SessionUser(cookie.Value) if err != nil { clearSessionCookie(w) redirectToLogin(w, r) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserKey, user))) }) } func CurrentUser(r *http.Request) AppUser { if u, ok := r.Context().Value(currentUserKey).(AppUser); ok { return u } return AppUser{} } func CanManageAccounts(r *http.Request) bool { return hasRole(CurrentUser(r), roleManager) } func CanManageUsers(r *http.Request) bool { return hasRole(CurrentUser(r), roleManager) } func IsAdmin(r *http.Request) bool { return CurrentUser(r).Role == roleAdmin } func requireManager(w http.ResponseWriter, r *http.Request) bool { if CanManageAccounts(r) { return true } http.Error(w, "Keine Berechtigung.", http.StatusForbidden) return false } func requireAdmin(w http.ResponseWriter, r *http.Request) bool { if IsAdmin(r) { return true } http.Error(w, "Nur Admins duerfen diese Aktion ausfuehren.", http.StatusForbidden) return false } func hasRole(user AppUser, needed string) bool { rank := map[string]int{roleUser: 1, roleManager: 2, roleAdmin: 3} return rank[user.Role] >= rank[needed] } func loginHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: renderLoginPage(w, r.URL.Query().Get("err")) case http.MethodPost: if err := r.ParseForm(); err != nil { renderLoginPage(w, err.Error()) return } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") user, err := GetAppUser(username) if err != nil || !user.Active || !CheckPassword(password, user.PasswordHash) { renderLoginPage(w, "Login fehlgeschlagen.") return } token, err := randomToken(32) if err != nil { renderLoginPage(w, err.Error()) return } expires := time.Now().Add(defaultSessionValidDays * 24 * time.Hour) if err := CreateSession(token, user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil { renderLoginPage(w, err.Error()) return } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds()), }) http.Redirect(w, r, "/view", http.StatusSeeOther) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func forgotPasswordHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: renderForgotPasswordPage(w, "") case http.MethodPost: if err := r.ParseForm(); err != nil { renderForgotPasswordPage(w, err.Error()) return } username := strings.TrimSpace(r.FormValue("username")) if username != "" { user, err := GetAppUser(username) if err == nil && user.Active { if err := sendPasswordReset(r, user); err != nil { log.Printf("password reset mail for %s failed: %v", username, err) } } } renderForgotPasswordPage(w, "Wenn der Benutzer existiert, wurde ein Reset-Link per E-Mail verschickt.") default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func resetPasswordHandler(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(r.URL.Query().Get("token")) if token == "" { renderResetPasswordPage(w, "", "Reset-Link fehlt.") return } tokenHash := resetTokenHash(token) user, err := PasswordResetUser(tokenHash) if err != nil { renderResetPasswordPage(w, "", "Reset-Link ist ungueltig oder abgelaufen.") return } switch r.Method { case http.MethodGet: renderResetPasswordPage(w, token, "") case http.MethodPost: if err := r.ParseForm(); err != nil { renderResetPasswordPage(w, token, err.Error()) return } password := r.FormValue("password") confirm := r.FormValue("confirm_password") if password == "" || password != confirm { renderResetPasswordPage(w, token, "Passwoerter stimmen nicht ueberein.") return } hash, err := HashPassword(password) if err != nil { renderResetPasswordPage(w, token, err.Error()) return } if err := UsePasswordReset(tokenHash, hash); err != nil { renderResetPasswordPage(w, token, err.Error()) return } log.Printf("password reset completed for %s", user.Username) http.Redirect(w, r, "/login?err="+urlQuery("Passwort wurde geaendert. Bitte neu anmelden."), http.StatusSeeOther) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func logoutHandler(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(sessionCookieName); err == nil { _ = DeleteSession(cookie.Value) } clearSessionCookie(w) http.Redirect(w, r, "/login", http.StatusSeeOther) } func renderLoginPage(w http.ResponseWriter, errMsg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `