feat(auth): gleitende Sitzung – Frist ab letzter Benutzung statt ab Anmeldung

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-01 01:14:13 +02:00
1 parent 6c53651568
commit 093ff5140e
6 files changed
+227 -6

No files matched your search

+6
View File
@@ -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))
})
+59 -2
View File
@@ -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 {
+125
View File
@@ -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)
}
}
}
+12 -1
View File
@@ -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 == "" {