Files
fn_registry/dev/issues/completed/0010-auth-system.md
T
2026-04-18 17:44:41 +02:00

21 KiB

0010 — Auth System

Metadata

Campo Valor
ID 0010
Estado pendiente
Prioridad alta
Tipo feature

Dependencias

Depende de 0009 (HTTP Server Foundation) para los middleware de auth (Middleware type, http_middleware_chain, http_error_response).


Objetivo

Crear funciones reutilizables de autenticacion y autorizacion en Go (dominio infra) cubriendo JWT, hashing de passwords, sesiones SQLite, cliente OAuth2 y checks RBAC, de forma que cualquier app nueva del registry pueda tener auth completo componiendo primitivas en vez de implementarlo desde cero.

Contexto

  • Actualmente el registry tiene CERO funciones de auth genericas. Solo existe auth_form_ts_ui (componente UI de login) y funciones de auth especificas para Metabase (metabase_auth_go_infra, metabase_auth_py_infra).
  • Existen funciones de password store (pass_get, pass_set, pass_generate en Bash) pero son wrappers de pass, no primitivas de hashing/verificacion.
  • generate_password_bash_cybersecurity genera passwords pero no los hashea.
  • Cada vez que una app necesita auth (como sqlite_api o deploy_server), hay que implementar JWT, sesiones y validacion ad-hoc.
  • Con estas funciones, una app nueva solo hace: password_hash al registrar, jwt_generate al login, jwt_middleware en las rutas protegidas, y opcionalmente rbac_middleware para permisos granulares.
  • Las funciones HTTP server de 0009 (Middleware type, http_middleware_chain) son el punto de integracion natural para los middlewares de auth.

Arquitectura

functions/infra/
├── jwt_generate.go                — NEW: genera JWT firmado desde claims + secret
├── jwt_generate.md                — NEW
├── jwt_validate.go                — NEW: valida JWT, retorna claims o error
├── jwt_validate.md                — NEW
├── jwt_middleware.go              — NEW: middleware HTTP que valida JWT del header Authorization
├── jwt_middleware.md              — NEW
├── password_hash.go               — NEW: hashea password con bcrypt
├── password_hash.md               — NEW
├── password_verify.go             — NEW: verifica password contra hash bcrypt
├── password_verify.md             — NEW
├── session_create.go              — NEW: crea sesion en SQLite, retorna token
├── session_create.md              — NEW
├── session_validate.go            — NEW: valida token de sesion, retorna datos de usuario
├── session_validate.md            — NEW
├── session_cleanup.go             — NEW: elimina sesiones expiradas
├── session_cleanup.md             — NEW
├── oauth2_auth_url.go             — NEW: construye URL de autorizacion OAuth2
├── oauth2_auth_url.md             — NEW
├── oauth2_exchange.go             — NEW: intercambia auth code por tokens
├── oauth2_exchange.md             — NEW
├── oauth2_refresh.go              — NEW: renueva access token con refresh token
├── oauth2_refresh.md              — NEW
├── rbac_check.go                  — NEW: verifica si un rol tiene un permiso (datos puros)
├── rbac_check.md                  — NEW
├── rbac_middleware.go             — NEW: middleware HTTP que verifica permisos tras auth
├── rbac_middleware.md             — NEW

types/infra/
├── jwt_claims.md                  — NEW: metadata del tipo JWTClaims
├── session.md                     — NEW: metadata del tipo Session
├── oauth_config.md                — NEW: metadata del tipo OAuthConfig
├── oauth_tokens.md                — NEW: metadata del tipo OAuthTokens
├── permission.md                  — NEW: metadata del tipo Permission
├── role.md                        — NEW: metadata del tipo Role

Patron pure core / impure shell

  • Pure: oauth2_auth_url (construye string URL sin I/O), rbac_check (evaluacion de datos en memoria)
  • Impure: todo lo demas — JWT usa time.Now() para expiracion/validacion, bcrypt hace trabajo criptografico con entropia del OS, sesiones interactuan con SQLite, OAuth2 hace HTTP requests, middlewares interactuan con http.Request/http.ResponseWriter

Diseno

Tipos

// JWTClaims contiene claims estandar y custom para un JWT
type JWTClaims struct {
    Subject   string            `json:"sub"`
    Issuer    string            `json:"iss,omitempty"`
    Audience  string            `json:"aud,omitempty"`
    ExpiresAt int64             `json:"exp"`
    IssuedAt  int64             `json:"iat"`
    Custom    map[string]any    `json:"custom,omitempty"`
}

// Session representa una sesion de usuario almacenada en SQLite
type Session struct {
    Token     string         `json:"token"`
    UserID    string         `json:"user_id"`
    ExpiresAt int64          `json:"expires_at"`
    CreatedAt int64          `json:"created_at"`
    Metadata  map[string]any `json:"metadata,omitempty"`
}

