fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
21 KiB
id, title, status, type, domain, scope, priority, depends, blocks, related, created, updated, tags
| id | title | status | type | domain | scope | priority | depends | blocks | related | created | updated | tags |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0010 | Auth System | completado | feature | multi-app | alta | 2026-05-17 | 2026-05-17 |
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_generateen Bash) pero son wrappers depass, no primitivas de hashing/verificacion. generate_password_bash_cybersecuritygenera passwords pero no los hashea.- Cada vez que una app necesita auth (como
sqlite_apiodeploy_server), hay que implementar JWT, sesiones y validacion ad-hoc. - Con estas funciones, una app nueva solo hace:
password_hashal registrar,jwt_generateal login,jwt_middlewareen las rutas protegidas, y opcionalmenterbac_middlewarepara permisos granulares. - Las funciones HTTP server de 0009 (
Middlewaretype,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 conhttp.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
JWTClaimsenfunctions/infra/jwt_claims.gocon.mdentypes/infra/jwt_claims.md - 1.2 Crear tipo
Sessionenfunctions/infra/session.gocon.mdentypes/infra/session.md - 1.3 Crear tipo
OAuthConfigenfunctions/infra/oauth_config.gocon.mdentypes/infra/oauth_config.md - 1.4 Crear tipo
OAuthTokensenfunctions/infra/oauth_tokens.gocon.mdentypes/infra/oauth_tokens.md - 1.5 Crear tipo
Permissionenfunctions/infra/permission.gocon.mdentypes/infra/permission.md - 1.6 Crear tipo
Roleenfunctions/infra/role.gocon.mdentypes/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. Seteaiatautomaticamente si no viene en claims. - 2.2
jwt_validate— split por., verifica firma HMAC-SHA256, decodifica claims, validaexpcontratime.Now(). Error descriptivo si firma invalida, expirado, o malformado. - 2.3
password_hash— wrapper degolang.org/x/crypto/bcrypt.GenerateFromPassword. Cost por defecto: 12. - 2.4
password_verify— wrapper debcrypt.CompareHashAndPassword. Retorna nil si match, error si no.
Fase 3: Sesiones SQLite
- 3.1
session_create— genera token concrypto/rand(32 bytes hex), inserta en tablasessions(la funcion crea la tabla si no existe viaCREATE TABLE IF NOT EXISTS), retornaSession. - 3.2
session_validate— busca token en tablasessions, verifica que no este expirado, retornaSessiono 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 atoken_urlcongrant_type=authorization_code,code,client_id,client_secret,redirect_uri. Parsea JSON response aOAuthTokens. - 4.3
oauth2_refresh— POST atoken_urlcongrant_type=refresh_token,refresh_token,client_id,client_secret. Parsea JSON response aOAuthTokens.
Fase 5: RBAC + Middlewares
- 5.1
rbac_check— busca el rol por nombre en[]Role, itera sus permisos, retornatruesi encuentra match deresource+action. Funcion pura. - 5.2
jwt_middleware— extrae token del headerAuthorization: Bearer <token>, valida conjwt_validate, inyecta claims enr.Context()concontext.WithValue, llamanext.ServeHTTP. Retorna 401 si falta token o es invalido. Usa tipoMiddlewarede 0009. - 5.3
rbac_middleware— extrae claims del context (puestas porjwt_middleware), lee el rol del campocustom["role"], evalua conrbac_check. Retorna 403 si no tiene permiso. Requiere quejwt_middlewarese 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_urlgenera URL correcta (test puro), exchange/refresh conhttptest.NewServermock - 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 indexy verificar que todas las funciones y tipos aparecen en registry.db - 6.8 Verificar
go vet -tags fts5ygo 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
sessionsse crea en la BD de la app, no en registry.db.CREATE TABLE IF NOT EXISTSevita setup manual. - OAuth2 sin
golang.org/x/oauth2: las tres operaciones (auth_url, exchange, refresh) son suficientemente simples para implementar connet/httpyencoding/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_checkes pura — la unica funcion pura del modulo junto conoauth2_auth_url. - Claims en context con key privada:
jwt_middlewareinyecta claims enr.Context()usando un tipo no exportado como key (type contextKey struct{}) para evitar colisiones.rbac_middlewarelas extrae del mismo context.
Prerequisitos
- 0009 (HTTP Server Foundation): tipo
Middleware, funcioneshttp_middleware_chain,http_error_response,http_json_response,http_parse_body. Los middlewares de auth (jwt_middleware,rbac_middleware) retornanMiddlewarey usan los helpers de response para errores 401/403.
Riesgos
- Seguridad de la implementacion JWT manual: Mitigado manteniendo el scope en HS256, usando
hmac.Equalpara comparacion constant-time, y validando siempreexp. Documentar en cada.mdque 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_middlewaresolo leeAuthorization: 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_creategenera tokens concrypto/rand(32 bytes = 256 bits de entropia), haciendo inviable la prediccion o fijacion de tokens. - bcrypt timing attacks:
bcrypt.CompareHashAndPasswordya es constant-time internamente. No se necesita proteccion adicional.