From 9153a2038439c956407c76a4f37981da0c865550 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:40:13 +0200 Subject: [PATCH] feat: session_create, session_validate, session_cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 3 del issue 0010 — sesiones SQLite como alternativa a JWT. - Tabla sessions creada con CREATE TABLE IF NOT EXISTS (autosetup) - Tokens de 32 bytes aleatorios (crypto/rand) codificados en hex (256 bits) - Indices en user_id y expires_at - Prepared statements para evitar SQL injection - SessionCleanup para eliminar expiradas periodicamente --- functions/infra/session_cleanup.go | 24 ++++++ functions/infra/session_cleanup.md | 47 +++++++++++ functions/infra/session_create.go | 83 ++++++++++++++++++ functions/infra/session_create.md | 51 +++++++++++ functions/infra/session_test.go | 126 ++++++++++++++++++++++++++++ functions/infra/session_validate.go | 58 +++++++++++++ functions/infra/session_validate.md | 43 ++++++++++ 7 files changed, 432 insertions(+) create mode 100644 functions/infra/session_cleanup.go create mode 100644 functions/infra/session_cleanup.md create mode 100644 functions/infra/session_create.go create mode 100644 functions/infra/session_create.md create mode 100644 functions/infra/session_test.go create mode 100644 functions/infra/session_validate.go create mode 100644 functions/infra/session_validate.md diff --git a/functions/infra/session_cleanup.go b/functions/infra/session_cleanup.go new file mode 100644 index 00000000..da61433f --- /dev/null +++ b/functions/infra/session_cleanup.go @@ -0,0 +1,24 @@ +package infra + +import ( + "database/sql" + "fmt" + "time" +) + +// SessionCleanup elimina todas las sesiones con expires_at < ahora. +// Retorna el numero de filas eliminadas. +func SessionCleanup(db *sql.DB) (int64, error) { + if db == nil { + return 0, fmt.Errorf("session_cleanup: db nil") + } + res, err := db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix()) + if err != nil { + return 0, fmt.Errorf("session_cleanup: delete: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("session_cleanup: rows_affected: %w", err) + } + return n, nil +} diff --git a/functions/infra/session_cleanup.md b/functions/infra/session_cleanup.md new file mode 100644 index 00000000..dc76a8bc --- /dev/null +++ b/functions/infra/session_cleanup.md @@ -0,0 +1,47 @@ +--- +name: session_cleanup +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SessionCleanup(db *sql.DB) (int64, error)" +description: "Elimina todas las sesiones expiradas de la tabla sessions. Retorna el numero de filas eliminadas. Ejecutar periodicamente (cada N horas) para evitar acumulacion." +tags: [session, auth, sqlite, cleanup, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [database/sql, fmt, time] +params: + - name: db + desc: "conexion SQL abierta a la BD de la app" +output: "numero de sesiones eliminadas en esta ejecucion" +tested: true +tests: ["elimina solo sesiones expiradas, mantiene las activas"] +test_file_path: "functions/infra/session_test.go" +file_path: "functions/infra/session_cleanup.go" +--- + +## Ejemplo + +```go +// En un ticker background de la app +go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + n, err := SessionCleanup(db) + if err != nil { + log.Printf("cleanup error: %v", err) + continue + } + log.Printf("sesiones expiradas eliminadas: %d", n) + } +}() +``` + +## Notas + +Impura — DELETE en SQLite, lee `time.Now()`. DELETE con prepared statement evita SQL injection. Es seguro ejecutar en paralelo con SessionCreate/SessionValidate (SQLite tiene locking por default en WAL mode). Ejecutar cada 1h es suficiente para la mayoria de apps; si la tabla crece mucho considerar tambien un DELETE parcial con LIMIT. diff --git a/functions/infra/session_create.go b/functions/infra/session_create.go new file mode 100644 index 00000000..f14687e0 --- /dev/null +++ b/functions/infra/session_create.go @@ -0,0 +1,83 @@ +package infra + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// sessionEnsureTable crea la tabla sessions si no existe. +// Columnas: token (PK), user_id, expires_at, created_at, metadata (JSON). +func sessionEnsureTable(db *sql.DB) error { + const stmt = ` +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +` + _, err := db.Exec(stmt) + return err +} + +// SessionCreate genera un token aleatorio de 32 bytes (64 hex chars) e +// inserta la sesion en la tabla sessions. Crea la tabla si no existe. +// Retorna la Session completa lista para devolver al cliente. +func SessionCreate(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error) { + var zero Session + if db == nil { + return zero, fmt.Errorf("session_create: db nil") + } + if userID == "" { + return zero, fmt.Errorf("session_create: user_id vacio") + } + if ttl <= 0 { + return zero, fmt.Errorf("session_create: ttl debe ser positivo") + } + if err := sessionEnsureTable(db); err != nil { + return zero, fmt.Errorf("session_create: crear tabla: %w", err) + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return zero, fmt.Errorf("session_create: rand: %w", err) + } + token := hex.EncodeToString(raw) + + now := time.Now().Unix() + exp := now + int64(ttl.Seconds()) + + var metaJSON []byte + if metadata == nil { + metaJSON = []byte("{}") + } else { + b, err := json.Marshal(metadata) + if err != nil { + return zero, fmt.Errorf("session_create: marshal metadata: %w", err) + } + metaJSON = b + } + + _, err := db.Exec( + "INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + token, userID, exp, now, string(metaJSON), + ) + if err != nil { + return zero, fmt.Errorf("session_create: insert: %w", err) + } + + return Session{ + Token: token, + UserID: userID, + ExpiresAt: exp, + CreatedAt: now, + Metadata: metadata, + }, nil +} diff --git a/functions/infra/session_create.md b/functions/infra/session_create.md new file mode 100644 index 00000000..b46f87a8 --- /dev/null +++ b/functions/infra/session_create.md @@ -0,0 +1,51 @@ +--- +name: session_create +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SessionCreate(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)" +description: "Crea una sesion SQLite con token aleatorio de 32 bytes hex (256 bits de entropia). Crea la tabla sessions si no existe. Retorna la Session lista para devolver al cliente." +tags: [session, auth, sqlite, token, infra] +uses_functions: [] +uses_types: [Session_go_infra] +returns: [Session_go_infra] +returns_optional: false +error_type: error_go_core +imports: [crypto/rand, database/sql, encoding/hex, encoding/json, fmt, time] +params: + - name: db + desc: "conexion SQL abierta a la BD de la app. No debe ser nil" + - name: userID + desc: "identificador del usuario al que pertenece la sesion. No vacio" + - name: ttl + desc: "tiempo de vida de la sesion (time.Duration). Debe ser positivo" + - name: metadata + desc: "datos libres a persistir junto a la sesion (role, email, ip, etc.). Puede ser nil" +output: "Session con token opaco listo para devolver al cliente via header o cookie" +tested: true +tests: ["crea sesion y persiste en BD", "genera tokens distintos", "rechaza db nil", "rechaza user_id vacio"] +test_file_path: "functions/infra/session_test.go" +file_path: "functions/infra/session_create.go" +--- + +## Ejemplo + +```go +db, _ := sql.Open("sqlite3", "app.db") +session, err := SessionCreate(db, user.ID, 24*time.Hour, map[string]any{ + "email": user.Email, + "role": "admin", + "ip": r.RemoteAddr, +}) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 500, Code: "session_error", Message: err.Error()}) + return +} +HTTPJSONResponse(w, 200, map[string]string{"token": session.Token}) +``` + +## Notas + +Impura — hace I/O en SQLite, usa entropia del OS con `crypto/rand`, y lee el tiempo con `time.Now()`. La tabla `sessions` se crea con `CREATE TABLE IF NOT EXISTS` (PK token, indices en user_id y expires_at) para que la primera llamada deje todo listo sin setup manual. Token es 32 bytes aleatorios codificados en hex = 64 chars, 256 bits de entropia. Session fixation mitigada: el token lo genera el servidor, no se acepta del cliente. Para invalidar una sesion: `DELETE FROM sessions WHERE token = ?`. Limpieza periodica de expiradas: SessionCleanup. diff --git a/functions/infra/session_test.go b/functions/infra/session_test.go new file mode 100644 index 00000000..b8c0a911 --- /dev/null +++ b/functions/infra/session_test.go @@ -0,0 +1,126 @@ +package infra + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func openSessionTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestSessionCreate_PersistsSession(t *testing.T) { + db := openSessionTestDB(t) + s, err := SessionCreate(db, "user-1", time.Hour, map[string]any{"role": "admin"}) + if err != nil { + t.Fatalf("SessionCreate: %v", err) + } + if s.Token == "" || len(s.Token) != 64 { + t.Errorf("token de largo incorrecto: len=%d", len(s.Token)) + } + if s.UserID != "user-1" { + t.Errorf("UserID = %q", s.UserID) + } + if s.ExpiresAt <= s.CreatedAt { + t.Errorf("ExpiresAt %d <= CreatedAt %d", s.ExpiresAt, s.CreatedAt) + } + + // Verificar persistencia + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&count); err != nil { + t.Fatalf("query: %v", err) + } + if count != 1 { + t.Errorf("esperaba 1 fila, got %d", count) + } +} + +func TestSessionCreate_GeneratesDistinctTokens(t *testing.T) { + db := openSessionTestDB(t) + s1, _ := SessionCreate(db, "u", time.Hour, nil) + s2, _ := SessionCreate(db, "u", time.Hour, nil) + if s1.Token == s2.Token { + t.Fatal("dos sesiones no pueden tener el mismo token") + } +} + +func TestSessionCreate_RejectsEmptyUserID(t *testing.T) { + db := openSessionTestDB(t) + if _, err := SessionCreate(db, "", time.Hour, nil); err == nil { + t.Fatal("esperaba error con user_id vacio") + } +} + +func TestSessionValidate_ValidSession(t *testing.T) { + db := openSessionTestDB(t) + s, _ := SessionCreate(db, "user-7", time.Hour, map[string]any{"k": "v"}) + + got, err := SessionValidate(db, s.Token) + if err != nil { + t.Fatalf("SessionValidate: %v", err) + } + if got.UserID != "user-7" { + t.Errorf("UserID = %q", got.UserID) + } + if got.Metadata["k"] != "v" { + t.Errorf("metadata[k] = %v", got.Metadata["k"]) + } +} + +func TestSessionValidate_MissingToken(t *testing.T) { + db := openSessionTestDB(t) + _ = sessionEnsureTable(db) + if _, err := SessionValidate(db, "nope"); err == nil { + t.Fatal("esperaba error con token inexistente") + } +} + +func TestSessionValidate_ExpiredSession(t *testing.T) { + db := openSessionTestDB(t) + _ = sessionEnsureTable(db) + // Insertar manualmente una sesion ya expirada + past := time.Now().Unix() - 60 + _, err := db.Exec( + "INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + "expired-tok", "u", past, past-100, "{}", + ) + if err != nil { + t.Fatalf("insert: %v", err) + } + if _, err := SessionValidate(db, "expired-tok"); err == nil { + t.Fatal("esperaba error con sesion expirada") + } +} + +func TestSessionCleanup_RemovesOnlyExpired(t *testing.T) { + db := openSessionTestDB(t) + active, _ := SessionCreate(db, "active-user", time.Hour, nil) + _ = sessionEnsureTable(db) + past := time.Now().Unix() - 60 + _, _ = db.Exec( + "INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + "expired", "u", past, past, "{}", + ) + + n, err := SessionCleanup(db) + if err != nil { + t.Fatalf("SessionCleanup: %v", err) + } + if n != 1 { + t.Errorf("esperaba 1 eliminada, got %d", n) + } + + // La activa sigue ahi + if _, err := SessionValidate(db, active.Token); err != nil { + t.Errorf("sesion activa no deberia haberse borrado: %v", err) + } +} diff --git a/functions/infra/session_validate.go b/functions/infra/session_validate.go new file mode 100644 index 00000000..d190efbe --- /dev/null +++ b/functions/infra/session_validate.go @@ -0,0 +1,58 @@ +package infra + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// SessionValidate busca el token en la tabla sessions, verifica que no este +// expirado y retorna la Session con su metadata deserializada. +// Error si el token no existe, esta expirado o la BD falla. +func SessionValidate(db *sql.DB, token string) (Session, error) { + var zero Session + if db == nil { + return zero, fmt.Errorf("session_validate: db nil") + } + if token == "" { + return zero, fmt.Errorf("session_validate: token vacio") + } + + var ( + userID string + expiresAt int64 + createdAt int64 + metaStr string + ) + row := db.QueryRow( + "SELECT user_id, expires_at, created_at, metadata FROM sessions WHERE token = ?", + token, + ) + if err := row.Scan(&userID, &expiresAt, &createdAt, &metaStr); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return zero, fmt.Errorf("session_validate: token no encontrado") + } + return zero, fmt.Errorf("session_validate: scan: %w", err) + } + + if time.Now().Unix() >= expiresAt { + return zero, fmt.Errorf("session_validate: sesion expirada") + } + + var metadata map[string]any + if metaStr != "" { + if err := json.Unmarshal([]byte(metaStr), &metadata); err != nil { + return zero, fmt.Errorf("session_validate: metadata malformada: %w", err) + } + } + + return Session{ + Token: token, + UserID: userID, + ExpiresAt: expiresAt, + CreatedAt: createdAt, + Metadata: metadata, + }, nil +} diff --git a/functions/infra/session_validate.md b/functions/infra/session_validate.md new file mode 100644 index 00000000..7ccf1f75 --- /dev/null +++ b/functions/infra/session_validate.md @@ -0,0 +1,43 @@ +--- +name: session_validate +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SessionValidate(db *sql.DB, token string) (Session, error)" +description: "Busca un token en la tabla sessions, verifica que no este expirado y retorna la Session con metadata deserializada. Error si no existe o expirado." +tags: [session, auth, sqlite, validate, infra] +uses_functions: [] +uses_types: [Session_go_infra] +returns: [Session_go_infra] +returns_optional: false +error_type: error_go_core +imports: [database/sql, encoding/json, errors, fmt, time] +params: + - name: db + desc: "conexion SQL abierta a la BD de la app" + - name: token + desc: "token opaco recibido del cliente (ej: header X-Session-Token o cookie)" +output: "Session valida con user_id, metadata y timestamps. Error si token invalido o expirado" +tested: true +tests: ["valida token existente no expirado", "rechaza token inexistente", "rechaza token expirado"] +test_file_path: "functions/infra/session_test.go" +file_path: "functions/infra/session_validate.go" +--- + +## Ejemplo + +```go +token := r.Header.Get("X-Session-Token") +session, err := SessionValidate(db, token) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"}) + return +} +role, _ := session.Metadata["role"].(string) +``` + +## Notas + +Impura — I/O en SQLite, lee `time.Now()` para validar expiracion. Query con prepared statement (`?` placeholder) para evitar SQL injection. En respuestas HTTP al cliente no distinguir entre "token no existe" y "expirado" para no filtrar informacion. Si necesitas invalidar la sesion tras validar (ej: renovar token): `DELETE FROM sessions WHERE token = ?` en el mismo handler.