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>
176 lines
5.8 KiB
Go
176 lines
5.8 KiB
Go
package auth
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/gorilla/sessions"
|
||
|
||
"aether/pkg/middleware"
|
||
)
|
||
|
||
const (
|
||
sessionName = "aether_session"
|
||
sessionKeyUser = "user_id"
|
||
// 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
|
||
}
|
||
|
||
func NewSessionStore(secret string, maxAge int) *SessionStore {
|
||
store := sessions.NewCookieStore([]byte(secret))
|
||
store.Options = &sessions.Options{
|
||
Path: "/",
|
||
MaxAge: maxAge,
|
||
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}
|
||
}
|
||
|
||
func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID uuid.UUID) error {
|
||
sess, err := ss.store.Get(r, sessionName)
|
||
if err != nil {
|
||
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 {
|
||
return uuid.Nil, false
|
||
}
|
||
raw, ok := sess.Values[sessionKeyUser].(string)
|
||
if !ok {
|
||
return uuid.Nil, false
|
||
}
|
||
id, err := uuid.Parse(raw)
|
||
if err != nil {
|
||
return uuid.Nil, false
|
||
}
|
||
return id, true
|
||
}
|
||
|
||
func (ss *SessionStore) Clear(w http.ResponseWriter, r *http.Request) error {
|
||
sess, err := ss.store.Get(r, sessionName)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
sess.Options.MaxAge = -1
|
||
return ss.store.Save(r, w, sess)
|
||
}
|
||
|
||
// SetCSRFCookie sets a CSRF token as a readable (non-httpOnly) cookie.
|
||
func SetCSRFCookie(w http.ResponseWriter, r *http.Request) (string, error) {
|
||
token := make([]byte, 32)
|
||
if _, err := rand.Read(token); err != nil {
|
||
return "", err
|
||
}
|
||
encoded := base64.RawURLEncoding.EncodeToString(token)
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: csrfCookieName,
|
||
Value: encoded,
|
||
Path: "/",
|
||
HttpOnly: false, // must be readable by JS
|
||
Secure: middleware.IsHTTPS(r),
|
||
SameSite: http.SameSiteStrictMode,
|
||
})
|
||
return encoded, nil
|
||
}
|
||
|
||
// ValidateCSRF prüft, dass das mitgeschickte Token zum CSRF-Cookie passt
|
||
// (Double-Submit).
|
||
//
|
||
// Neben dem Header wird auch ein Formularfeld akzeptiert. Grund: Ein Download
|
||
// muss aus einer **Navigation** kommen, damit der Browser ihn als Datei
|
||
// behandelt statt ihn in den Speicher zu laden – und ein abgeschicktes
|
||
// Formular kann keine Header setzen. Am Schutz ändert das nichts: Verglichen
|
||
// wird weiterhin gegen das Cookie, an das eine fremde Seite nicht herankommt.
|
||
func ValidateCSRF(r *http.Request) bool {
|
||
cookie, err := r.Cookie(csrfCookieName)
|
||
if err != nil || cookie.Value == "" {
|
||
return false
|
||
}
|
||
if token := r.Header.Get(csrfHeaderName); token != "" {
|
||
return token == cookie.Value
|
||
}
|
||
// Nur bei Formularen nachsehen: Bei anderen Inhaltstypen würde ParseForm
|
||
// den Rumpf lesen, den der eigentliche Handler danach noch braucht.
|
||
ct := r.Header.Get("Content-Type")
|
||
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||
if err := r.ParseForm(); err != nil {
|
||
return false
|
||
}
|
||
token := r.PostFormValue("csrf_token")
|
||
return token != "" && token == cookie.Value
|
||
}
|
||
return false
|
||
}
|