feat(backend): stateless HMAC session cookies for the web UI
Adds sessionKey/signSession/verifySession primitives and setSessionCookie/clearSessionCookie helpers in a new backend/session.go. Sessions are derived from API_TOKEN via HMAC-SHA256 with domain separation (sessionKeyPurpose), so there is no session table and rotating the token invalidates every outstanding cookie at once. No routes or handlers yet — that's task 5.
This commit is contained in:
@@ -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 "<expiryMs>.<base64url HMAC(expiryMs)>".
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user