From 093ff5140e10145a092aff0300d9ba76147f7dba Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 1 Sep 2026 01:14:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20gleitende=20Sitzung=20=E2=80=93?= =?UTF-8?q?=20Frist=20ab=20letzter=20Benutzung=20statt=20ab=20Anmeldung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc flog trotz täglicher Nutzung heraus: Die Frist lief ab dem Anmelden. Ein Ablauf, der den Anwesenden trifft und nicht den Abwesenden, erkauft keine Sicherheit, sondern nur Ärger. Jetzt verlängert jeder angemeldete Aufruf die Sitzung – höchstens einmal pro Stunde, sonst trüge jede Antwort ein Set-Cookie (beim Laden einer Dateiliste dreißig Stück). Vorgabe 30 Tage ab letzter Benutzung; bestehende .env-Werte bleiben unangetastet. security(auth): Frist galt nur im Browser, nicht für die Signatur Beim Bauen gefunden, war schon vorher drin: NewSessionStore ersetzte store.Options, ohne store.MaxAge() zu rufen. Die Codecs behielten damit gorillas Voreinstellung von 30 Tagen. Wirkung: Der Browser warf das Cookie nach SESSION_MAX_AGE weg – wer den Wert vorher herauskopiert hatte, konnte ihn 30 Tage lang weiterbenutzen. Wer die Frist kürzte, um sie zu kürzen, hatte nichts gekürzt. Mit Test festgehalten, und der Test ist gegengeprüft: Ohne den Aufruf schlägt er fehl ("abgelaufene Sitzung gilt weiter"), mit ihm läuft er durch. Ein Fehler steckte dabei zuerst im Test selbst: gorilla legt die entschlüsselte Sitzung im Zusammenhang der Anfrage ab. Zweimal dieselbe Anfrage zu befragen prüfte den Zwischenspeicher, nicht die Frist. Jetzt baut jede Prüfung eine frische Anfrage. Live gegengeprüft: Anmeldung setzt das Cookie, drei angemeldete Aufrufe danach setzen keins (die Bremse greift). Dazu die gemessenen Leerlaufwerte der Office-Server im README statt der Zahlen aus deren Dokumentation (Collabora 51 MB, OnlyOffice 396 MB). Co-Authored-By: Claude Opus 5 --- README.md | 22 ++++- backend/internal/auth/middleware.go | 6 ++ backend/internal/auth/session.go | 61 ++++++++++++- backend/internal/auth/session_test.go | 125 ++++++++++++++++++++++++++ backend/pkg/config/config.go | 13 ++- deploy/.env.example | 6 +- 6 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 backend/internal/auth/session_test.go diff --git a/README.md b/README.md index 0b14b62..5f9f78f 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,18 @@ auf, wenn ein Freigabe-Link oder ein OIDC-Rücksprung ins Leere zeigt. hier das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit und Audit-Log werden damit wertlos, ohne dass irgendetwas nach Fehler aussieht. +**`SESSION_MAX_AGE`** ist die Frist **ab der letzten Benutzung**, nicht ab dem +Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie 30 Tage +nicht anfasst, meldet sich neu an. Verlängert wird höchstens einmal pro +Stunde – sonst trüge jede Antwort ein `Set-Cookie`. + +Was das kostet, damit es niemanden überrascht: Ein Cookie, das jemand aus +einem fremden Browser kopiert, verlängert sich beim Benutzen mit. Es gibt +keine Sitzungsliste auf dem Server, die man widerrufen könnte – das ist der +Preis dafür, ohne Sitzungstabelle auszukommen. **Alle** Sitzungen sofort +beenden heißt: `SESSION_SECRET` ändern und neu starten. Bei einem gemeinsam +genutzten Rechner ist eine kürzere Frist die bessere Wahl. + **`ADMIN_PASSWORD`** legt beim allerersten Start den Admin an. Danach leeren: Das Konto bleibt, das Passwort stünde sonst dauerhaft in einer Datei. @@ -771,8 +783,14 @@ Der Office-Server ist der mit Abstand hungrigste Teil dieser Anlage: | | Bedarf | Bemerkung | |---|---|---| -| **Collabora (CODE)** | ~1 GB im Leerlauf, +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess | -| **OnlyOffice Docs** | 2 GB Minimum, **4 GB empfohlen**, dazu Swap | bringt eigenes Postgres, RabbitMQ und Node mit | +| **Collabora (CODE)** | **51 MB im Leerlauf** (gemessen), +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess | +| **OnlyOffice Docs** | **396 MB im Leerlauf** (gemessen), 2 GB Minimum unter Last, **4 GB empfohlen** | bringt eigenes Postgres, RabbitMQ und Node mit | + +Die Leerlaufwerte sind auf dieser Anlage nachgemessen und liegen deutlich +unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der +Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB, +OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige +Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt. | **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau | Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 7669887..6aebc84 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -26,6 +26,12 @@ func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler response.Error(w, http.StatusUnauthorized, "authentication required") return } + // Die Frist läuft ab der letzten Benutzung, nicht ab dem + // Anmelden. **Vor** dem Handler, weil danach der Rumpf der + // Antwort schon geschrieben sein kann – ein Set-Cookie käme dann + // zu spät und fiele stillschweigend unter den Tisch. + ss.Touch(w, r) + ctx := context.WithValue(r.Context(), keyUser, user) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go index 76d84f2..04e15a2 100644 --- a/backend/internal/auth/session.go +++ b/backend/internal/auth/session.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/gorilla/sessions" @@ -15,10 +16,22 @@ import ( const ( sessionName = "aether_session" sessionKeyUser = "user_id" - csrfCookieName = "aether_csrf" - csrfHeaderName = "X-CSRF-Token" + // sessionKeyTouched hält fest, wann die Sitzung zuletzt verlängert wurde. + // Ohne diesen Wert müsste jede einzelne Anfrage ein neues Cookie setzen. + sessionKeyTouched = "touched_at" + csrfCookieName = "aether_csrf" + csrfHeaderName = "X-CSRF-Token" ) +// refreshInterval: Wie oft eine benutzte Sitzung höchstens erneuert wird. +// +// Die Frist gleitet – aber sie muss nicht bei jedem Klick nachgezogen werden. +// Ohne diese Bremse trüge **jede** Antwort ein `Set-Cookie`, und beim Laden +// einer Dateiliste sind das schnell dreißig Stück. Einmal pro Stunde reicht: +// Der Unterschied zur Frist ist eine Stunde, und die fällt bei Tagen nicht +// ins Gewicht. +var refreshInterval = time.Hour + type SessionStore struct { store *sessions.CookieStore maxAge int @@ -32,6 +45,15 @@ func NewSessionStore(secret string, maxAge int) *SessionStore { HttpOnly: true, SameSite: http.SameSiteLaxMode, } + // **Zwei Fristen, nicht eine.** `Options.MaxAge` steht nur im Cookie – der + // Browser wirft es dann weg. Wie lange der *Inhalt* gilt, entscheidet die + // Signatur, und die hängt an den Codecs. + // + // Ohne diesen Aufruf blieb dort gorillas Voreinstellung von 30 Tagen + // stehen: Ein Cookie, das jemand aus einem Browser kopiert hat, wäre einen + // Monat lang gültig geblieben, obwohl SESSION_MAX_AGE auf einen Tag stand. + // Wer die Frist kürzt, um sie zu kürzen, hätte nichts gekürzt. + store.MaxAge(maxAge) return &SessionStore{store: store, maxAge: maxAge} } @@ -41,10 +63,45 @@ func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID u sess, _ = ss.store.New(r, sessionName) } sess.Values[sessionKeyUser] = userID.String() + sess.Values[sessionKeyTouched] = time.Now().Unix() sess.Options.Secure = middleware.IsHTTPS(r) return ss.store.Save(r, w, sess) } +// Touch verlängert eine benutzte Sitzung. +// +// **Warum überhaupt:** Bisher lief die Frist ab dem *Anmelden* – wer die Cloud +// täglich benutzte, flog nach 24 Stunden trotzdem heraus, mitten in der +// Arbeit. Das ist die Sorte Ablauf, die keine Sicherheit bringt: Sie trifft +// den, der da ist, und nicht den, der weg ist. +// +// Jetzt läuft sie ab der *letzten Benutzung*. Wer die Cloud regelmäßig +// öffnet, bleibt angemeldet; wer sie einen Monat nicht anfasst, muss sich neu +// anmelden – und genau dieser zweite Fall ist der, um den es bei einem +// abgelaufenen Cookie geht. +// +// **Was das kostet, ehrlich gesagt:** Ein entwendetes Cookie, das weiter +// benutzt wird, verlängert sich mit. Ein Sitzungscookie hat keine Liste auf +// dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne +// Sitzungstabelle auszukommen. Wer eine Sitzung sofort beenden will, ändert +// SESSION_SECRET; dann sind alle ungültig. +func (ss *SessionStore) Touch(w http.ResponseWriter, r *http.Request) { + sess, err := ss.store.Get(r, sessionName) + if err != nil || sess.IsNew { + return + } + if last, ok := sess.Values[sessionKeyTouched].(int64); ok { + if time.Since(time.Unix(last, 0)) < refreshInterval { + return + } + } + sess.Values[sessionKeyTouched] = time.Now().Unix() + sess.Options.Secure = middleware.IsHTTPS(r) + // Ein Fehlschlag ist keiner, der den Aufruf scheitern lassen darf: Die + // Sitzung gilt weiter, sie wurde nur nicht verlängert. + _ = ss.store.Save(r, w, sess) +} + func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) { sess, err := ss.store.Get(r, sessionName) if err != nil { diff --git a/backend/internal/auth/session_test.go b/backend/internal/auth/session_test.go new file mode 100644 index 0000000..a8d62c0 --- /dev/null +++ b/backend/internal/auth/session_test.go @@ -0,0 +1,125 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" +) + +const testSecret = "test-geheimnis-mit-mehr-als-32-zeichen!!" + +// Anmelden und die gesetzten Cookies zurückgeben. +func signIn(t *testing.T, ss *SessionStore) []*http.Cookie { + t.Helper() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + if err := ss.SetUser(w, r, uuid.New()); err != nil { + t.Fatalf("SetUser: %v", err) + } + return w.Result().Cookies() +} + +// Eine **frische** Anfrage mit diesen Cookies. +// +// Jedes Mal eine neue, und das ist keine Umständlichkeit: gorilla legt die +// entschlüsselte Sitzung im Zusammenhang der Anfrage ab und gibt sie beim +// zweiten Zugriff unverändert zurück. Zweimal dieselbe Anfrage zu befragen +// prüfte also den Zwischenspeicher und nicht die Frist – der erste Entwurf +// dieses Tests ist genau darauf hereingefallen. +func withCookies(cookies []*http.Cookie) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range cookies { + r.AddCookie(c) + } + return r +} + +func signedIn(t *testing.T, ss *SessionStore) *http.Request { + t.Helper() + return withCookies(signIn(t, ss)) +} + +// Die Frist muss auch für den **Inhalt** gelten, nicht nur für das Cookie. +// +// Der Browser wirft ein abgelaufenes Cookie von selbst weg – wer es vorher +// herauskopiert hat, aber nicht. Ohne store.MaxAge() blieb die Signatur 30 +// Tage gültig, egal was in SESSION_MAX_AGE stand. +func TestSessionExpiresAfterMaxAge(t *testing.T) { + ss := NewSessionStore(testSecret, 1) // eine Sekunde + cookies := signIn(t, ss) + + if _, ok := ss.GetUserID(withCookies(cookies)); !ok { + t.Fatal("frische Sitzung wurde nicht erkannt") + } + time.Sleep(2100 * time.Millisecond) + if _, ok := ss.GetUserID(withCookies(cookies)); ok { + t.Error("abgelaufene Sitzung gilt weiter – die Frist wirkt nur im Browser") + } +} + +// Nicht bei jedem Aufruf ein neues Cookie: Sonst trüge jede Antwort ein +// Set-Cookie, und beim Laden einer Dateiliste sind das dreißig Stück. +func TestTouchIsThrottled(t *testing.T) { + ss := NewSessionStore(testSecret, 3600) + r := signedIn(t, ss) + + w := httptest.NewRecorder() + ss.Touch(w, r) + if len(w.Result().Cookies()) != 0 { + t.Error("Sitzung wurde sofort erneuert, obwohl sie eben erst entstand") + } +} + +// Nach Ablauf der Bremse verlängert sie sich – das ist der eigentliche Zweck. +func TestTouchExtendsSession(t *testing.T) { + old := refreshInterval + refreshInterval = 0 + defer func() { refreshInterval = old }() + + ss := NewSessionStore(testSecret, 3600) + r := signedIn(t, ss) + + w := httptest.NewRecorder() + ss.Touch(w, r) + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("Sitzung wurde nicht verlängert") + } + // Das erneuerte Cookie muss dieselbe Anmeldung tragen. + next := httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range cookies { + next.AddCookie(c) + } + if _, ok := ss.GetUserID(next); !ok { + t.Error("nach dem Verlängern ist niemand mehr angemeldet") + } +} + +// Eine Anfrage ohne Cookie darf keine Sitzung erfinden. +func TestTouchIgnoresAnonymous(t *testing.T) { + ss := NewSessionStore(testSecret, 3600) + w := httptest.NewRecorder() + ss.Touch(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if len(w.Result().Cookies()) != 0 { + t.Error("für einen Unangemeldeten wurde ein Cookie gesetzt") + } +} + +// Abmelden muss stärker sein als Verlängern. +func TestClearEndsSession(t *testing.T) { + ss := NewSessionStore(testSecret, 3600) + r := signedIn(t, ss) + + w := httptest.NewRecorder() + if err := ss.Clear(w, r); err != nil { + t.Fatalf("Clear: %v", err) + } + for _, c := range w.Result().Cookies() { + if c.Name == sessionName && c.MaxAge >= 0 { + t.Errorf("Abmelden löscht das Cookie nicht (MaxAge=%d)", c.MaxAge) + } + } +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 4fb588a..0d48573 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -222,10 +222,21 @@ func Load() (*Config, error) { OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true", } - maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "86400")) + // 30 Tage, und die Frist **gleitet**: Sie zählt ab der letzten Benutzung, + // nicht ab dem Anmelden (siehe auth.SessionStore.Touch). Vorher stand hier + // ein Tag ab Anmeldung – wer täglich arbeitete, flog trotzdem täglich + // heraus. Ein Ablauf, der den Anwesenden trifft und nicht den Abwesenden, + // erkauft keine Sicherheit, sondern nur Ärger. + // + // Kürzer ist jederzeit einstellbar; bei einem gemeinsam genutzten Rechner + // wäre das sinnvoll. + maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "2592000")) if err != nil { return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err) } + if maxAge <= 0 { + return nil, fmt.Errorf("SESSION_MAX_AGE must be greater than 0 (seconds)") + } cfg.SessionMaxAge = maxAge if cfg.SessionSecret == "" { diff --git a/deploy/.env.example b/deploy/.env.example index 459b2da..d69a063 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -27,7 +27,11 @@ DB_PASSWORD=changeme_strong_password # ── Sitzung ─────────────────────────────────────────────────────────────────── SESSION_SECRET=changeme_generate_with_openssl_rand_hex_32 # openssl rand -hex 32 -SESSION_MAX_AGE=86400 # Sekunden +# Wie lange eine Sitzung gilt – **ab der letzten Benutzung**, nicht ab dem +# Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie so +# lange nicht anfasst, meldet sich neu an. 2592000 = 30 Tage. +# Kürzer ist sinnvoll, wenn mehrere Leute denselben Rechner benutzen. +SESSION_MAX_AGE=2592000 # Sekunden # ── Speicher ────────────────────────────────────────────────────────────────── STORAGE_PATH=/data # im Container