Add session package for encrypted cookie management

Uses gorilla/securecookie with keys derived via HKDF.
30-day TTL, HttpOnly, Secure, SameSiteStrict cookies.
This commit is contained in:
2026-01-08 07:37:58 -08:00
parent 3f4f345d1c
commit 041b18f651
2 changed files with 349 additions and 0 deletions

145
internal/session/session.go Normal file
View File

@@ -0,0 +1,145 @@
// Package session provides encrypted session cookie management.
package session
import (
"errors"
"net/http"
"time"
"github.com/gorilla/securecookie"
"sneak.berlin/go/pixa/internal/seal"
)
// Session configuration constants.
const (
CookieName = "pixa_session"
SessionTTL = 30 * 24 * time.Hour // 30 days
// HKDF salts for key derivation
hashKeySalt = "pixa-session-hash-v1"
blockKeySalt = "pixa-session-block-v1"
)
// Errors returned by session operations.
var (
ErrInvalidSession = errors.New("invalid or expired session")
ErrNoSession = errors.New("no session cookie present")
)
// Data contains the session payload stored in the encrypted cookie.
type Data struct {
Authenticated bool `json:"auth"`
CreatedAt time.Time `json:"created"`
ExpiresAt time.Time `json:"expires"`
}
// Manager handles session creation and validation using encrypted cookies.
type Manager struct {
sc *securecookie.SecureCookie
secure bool // Set Secure flag on cookies (should be true in production)
sameSite http.SameSite
}
// NewManager creates a session manager with keys derived from the signing key.
// Set secure=true in production to require HTTPS for cookies.
func NewManager(signingKey string, secure bool) (*Manager, error) {
masterKey := []byte(signingKey)
// Derive separate keys for HMAC (hash) and encryption (block)
hashKey, err := seal.DeriveKey(masterKey, hashKeySalt)
if err != nil {
return nil, err
}
blockKey, err := seal.DeriveKey(masterKey, blockKeySalt)
if err != nil {
return nil, err
}
sc := securecookie.New(hashKey[:], blockKey[:])
sc.MaxAge(int(SessionTTL.Seconds()))
return &Manager{
sc: sc,
secure: secure,
sameSite: http.SameSiteStrictMode,
}, nil
}
// CreateSession creates a new authenticated session and sets the cookie.
func (m *Manager) CreateSession(w http.ResponseWriter) error {
now := time.Now()
data := &Data{
Authenticated: true,
CreatedAt: now,
ExpiresAt: now.Add(SessionTTL),
}
encoded, err := m.sc.Encode(CookieName, data)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: encoded,
Path: "/",
MaxAge: int(SessionTTL.Seconds()),
HttpOnly: true,
Secure: m.secure,
SameSite: m.sameSite,
})
return nil
}
// ValidateSession checks if the request has a valid session cookie.
// Returns the session data if valid, or an error if invalid/missing.
func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
cookie, err := r.Cookie(CookieName)
if err != nil {
if errors.Is(err, http.ErrNoCookie) {
return nil, ErrNoSession
}
return nil, err
}
var data Data
if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil {
return nil, ErrInvalidSession
}
// Check if session has expired (defense in depth - cookie MaxAge should handle this)
if time.Now().After(data.ExpiresAt) {
return nil, ErrInvalidSession
}
if !data.Authenticated {
return nil, ErrInvalidSession
}
return &data, nil
}
// ClearSession removes the session cookie.
func (m *Manager) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1, // Delete immediately
HttpOnly: true,
Secure: m.secure,
SameSite: m.sameSite,
})
}
// IsAuthenticated is a convenience method that returns true if the request
// has a valid authenticated session.
func (m *Manager) IsAuthenticated(r *http.Request) bool {
data, err := m.ValidateSession(r)
return err == nil && data != nil && data.Authenticated
}

View File

@@ -0,0 +1,204 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestManager_CreateAndValidate(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345", false)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
// Create a session
w := httptest.NewRecorder()
if err := mgr.CreateSession(w); err != nil {
t.Fatalf("CreateSession() error = %v", err)
}
// Extract the cookie from response
resp := w.Result()
cookies := resp.Cookies()
if len(cookies) == 0 {
t.Fatal("CreateSession() did not set a cookie")
}
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("CreateSession() did not set cookie named %q", CookieName)
}
// Validate the session
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
data, err := mgr.ValidateSession(req)
if err != nil {
t.Fatalf("ValidateSession() error = %v", err)
}
if !data.Authenticated {
t.Error("ValidateSession() returned unauthenticated session")
}
if data.ExpiresAt.Before(time.Now()) {
t.Error("ValidateSession() returned already-expired session")
}
}
func TestManager_ValidateSession_NoCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
_, err := mgr.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with no cookie")
}
if err != ErrNoSession {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrNoSession)
}
}
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{
Name: CookieName,
Value: "tampered-invalid-cookie-value",
})
_, err := mgr.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with tampered cookie")
}
if err != ErrInvalidSession {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrInvalidSession)
}
}
func TestManager_ValidateSession_WrongKey(t *testing.T) {
mgr1, _ := NewManager("signing-key-1", false)
mgr2, _ := NewManager("signing-key-2", false)
// Create session with mgr1
w := httptest.NewRecorder()
_ = mgr1.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
// Try to validate with mgr2 (different key)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
_, err := mgr2.ValidateSession(req)
if err == nil {
t.Error("ValidateSession() should fail with different signing key")
}
}
func TestManager_ClearSession(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
w := httptest.NewRecorder()
mgr.ClearSession(w)
resp := w.Result()
cookies := resp.Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("ClearSession() did not set a cookie")
}
if sessionCookie.MaxAge != -1 {
t.Errorf("ClearSession() cookie MaxAge = %d, want -1", sessionCookie.MaxAge)
}
}
func TestManager_IsAuthenticated(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false)
// No session - should return false
req := httptest.NewRequest(http.MethodGet, "/", nil)
if mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return false with no session")
}
// Create session
w := httptest.NewRecorder()
_ = mgr.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
// With valid session - should return true
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
if !mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return true with valid session")
}
}
func TestManager_CookieAttributes(t *testing.T) {
// Test with secure=true
mgr, _ := NewManager("test-key", true)
w := httptest.NewRecorder()
_ = mgr.CreateSession(w)
resp := w.Result()
var sessionCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if !sessionCookie.HttpOnly {
t.Error("Cookie should have HttpOnly flag")
}
if !sessionCookie.Secure {
t.Error("Cookie should have Secure flag when manager created with secure=true")
}
if sessionCookie.SameSite != http.SameSiteStrictMode {
t.Errorf("Cookie SameSite = %v, want %v", sessionCookie.SameSite, http.SameSiteStrictMode)
}
}