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:
145
internal/session/session.go
Normal file
145
internal/session/session.go
Normal 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
|
||||
}
|
||||
204
internal/session/session_test.go
Normal file
204
internal/session/session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user