// OAuthConfig contiene la configuracion de un proveedor OAuth2
type OAuthConfig struct {
    ClientID     string   `json:"client_id"`
    ClientSecret string   `json:"client_secret"`
    AuthURL      string   `json:"auth_url"`
    TokenURL     string   `json:"token_url"`
    RedirectURL  string   `json:"redirect_url"`
    Scopes       []string `json:"scopes"`
}

// OAuthTokens contiene los tokens obtenidos de un flujo OAuth2
type OAuthTokens struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    TokenType    string `json:"token_type"`
    ExpiresAt    int64  `json:"expires_at"`
}

// Permission representa una accion sobre un recurso
type Permission struct {
    Resource string `json:"resource"`
    Action   string `json:"action"`
}

// Role agrupa permisos bajo un nombre
type Role struct {
    Name        string       `json:"name"`
    Permissions []Permission `json:"permissions"`
}

Funciones

Funcion Purity Firma (simplificada)
jwt_generate impure (claims JWTClaims, secret string) (string, error)
jwt_validate impure (token string, secret string) (JWTClaims, error)
jwt_middleware impure (secret string) Middleware
password_hash impure (password string, cost int) (string, error)
password_verify impure (password string, hash string) error
session_create impure (db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)
session_validate impure (db *sql.DB, token string) (Session, error)
session_cleanup impure (db *sql.DB) (int64, error)
oauth2_auth_url pure (config OAuthConfig, state string) string
oauth2_exchange impure (config OAuthConfig, code string) (OAuthTokens, error)
oauth2_refresh impure (config OAuthConfig, refreshToken string) (OAuthTokens, error)
rbac_check pure (roles []Role, roleName string, perm Permission) bool
rbac_middleware impure (roles []Role, requiredPerm Permission) Middleware

Tareas

Fase 1: Tipos

  • 1.1 Crear tipo JWTClaims en functions/infra/jwt_claims.go con .md en types/infra/jwt_claims.md
  • 1.2 Crear tipo Session en functions/infra/session.go con .md en types/infra/session.md
  • 1.3 Crear tipo OAuthConfig en functions/infra/oauth_config.go con .md en types/infra/oauth_config.md
  • 1.4 Crear tipo OAuthTokens en functions/infra/oauth_tokens.go con .md en types/infra/oauth_tokens.md
  • 1.5 Crear tipo Permission en functions/infra/permission.go con .md en types/infra/permission.md
  • 1.6 Crear tipo Role en functions/infra/role.go con .md en types/infra/role.md

Fase 2: JWT + Password (auth core)

  • 2.1 jwt_generate — codifica header+payload en base64url, firma con HMAC-SHA256, retorna token string. Setea iat automaticamente si no viene en claims.
  • 2.2 jwt_validate — split por ., verifica firma HMAC-SHA256, decodifica claims, valida exp contra time.Now(). Error descriptivo si firma invalida, expirado, o malformado.
  • 2.3 password_hash — wrapper de golang.org/x/crypto/bcrypt.GenerateFromPassword. Cost por defecto: 12.
  • 2.4 password_verify — wrapper de bcrypt.CompareHashAndPassword. Retorna nil si match, error si no.

Fase 3: Sesiones SQLite

  • 3.1 session_create — genera token con crypto/rand (32 bytes hex), inserta en tabla sessions (la funcion crea la tabla si no existe via CREATE TABLE IF NOT EXISTS), retorna Session.
  • 3.2 session_validate — busca token en tabla sessions, verifica que no este expirado, retorna Session o error.
  • 3.3 session_cleanupDELETE FROM sessions WHERE expires_at < ? con timestamp actual. Retorna cantidad de filas eliminadas.

Fase 4: OAuth2

  • 4.1 oauth2_auth_url — construye URL con query params: client_id, redirect_uri, response_type=code, scope, state. Funcion pura, solo concatena strings.
  • 4.2 oauth2_exchange — POST a token_url con grant_type=authorization_code, code, client_id, client_secret, redirect_uri. Parsea JSON response a OAuthTokens.
  • 4.3 oauth2_refresh — POST a token_url con grant_type=refresh_token, refresh_token, client_id, client_secret. Parsea JSON response a OAuthTokens.

Fase 5: RBAC + Middlewares

  • 5.1 rbac_check — busca el rol por nombre en []Role, itera sus permisos, retorna true si encuentra match de resource + action. Funcion pura.
  • 5.2 jwt_middleware — extrae token del header Authorization: Bearer <token>, valida con jwt_validate, inyecta claims en r.Context() con context.WithValue, llama next.ServeHTTP. Retorna 401 si falta token o es invalido. Usa tipo Middleware de 0009.
  • 5.3 rbac_middleware — extrae claims del context (puestas por jwt_middleware), lee el rol del campo custom["role"], evalua con rbac_check. Retorna 403 si no tiene permiso. Requiere que jwt_middleware se ejecute antes en la chain.

