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