7d0eaaef02
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.
90 lines
2.7 KiB
Go
90 lines
2.7 KiB
Go
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,
|
|
})
|
|
}
|