Fase 6: Tests y cleanup

  • 6.1 Tests para JWT: generar token, validar token valido, rechazar token expirado, rechazar firma invalida
  • 6.2 Tests para password: hash y verify OK, verify con password incorrecto falla
  • 6.3 Tests para sesiones: crear, validar, validar expirada, cleanup
  • 6.4 Tests para OAuth2: oauth2_auth_url genera URL correcta (test puro), exchange/refresh con httptest.NewServer mock
  • 6.5 Tests para RBAC: check con permiso, sin permiso, rol inexistente
  • 6.6 Tests para middlewares: jwt_middleware con token valido/invalido/ausente, rbac_middleware con/sin permiso
  • 6.7 fn index y verificar que todas las funciones y tipos aparecen en registry.db
  • 6.8 Verificar go vet -tags fts5 y go test -tags fts5 ./functions/infra/

Ejemplo de uso

Registro de usuario y login con JWT

// Handler de registro
func registerHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
        return
    }

    hash, err := infra.PasswordHash(input.Password, 12)
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()})
        return
    }

    // Guardar email + hash en la BD de la app...
    userID := saveUser(input.Email, hash)

    infra.HttpJsonResponse(w, 201, map[string]string{"id": userID})
}

// Handler de login
func loginHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
        return
    }

    user, err := findUserByEmail(input.Email)
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
        return
    }

    if err := infra.PasswordVerify(input.Password, user.PasswordHash); err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
        return
    }

    claims := infra.JWTClaims{
        Subject:   user.ID,
        ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
        Custom:    map[string]any{"role": "admin", "email": user.Email},
    }
    token, err := infra.JwtGenerate(claims, os.Getenv("JWT_SECRET"))
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "token_error", Message: err.Error()})
        return
    }

    infra.HttpJsonResponse(w, 200, map[string]string{"token": token})
}

Rutas protegidas con JWT + RBAC

// Definir roles y permisos como datos
roles := []infra.Role{
    {
        Name: "admin",
        Permissions: []infra.Permission{
            {Resource: "users", Action: "read"},
            {Resource: "users", Action: "write"},
            {Resource: "users", Action: "delete"},
        },
    },
    {
        Name: "viewer",
        Permissions: []infra.Permission{
            {Resource: "users", Action: "read"},
        },
    },
}

// Rutas publicas
publicRoutes := []infra.Route{
    {Method: "POST", Path: "/register", Handler: registerHandler},
    {Method: "POST", Path: "/login",    Handler: loginHandler},
}

// Rutas protegidas con JWT
protectedRoutes := []infra.Route{
    {Method: "GET", Path: "/api/me", Handler: meHandler},
}

// Rutas protegidas con JWT + RBAC
adminRoutes := []infra.Route{
    {Method: "DELETE", Path: "/api/users/{id}", Handler: deleteUserHandler},
}

secret := os.Getenv("JWT_SECRET")

mux := http.NewServeMux()

// Publicas: sin middleware de auth
for _, r := range publicRoutes {
    mux.HandleFunc(r.Method+" "+r.Path, r.Handler)
}

// Protegidas: JWT middleware
jwtProtected := infra.HttpMiddlewareChain(
    infra.JwtMiddleware(secret),
)
for _, r := range protectedRoutes {
    mux.Handle(r.Method+" "+r.Path, jwtProtected(http.HandlerFunc(r.Handler)))
}

// Admin: JWT + RBAC
adminProtected := infra.HttpMiddlewareChain(
    infra.JwtMiddleware(secret),
    infra.RbacMiddleware(roles, infra.Permission{Resource: "users", Action: "delete"}),
)
for _, r := range adminRoutes {
    mux.Handle(r.Method+" "+r.Path, adminProtected(http.HandlerFunc(r.Handler)))
}

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8080", mux, ctx)

Flujo OAuth2 (ej. Google)

googleConfig := infra.OAuthConfig{
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    AuthURL:      "https://accounts.google.com/o/oauth2/v2/auth",
    TokenURL:     "https://oauth2.googleapis.com/token",
    RedirectURL:  "http://localhost:8080/callback",
    Scopes:       []string{"openid", "email", "profile"},
}

// 1. Redirigir al usuario al proveedor
func oauthLoginHandler(w http.ResponseWriter, r *http.Request) {
    state := generateRandomState() // guardar en cookie/session para validar despues
    url := infra.Oauth2AuthUrl(googleConfig, state)
    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

// 2. Callback: intercambiar code por tokens
func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")
    state := r.URL.Query().Get("state")

    // Validar state contra el guardado en cookie/session...

    tokens, err := infra.Oauth2Exchange(googleConfig, code)
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()})
        return
    }

    // Usar tokens.AccessToken para obtener datos del usuario...
    // Crear sesion local o JWT propio
    infra.HttpJsonResponse(w, 200, map[string]string{"access_token": tokens.AccessToken})
}

