package main import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "net/http" "strconv" "strings" "time" ) const ( sessionCookieName = "mangabm_session" // 60 days: long enough that a phone stays logged in between reading spells. sessionTTL = 60 * 24 * time.Hour // Domain separation, so the session key can never collide with any other // use of API_TOKEN. Changing this string logs everyone out. sessionKeyPurpose = "mangabm-web-session-v1" ) // sessionKey derives the cookie-signing key from the API token. Sessions are // stateless — there is no session table — so rotating API_TOKEN invalidates // every outstanding cookie at once. func sessionKey(apiToken string) []byte { sum := sha256.Sum256([]byte(apiToken + sessionKeyPurpose)) return sum[:] } // signSession encodes ".". func signSession(key []byte, expiryMs int64) string { payload := strconv.FormatInt(expiryMs, 10) return payload + "." + sessionMAC(key, payload) } func sessionMAC(key []byte, payload string) string { mac := hmac.New(sha256.New, key) mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // verifySession checks shape, then expiry, then the signature — in that order. // The signature comparison is constant-time; the checks before it only look at // data the holder already supplied, so their timing leaks nothing. func verifySession(key []byte, value string, nowMs int64) bool { payload, sig, ok := strings.Cut(value, ".") if !ok { return false } expiry, err := strconv.ParseInt(payload, 10, 64) if err != nil || expiry <= nowMs { return false } want := sessionMAC(key, payload) return subtle.ConstantTimeCompare([]byte(sig), []byte(want)) == 1 } // isHTTPS reports whether the browser's connection is encrypted. Behind Traefik // the Go server itself speaks plain HTTP, so the forwarded header is the only // signal; without this check the Secure cookie would never be set in // production, and setting it unconditionally would break http://localhost dev. func isHTTPS(r *http.Request) bool { return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" } func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()), Path: "/", MaxAge: int(sessionTTL / time.Second), HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) } func clearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) }