feat: session_create, session_validate, session_cleanup

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
This commit is contained in:
2026-04-18 17:40:13 +02:00
parent eff5771b03
commit 9153a20384
7 changed files with 432 additions and 0 deletions
+24
View File
@@ -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
}
+47
View File
@@ -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.
+83
View File
@@ -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
}
+51
View File
@@ -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.
+126
View File
@@ -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)
}
}
+58
View File
@@ -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
}
+43
View File
@@ -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.