diff --git a/backend/session.go b/backend/session.go new file mode 100644 index 0000000..a6fbffc --- /dev/null +++ b/backend/session.go @@ -0,0 +1,89 @@ +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, + }) +} diff --git a/backend/session_test.go b/backend/session_test.go new file mode 100644 index 0000000..0a6e761 --- /dev/null +++ b/backend/session_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestSessionRoundTrip(t *testing.T) { + key := sessionKey("token-abc") + now := time.Now().UnixMilli() + value := signSession(key, now+60_000) + if !verifySession(key, value, now) { + t.Fatal("verifySession = false for a freshly signed cookie, want true") + } +} + +func TestSessionRejects(t *testing.T) { + key := sessionKey("token-abc") + now := time.Now().UnixMilli() + valid := signSession(key, now+60_000) + payload, sig, _ := strings.Cut(valid, ".") + + cases := []struct { + name string + value string + }{ + {"empty", ""}, + {"no separator", payload + sig}, + {"unparseable expiry", "notanumber." + sig}, + {"expired", signSession(key, now-1)}, + {"tampered signature", payload + "." + flipLastChar(sig)}, + {"tampered expiry", "99999999999999." + sig}, + {"signed with another key", signSession(sessionKey("other-token"), now+60_000)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if verifySession(key, tc.value, now) { + t.Fatalf("verifySession(%q) = true, want false", tc.value) + } + }) + } +} + +func flipLastChar(s string) string { + if s == "" { + return "x" + } + last := s[len(s)-1] + if last == 'A' { + return s[:len(s)-1] + "B" + } + return s[:len(s)-1] + "A" +} + +func TestSessionKeyDependsOnToken(t *testing.T) { + a := sessionKey("token-a") + b := sessionKey("token-b") + if string(a) == string(b) { + t.Fatal("sessionKey collided for different API tokens") + } +} + +func TestSetSessionCookieAttributes(t *testing.T) { + cases := []struct { + name string + tls bool + forwarded string + wantSecure bool + }{ + {"plain http dev", false, "", false}, + {"direct tls", true, "", true}, + {"behind https proxy", false, "https", true}, + {"behind http proxy", false, "http", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/login", nil) + if tc.tls { + r.TLS = &tls.ConnectionState{} + } + if tc.forwarded != "" { + r.Header.Set("X-Forwarded-Proto", tc.forwarded) + } + rr := httptest.NewRecorder() + setSessionCookie(rr, r, sessionKey("token-abc")) + + cookies := rr.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("got %d cookies, want 1", len(cookies)) + } + c := cookies[0] + if c.Name != sessionCookieName { + t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName) + } + if !c.HttpOnly { + t.Fatal("cookie HttpOnly = false, want true") + } + if c.SameSite != http.SameSiteLaxMode { + t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite) + } + if c.Path != "/" { + t.Fatalf("cookie Path = %q, want /", c.Path) + } + if c.Secure != tc.wantSecure { + t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure) + } + if c.MaxAge != int(sessionTTL/time.Second) { + t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(sessionTTL/time.Second)) + } + }) + } +} + +func TestClearSessionCookie(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/logout", nil) + rr := httptest.NewRecorder() + clearSessionCookie(rr, r) + + cookies := rr.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("got %d cookies, want 1", len(cookies)) + } + if cookies[0].MaxAge >= 0 { + t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge) + } +}