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, ` Login - Mail-Graveyard
Mail-Graveyard
%s
`, loginErrorHTML(errMsg)) } func renderForgotPasswordPage(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ` Passwort vergessen - Mail-Graveyard
Mail-Graveyard
%s
`, loginNoticeHTML(msg)) } func renderResetPasswordPage(w http.ResponseWriter, token, errMsg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ` Passwort setzen - Mail-Graveyard
Mail-Graveyard
%s
`, html.EscapeString(token), loginErrorHTML(errMsg)) } func loginErrorHTML(errMsg string) string { if errMsg == "" { return "" } return `
` + html.EscapeString(errMsg) + `
` } func loginNoticeHTML(msg string) string { if msg == "" { return "" } return `
` + html.EscapeString(msg) + `
` } func redirectToLogin(w http.ResponseWriter, r *http.Request) { if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", "/login") w.WriteHeader(http.StatusUnauthorized) return } http.Redirect(w, r, "/login", http.StatusSeeOther) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(0, 0), }) } func HashPassword(password string) (string, error) { salt, err := randomToken(18) if err != nil { return "", err } sum := passwordDigest(password, salt, passwordHashIterations) return fmt.Sprintf("sha256:%d:%s:%s", passwordHashIterations, salt, base64.RawStdEncoding.EncodeToString(sum)), nil } func CheckPassword(password, encoded string) bool { parts := strings.Split(encoded, ":") if len(parts) != 4 || parts[0] != "sha256" { return false } var iterations int if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil || iterations <= 0 { return false } want, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil { return false } got := passwordDigest(password, parts[2], iterations) return subtle.ConstantTimeCompare(got, want) == 1 } func passwordDigest(password, salt string, iterations int) []byte { data := []byte(salt + ":" + password) sum := sha256.Sum256(data) out := sum[:] for i := 1; i < iterations; i++ { next := sha256.Sum256(out) out = next[:] } return out } func randomToken(size int) (string, error) { b := make([]byte, size) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func resetTokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return base64.RawURLEncoding.EncodeToString(sum[:]) } func sendPasswordReset(r *http.Request, user AppUser) error { token, err := randomToken(32) if err != nil { return err } expires := time.Now().Add(30 * time.Minute) if err := CreatePasswordReset(resetTokenHash(token), user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil { return err } link := baseURL(r) + "/password/reset?token=" + token subject := "Mail-Graveyard Passwort zuruecksetzen" body := fmt.Sprintf("Hallo %s,\n\nzum Zuruecksetzen deines Mail-Graveyard-Passworts nutze diesen Link:\n\n%s\n\nDer Link ist 30 Minuten gueltig.\n", userDisplayName(user), link) return SendPlainMail([]string{user.Username}, subject, body) } func baseURL(r *http.Request) string { proto := r.Header.Get("X-Forwarded-Proto") if proto == "" { if r.TLS != nil { proto = "https" } else { proto = "http" } } host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host } return proto + "://" + host }