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