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

436 lines
21 KiB
Markdown

# 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
```go
// 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_cleanup``DELETE 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
```go
// 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
```go
// 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)
```go
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)
```go
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.