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 }