Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package session implements HMAC-signed cookie sessions for the admin
|
||||
// UI. The cookie is the only state: it is signed, verified and expired
|
||||
// here, and no session table exists anywhere.
|
||||
//
|
||||
// The cookie value is base64url(payload) + "." + base64url(signature)
|
||||
// where payload is JSON {"d": {…}, "iat": unix-seconds}. Sessions are
|
||||
// invalidated by signature mismatch or expiry, so users simply log in
|
||||
// again after the signing key changes.
|
||||
package session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CookieName is the session cookie name.
|
||||
const CookieName = "volumen_session"
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// Store signs and verifies session cookies.
|
||||
type Store struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
}
|
||||
|
||||
// New creates a session store. An empty secret is replaced with an
|
||||
// ephemeral random one (sessions then do not survive restarts).
|
||||
func New(secret string, ttl time.Duration, secure bool) *Store {
|
||||
key := []byte(secret)
|
||||
if len(key) == 0 {
|
||||
key = make([]byte, 64)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
slog.Warn("session: random source failed", "error", err)
|
||||
}
|
||||
slog.Warn("session: session_key is empty; using an ephemeral random secret")
|
||||
}
|
||||
return &Store{secret: key, ttl: ttl, secure: secure}
|
||||
}
|
||||
|
||||
// Secure reports whether cookies carry the Secure flag.
|
||||
func (s *Store) Secure() bool { return s.secure }
|
||||
|
||||
// Session is a mutable key-value bag loaded for one request.
|
||||
type Session struct {
|
||||
data map[string]string
|
||||
dirty bool
|
||||
store *Store
|
||||
}
|
||||
|
||||
// Get returns the value for key, or "".
|
||||
func (s *Session) Get(key string) string { return s.data[key] }
|
||||
|
||||
// Set stores a value and marks the session for re-signing.
|
||||
func (s *Session) Set(key, value string) {
|
||||
if s.data[key] == value {
|
||||
return
|
||||
}
|
||||
s.data[key] = value
|
||||
s.dirty = true
|
||||
}
|
||||
|
||||
// Delete removes a key.
|
||||
func (s *Session) Delete(key string) {
|
||||
if _, ok := s.data[key]; !ok {
|
||||
return
|
||||
}
|
||||
delete(s.data, key)
|
||||
s.dirty = true
|
||||
}
|
||||
|
||||
// Clear empties the session.
|
||||
func (s *Session) Clear() {
|
||||
if len(s.data) == 0 {
|
||||
return
|
||||
}
|
||||
s.data = map[string]string{}
|
||||
s.dirty = true
|
||||
}
|
||||
|
||||
// Abandon empties the session and clears the dirty flag, for a response
|
||||
// that expires the cookie instead of re-signing it. Save would otherwise
|
||||
// append a fresh cookie after Destroy's expiring one, and the browser
|
||||
// applies the last header.
|
||||
func (s *Session) Abandon() {
|
||||
s.data = map[string]string{}
|
||||
s.dirty = false
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Data map[string]string `json:"d"`
|
||||
Iat int64 `json:"iat"`
|
||||
}
|
||||
|
||||
// Load reads and verifies the session cookie from the request; a
|
||||
// missing or invalid cookie yields an empty session.
|
||||
func (s *Store) Load(r *http.Request) *Session {
|
||||
sess := &Session{data: map[string]string{}, store: s}
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return sess
|
||||
}
|
||||
data, ok := s.verify(cookie.Value)
|
||||
if !ok {
|
||||
return sess
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return sess
|
||||
}
|
||||
if s.ttl > 0 && time.Since(time.Unix(p.Iat, 0)) > s.ttl {
|
||||
return sess
|
||||
}
|
||||
if p.Data != nil {
|
||||
sess.data = p.Data
|
||||
} else {
|
||||
sess.data = map[string]string{}
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *Store) verify(value string) ([]byte, bool) {
|
||||
// The signature is appended after the last dot; the body is base64url
|
||||
// and carries none.
|
||||
body, sig, found := strings.CutLast(value, ".")
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(body)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
mac, err := base64.RawURLEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if !hmac.Equal(mac, s.sign(body)) {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func (s *Store) sign(body string) []byte {
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(body))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// Save re-signs and sets the cookie when the session changed.
|
||||
func (s *Store) Save(w http.ResponseWriter, sess *Session) {
|
||||
if !sess.dirty {
|
||||
return
|
||||
}
|
||||
sess.dirty = false
|
||||
body, err := json.Marshal(payload{Data: sess.data, Iat: time.Now().Unix()})
|
||||
if err != nil {
|
||||
slog.Warn("session: cannot encode payload", "error", err)
|
||||
return
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(body) +
|
||||
"." + base64.RawURLEncoding.EncodeToString(s.sign(base64.RawURLEncoding.EncodeToString(body)))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// Destroy expires the session cookie.
|
||||
func (s *Store) Destroy(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware loads the session before the handler and persists it
|
||||
// afterwards when it changed. The response header is not flushed until
|
||||
// the first body write (or the end of the request), so the session
|
||||
// cookie survives handlers that only set a status code.
|
||||
func (s *Store) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.Load(r)
|
||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(recorder, r.WithContext(WithContext(r.Context(), sess)))
|
||||
if sess.dirty {
|
||||
s.Save(recorder, sess)
|
||||
}
|
||||
recorder.flush()
|
||||
})
|
||||
}
|
||||
|
||||
// WithContext returns a context carrying sess the way the middleware
|
||||
// does. A chain that must not buffer the response, a large download,
|
||||
// attaches the session through this instead: the middleware records the
|
||||
// whole body so it can still set a cookie after the handler ran, which
|
||||
// is exactly the memory a streamed response must not pay. A session
|
||||
// attached this way is read-only in effect: nothing flushes its dirty
|
||||
// flag, so mutations do not persist.
|
||||
func WithContext(ctx context.Context, sess *Session) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, sess)
|
||||
}
|
||||
|
||||
// FromContext returns the session attached to the request, or an empty
|
||||
// detached session when the middleware is not installed.
|
||||
func FromContext(ctx context.Context) *Session {
|
||||
if sess, ok := ctx.Value(contextKey{}).(*Session); ok {
|
||||
return sess
|
||||
}
|
||||
return &Session{data: map[string]string{}}
|
||||
}
|
||||
|
||||
// statusRecorder buffers the response so the session cookie can be
|
||||
// added after the handler ran: net/http snapshots headers at the first
|
||||
// body byte otherwise.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
sent bool
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
if !r.wroteHeader {
|
||||
r.status = code
|
||||
r.wroteHeader = true
|
||||
}
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
r.wroteHeader = true
|
||||
return r.buf.Write(b)
|
||||
}
|
||||
|
||||
// flush sends the recorded status and buffered body once.
|
||||
func (r *statusRecorder) flush() {
|
||||
if r.sent {
|
||||
return
|
||||
}
|
||||
r.sent = true
|
||||
r.ResponseWriter.WriteHeader(r.status)
|
||||
if r.buf.Len() > 0 {
|
||||
_, _ = r.ResponseWriter.Write(r.buf.Bytes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package session
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetSaveLoadRoundTrip(t *testing.T) {
|
||||
store := New("secret-key", time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
sess := &Session{data: map[string]string{}, store: store}
|
||||
sess.Set("user", "petr")
|
||||
store.Save(rec, sess)
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("cookies = %v", cookies)
|
||||
}
|
||||
if cookies[0].Name != CookieName || cookies[0].HttpOnly != true ||
|
||||
cookies[0].SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("cookie attrs = %+v", cookies[0])
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookies[0])
|
||||
loaded := store.Load(req)
|
||||
if loaded.Get("user") != "petr" {
|
||||
t.Fatalf("loaded = %v", loaded.data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCookieRejected(t *testing.T) {
|
||||
store := New("secret-key", time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
sess := &Session{data: map[string]string{}, store: store}
|
||||
sess.Set("user", "petr")
|
||||
store.Save(rec, sess)
|
||||
value := rec.Result().Cookies()[0].Value
|
||||
|
||||
tampered := value[:len(value)-2] + "xx"
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: tampered})
|
||||
if got := store.Load(req).Get("user"); got != "" {
|
||||
t.Fatalf("tampered cookie accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignSignatureRejected(t *testing.T) {
|
||||
other := New("other-key", time.Hour, false)
|
||||
store := New("secret-key", time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
sess := &Session{data: map[string]string{}, store: other}
|
||||
sess.Set("user", "petr")
|
||||
other.Save(rec, sess)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(rec.Result().Cookies()[0])
|
||||
if got := store.Load(req).Get("user"); got != "" {
|
||||
t.Fatalf("foreign cookie accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// synctest advances the clock past the one-nanosecond lifetime, so the
|
||||
// test needs no sleep and cannot be flaky on a loaded machine.
|
||||
func TestExpiredSessionRejected(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
store := New("secret-key", time.Nanosecond, false)
|
||||
rec := httptest.NewRecorder()
|
||||
sess := &Session{data: map[string]string{}, store: store}
|
||||
sess.Set("user", "petr")
|
||||
store.Save(rec, sess)
|
||||
time.Sleep(time.Second)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(rec.Result().Cookies()[0])
|
||||
if got := store.Load(req).Get("user"); got != "" {
|
||||
t.Fatalf("expired cookie accepted: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMissingCookieYieldsEmptySession(t *testing.T) {
|
||||
store := New("k", time.Hour, false)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
sess := store.Load(req)
|
||||
if sess.Get("user") != "" || sess.dirty {
|
||||
t.Fatalf("session = %v dirty=%v", sess.data, sess.dirty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanSessionNotPersisted(t *testing.T) {
|
||||
store := New("k", time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
store.Save(rec, &Session{data: map[string]string{}, store: store})
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatal("cookie set for clean session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSameValueNotDirty(t *testing.T) {
|
||||
store := New("k", time.Hour, false)
|
||||
sess := &Session{data: map[string]string{"a": "1"}, store: store}
|
||||
sess.Set("a", "1")
|
||||
if sess.dirty {
|
||||
t.Fatal("dirty for unchanged value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAndClear(t *testing.T) {
|
||||
store := New("k", time.Hour, false)
|
||||
sess := &Session{data: map[string]string{"a": "1", "b": "2"}, store: store}
|
||||
sess.Delete("a")
|
||||
if sess.Get("a") != "" || !sess.dirty {
|
||||
t.Fatal("delete failed")
|
||||
}
|
||||
sess.dirty = false
|
||||
sess.Delete("missing")
|
||||
if sess.dirty {
|
||||
t.Fatal("deleting missing key dirtied the session")
|
||||
}
|
||||
sess.Clear()
|
||||
if sess.Get("b") != "" || !sess.dirty {
|
||||
t.Fatal("clear failed")
|
||||
}
|
||||
sess.dirty = false
|
||||
sess.Clear()
|
||||
if sess.dirty {
|
||||
t.Fatal("clearing empty session dirtied it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareRoundTrip(t *testing.T) {
|
||||
store := New("k", time.Hour, false)
|
||||
handler := store.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := FromContext(r.Context())
|
||||
sess.Set("user", "petr")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("cookies = %v", cookies)
|
||||
}
|
||||
|
||||
handler2 := store.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := FromContext(r.Context()).Get("user"); got != "petr" {
|
||||
t.Errorf("user = %q", got)
|
||||
}
|
||||
}))
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req2.AddCookie(cookies[0])
|
||||
handler2.ServeHTTP(rec2, req2)
|
||||
}
|
||||
|
||||
func TestFromContextWithoutMiddleware(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
sess := FromContext(req.Context())
|
||||
sess.Set("x", "y")
|
||||
if sess.Get("x") != "y" {
|
||||
t.Fatal("detached session broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageCookieIgnored(t *testing.T) {
|
||||
store := New("k", time.Hour, false)
|
||||
for _, value := range []string{"nodot", ".", "a.b", strings.Repeat("!", 50)} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: value})
|
||||
store.Load(req) // must not panic
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user