package web import ( "errors" "fmt" "net/http" "strconv" "strings" "time" "git.ravensburg.dev/cabele/opnsense-portal/internal/audit" "git.ravensburg.dev/cabele/opnsense-portal/internal/auth" ) func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { NoStore(w) // Eine bereits gültige Session überspringt das Formular. if c, err := r.Cookie(SessionCookieName); err == nil { if _, ok := s.d.Sessions.Get(c.Value); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } } var data PageData switch { case r.URL.Query().Get("expired") != "": data.Flash, data.FlashKind = T("error_session_expired"), "info" case r.URL.Query().Get("abgemeldet") != "": data.Flash, data.FlashKind = T("logout_done"), "info" } s.renderPage(w, r, http.StatusOK, "login", nil, data) } // loginFailed rendert die Anmeldeseite mit einer Meldung. func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, status int, message string) { s.renderPage(w, r, status, "login", nil, PageData{Flash: message, FlashKind: "error"}) } func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { NoStore(w) started := time.Now() ip := ClientIP(r) if err := r.ParseForm(); err != nil { s.loginFailed(w, r, http.StatusBadRequest, T("error_generic_login")) return } username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") // Rate-Limit vor jedem Verzeichniszugriff prüfen. if wait, ok := s.d.Limiter.Allow(username, ip); !ok { s.log(audit.Event{ Event: audit.EventRateLimited, User: audit.UnknownUser, // an dieser Stelle ist der Name ungeprüft SrcIP: ip, Detail: wait.Round(time.Second).String(), }) w.Header().Set("Retry-After", strconv.Itoa(int(wait.Round(time.Second).Seconds()))) s.loginFailed(w, r, http.StatusTooManyRequests, fmt.Sprintf(T("error_rate_limited"), humanDuration(wait))) return } id, err := s.d.Auth.Authenticate(r.Context(), username, password) s.equalizeTiming(started) if err != nil { s.d.Limiter.RecordFailure(username, ip) reason, known := reasonAndKnownUser(err) s.log(audit.Event{ Event: audit.EventLoginFailed, User: auditUser(username, known), SrcIP: ip, Reason: reason, }) message := T("error_generic_login") var ae *auth.Error if errors.As(err, &ae) && ae.UserVisible() { message = T("error_password_expired") } s.loginFailed(w, r, http.StatusUnauthorized, message) return } s.d.Limiter.RecordSuccess(username, ip) sess, err := s.d.Sessions.Create(id) if err != nil { s.renderError(w, r, http.StatusInternalServerError, nil, "error_internal", "error_internal_body") return } s.setCookie(w, sess) s.log(audit.Event{ Event: audit.EventLoginSuccess, User: id.Username, SrcIP: ip, Session: audit.ShortSession(sess.Token), }) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *Session) { if err := r.ParseForm(); err != nil || !sess.ValidCSRF(r.PostFormValue("csrf_token")) { s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf") return } s.d.Sessions.Destroy(sess.Token) s.clearCookie(w) s.log(audit.Event{ Event: audit.EventLogout, User: sess.Identity.Username, SrcIP: ClientIP(r), Session: audit.ShortSession(sess.Token), }) http.Redirect(w, r, "/login?abgemeldet=1", http.StatusSeeOther) } // setCookie setzt das gehärtete Session-Cookie. func (s *Server) setCookie(w http.ResponseWriter, sess *Session) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: sess.Token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(s.d.SessionTTL.Seconds()), }) } func (s *Server) clearCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1, }) } // equalizeTiming hält die Antwortzeit auf einem Mindestwert, damit die Dauer // nicht verrät, ob ein Konto existiert. func (s *Server) equalizeTiming(started time.Time) { if elapsed := time.Since(started); elapsed < s.d.MinLoginDuration { time.Sleep(s.d.MinLoginDuration - elapsed) } } // reasonAndKnownUser liefert den Audit-Reason und ob der Benutzer im // Verzeichnis existiert. Nur dann darf sein Name im Klartext geloggt werden. func reasonAndKnownUser(err error) (reason string, known bool) { var ae *auth.Error if !errors.As(err, &ae) { return auth.ReasonBackendUnavailable, false } switch ae.Reason { case auth.ReasonUserNotFound, auth.ReasonBackendUnavailable, "": return ae.Reason, false default: // Der Benutzer wurde im Verzeichnis gefunden; sein Name ist ein // echter Kontoname und kein versehentlich eingegebenes Passwort. return ae.Reason, true } } // auditUser entscheidet, ob der eingegebene Name im Log erscheinen darf. func auditUser(input string, known bool) string { if !known { return audit.UnknownUser } return strings.ToLower(input) } // humanDuration formatiert Wartezeiten deutsch lesbar. func humanDuration(d time.Duration) string { d = d.Round(time.Second) if d < time.Minute { return fmt.Sprintf("%d Sekunden", int(d.Seconds())) } minutes := int(d.Minutes()) if minutes == 1 { return "einer Minute" } return fmt.Sprintf("%d Minuten", minutes) }