package calendar import ( "io" "net/http" "strconv" "strings" "time" "github.com/emersion/go-webdav/caldav" "aether/internal/auth" "aether/pkg/middleware" ) // Brute-force-Schutz für /caldav/, aus denselben Gründen wie beim // WebDAV-Endpunkt: Basic Auth läuft am normalen Login-Rate-Limit vorbei. const ( davMaxFailures = 10 davWindow = 15 * time.Minute davLockout = 15 * time.Minute ) // Handler serves CalDAV at /caldav/ with HTTP Basic Auth (App-Passwörter). type Handler struct { authStore *auth.Store dav *caldav.Handler limiter *middleware.FailureLimiter } func NewHandler(authStore *auth.Store, store *Store) *Handler { return &Handler{ authStore: authStore, dav: &caldav.Handler{Backend: NewBackend(store), Prefix: "/caldav"}, limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout), } } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { clientIP := middleware.RealIP(r) if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed { w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1)) http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests) return } username, password, ok := r.BasicAuth() if !ok || username == "" || password == "" { challenge(w) return } // Nur App-Passwörter, aus demselben Grund wie bei /dav/: das // Kontopasswort öffnet auch den Admin-Bereich. user, err := h.authStore.VerifyAppPassword(r.Context(), username, password) if err != nil { h.limiter.Fail(clientIP) challenge(w) return } h.limiter.Reset(clientIP) // Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit // GET statt PROPFIND, oder ein Mensch öffnet die Adresse zum Testen im // Browser. Ohne diese Abkürzung würde das an GetCalendarObject // durchgereicht, das für Sammlungen (Principal, Home-Set, Kalender) mit // 404 antwortet – ein Client, der so den Dienst prüft, bräche die // Einrichtung ab, bevor er je ein PROPFIND schickt. if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !strings.HasSuffix(r.URL.Path, ".ics") { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) if r.Method == http.MethodGet { io.WriteString(w, "Dies ist eine CalDAV-Sammlung. Mit einem CalDAV-Client verbinden (PROPFIND), nicht mit dem Browser.\n") } return } h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user))) } // ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem // Clients wie DAVx5 die automatische Einrichtung starten (RFC 6764). Er // liegt außerhalb von /caldav/, deshalb braucht es diese eigene Route statt // caldav.Handler's eingebauter Behandlung, die nur den exakten Pfad // "/.well-known/caldav" innerhalb des eigenen Präfix kennt. func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username == "" || password == "" { challenge(w) return } user, err := h.authStore.VerifyAppPassword(r.Context(), username, password) if err != nil { challenge(w) return } http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect) } func challenge(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`) http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized) }