// 3. Renovar token cuando expire
func refreshHandler(w http.ResponseWriter, r *http.Request) {
    refreshToken := extractRefreshToken(r)
    newTokens, err := infra.Oauth2Refresh(googleConfig, refreshToken)
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()})
        return
    }
    infra.HttpJsonResponse(w, 200, newTokens)
}

Sesiones SQLite (alternativa a JWT para apps con estado)

db, _ := sql.Open("sqlite3", "app.db?_journal_mode=wal")

// Crear sesion al login
func sessionLoginHandler(w http.ResponseWriter, r *http.Request) {
    // ... validar credenciales ...
    session, err := infra.SessionCreate(db, user.ID, 24*time.Hour, map[string]any{
        "email": user.Email,
        "role":  "admin",
    })
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "session_error", Message: err.Error()})
        return
    }
    infra.HttpJsonResponse(w, 200, map[string]string{"session_token": session.Token})
}

// Validar sesion en cada request
func protectedHandler(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("X-Session-Token")
    session, err := infra.SessionValidate(db, token)
    if err != nil {
        infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"})
        return
    }
    infra.HttpJsonResponse(w, 200, map[string]any{"user_id": session.UserID, "metadata": session.Metadata})
}

// Limpiar sesiones expiradas periodicamente
deleted, _ := infra.SessionCleanup(db)
fmt.Printf("sesiones eliminadas: %d\n", deleted)

Decisiones de diseno

  • bcrypt sobre argon2: bcrypt esta en golang.org/x/crypto (dependencia semi-oficial de Go), es robusto y ampliamente adoptado. Argon2 es mas moderno pero agrega complejidad sin beneficio practico para la mayoria de apps del registry. Cost por defecto 12 (buen balance velocidad/seguridad).
  • JWT con HMAC-SHA256 sin libreria externa: implementacion manual con crypto/hmac + crypto/sha256 + encoding/base64. Solo soporta HS256 — suficiente para apps single-server. Si se necesita RS256 (multiples servicios verificando), se crea una funcion separada en el futuro.
  • Sesiones en SQLite: coherente con la filosofia del registry (SQLite en todas partes). La tabla sessions se crea en la BD de la app, no en registry.db. CREATE TABLE IF NOT EXISTS evita setup manual.
  • OAuth2 sin golang.org/x/oauth2: las tres operaciones (auth_url, exchange, refresh) son suficientemente simples para implementar con net/http y encoding/json. Evita una dependencia mas y mantiene el control total del flujo.
  • RBAC como datos puros: roles y permisos son slices pasados como argumento, no leidos de BD. Cada app decide donde los almacena (hardcoded, JSON, SQLite). rbac_check es pura — la unica funcion pura del modulo junto con oauth2_auth_url.
  • Claims en context con key privada: jwt_middleware inyecta claims en r.Context() usando un tipo no exportado como key (type contextKey struct{}) para evitar colisiones. rbac_middleware las extrae del mismo context.

Prerequisitos

  • 0009 (HTTP Server Foundation): tipo Middleware, funciones http_middleware_chain, http_error_response, http_json_response, http_parse_body. Los middlewares de auth (jwt_middleware, rbac_middleware) retornan Middleware y usan los helpers de response para errores 401/403.

Riesgos

  • Seguridad de la implementacion JWT manual: Mitigado manteniendo el scope en HS256, usando hmac.Equal para comparacion constant-time, y validando siempre exp. Documentar en cada .md que NO es apto para escenarios multi-servicio donde se necesita RSA/ECDSA.
  • Almacenamiento de secrets: Las funciones reciben el secret como parametro (string), no lo leen de env ni de archivos. Es responsabilidad de la app obtener el secret de forma segura (env var, pass_get, etc.). Documentar este patron.
  • Token en header vs cookie: jwt_middleware solo lee Authorization: Bearer. Para apps que necesiten cookies (frontend SSR), se crearia un middleware separado en el futuro. No mezclar ambos patrones en la misma funcion.
  • SQL injection en sesiones: Mitigado usando prepared statements con ? placeholders en todas las queries de sesion. Nunca concatenar strings.
  • Session fixation: session_create genera tokens con crypto/rand (32 bytes = 256 bits de entropia), haciendo inviable la prediccion o fijacion de tokens.
  • bcrypt timing attacks: bcrypt.CompareHashAndPassword ya es constant-time internamente. No se necesita proteccion adicional.