- Replace old DDL structure with new numbered files (01-24) - Update migrations and seeds for new schema - Clean up deprecated files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
338 lines
11 KiB
PL/PgSQL
338 lines
11 KiB
PL/PgSQL
-- =============================================================
|
|
-- ARCHIVO: 06-auth-extended.sql
|
|
-- DESCRIPCION: Extensiones de autenticacion SaaS (JWT, OAuth, MFA)
|
|
-- VERSION: 1.0.0
|
|
-- PROYECTO: ERP-Core V2
|
|
-- FECHA: 2026-01-10
|
|
-- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001)
|
|
-- HISTORIAS: US-001, US-002, US-003
|
|
-- =============================================================
|
|
|
|
-- =====================
|
|
-- MODIFICACIONES A TABLAS EXISTENTES
|
|
-- =====================
|
|
|
|
-- Agregar columnas OAuth a auth.users (US-002)
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider VARCHAR(50);
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider_id VARCHAR(255);
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
|
|
|
-- Agregar columnas MFA a auth.users (US-003)
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE;
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT;
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_backup_codes TEXT[];
|
|
|
|
-- Agregar columna superadmin (EPIC-SAAS-006)
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE;
|
|
|
|
-- Indices para nuevas columnas
|
|
CREATE INDEX IF NOT EXISTS idx_users_oauth_provider ON auth.users(oauth_provider, oauth_provider_id) WHERE oauth_provider IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_users_mfa_enabled ON auth.users(mfa_enabled) WHERE mfa_enabled = TRUE;
|
|
|
|
-- =====================
|
|
-- TABLA: auth.sessions
|
|
-- Sesiones de usuario con refresh tokens (US-001)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS auth.sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Token info
|
|
refresh_token_hash VARCHAR(255) NOT NULL,
|
|
jti VARCHAR(255) UNIQUE NOT NULL, -- JWT ID para blacklist
|
|
|
|
-- Device info
|
|
device_info JSONB DEFAULT '{}',
|
|
device_fingerprint VARCHAR(255),
|
|
user_agent TEXT,
|
|
ip_address INET,
|
|
|
|
-- Geo info
|
|
country_code VARCHAR(2),
|
|
city VARCHAR(100),
|
|
|
|
-- Timestamps
|
|
last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
revoked_reason VARCHAR(100),
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Indices para sessions
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON auth.sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON auth.sessions(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON auth.sessions(jti);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON auth.sessions(expires_at) WHERE revoked_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked_at) WHERE revoked_at IS NULL;
|
|
|
|
-- =====================
|
|
-- TABLA: auth.token_blacklist
|
|
-- Tokens revocados/invalidados (US-001)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS auth.token_blacklist (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
jti VARCHAR(255) UNIQUE NOT NULL,
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
token_type VARCHAR(20) NOT NULL CHECK (token_type IN ('access', 'refresh')),
|
|
|
|
-- Metadata
|
|
reason VARCHAR(100),
|
|
revoked_by UUID REFERENCES auth.users(id),
|
|
|
|
-- Timestamps
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Indice para limpieza de tokens expirados
|
|
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON auth.token_blacklist(expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_token_blacklist_jti ON auth.token_blacklist(jti);
|
|
|
|
-- =====================
|
|
-- TABLA: auth.oauth_providers
|
|
-- Proveedores OAuth vinculados a usuarios (US-002)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS auth.oauth_providers (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Provider info
|
|
provider VARCHAR(50) NOT NULL, -- google, github, microsoft, apple
|
|
provider_user_id VARCHAR(255) NOT NULL,
|
|
provider_email VARCHAR(255),
|
|
|
|
-- Tokens (encrypted)
|
|
access_token_encrypted TEXT,
|
|
refresh_token_encrypted TEXT,
|
|
token_expires_at TIMESTAMPTZ,
|
|
|
|
-- Profile data from provider
|
|
profile_data JSONB DEFAULT '{}',
|
|
|
|
-- Timestamps
|
|
linked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
last_used_at TIMESTAMPTZ,
|
|
unlinked_at TIMESTAMPTZ,
|
|
|
|
UNIQUE(provider, provider_user_id)
|
|
);
|
|
|
|
-- Indices para oauth_providers
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_providers_user ON auth.oauth_providers(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_providers_provider ON auth.oauth_providers(provider, provider_user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_providers_email ON auth.oauth_providers(provider_email);
|
|
|
|
-- =====================
|
|
-- TABLA: auth.mfa_devices
|
|
-- Dispositivos MFA registrados (US-003)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS auth.mfa_devices (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Device info
|
|
device_type VARCHAR(50) NOT NULL, -- totp, sms, email, hardware_key
|
|
device_name VARCHAR(255),
|
|
|
|
-- TOTP specific
|
|
secret_encrypted TEXT,
|
|
|
|
-- Status
|
|
is_primary BOOLEAN DEFAULT FALSE,
|
|
is_verified BOOLEAN DEFAULT FALSE,
|
|
verified_at TIMESTAMPTZ,
|
|
|
|
-- Usage tracking
|
|
last_used_at TIMESTAMPTZ,
|
|
use_count INTEGER DEFAULT 0,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
disabled_at TIMESTAMPTZ
|
|
);
|
|
|
|
-- Indices para mfa_devices
|
|
CREATE INDEX IF NOT EXISTS idx_mfa_devices_user ON auth.mfa_devices(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_mfa_devices_primary ON auth.mfa_devices(user_id, is_primary) WHERE is_primary = TRUE;
|
|
|
|
-- =====================
|
|
-- TABLA: auth.mfa_backup_codes
|
|
-- Codigos de respaldo MFA (US-003)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS auth.mfa_backup_codes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Code (hashed)
|
|
code_hash VARCHAR(255) NOT NULL,
|
|
|
|
-- Status
|
|
used_at TIMESTAMPTZ,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMPTZ
|
|
);
|
|
|
|
-- Indices para mfa_backup_codes
|
|
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user ON auth.mfa_backup_codes(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_unused ON auth.mfa_backup_codes(user_id, used_at) WHERE used_at IS NULL;
|
|
|
|
-- =====================
|
|
-- TABLA: auth.login_attempts
|
|
-- Intentos de login para rate limiting y seguridad
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS auth.login_attempts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Identificacion
|
|
email VARCHAR(255),
|
|
ip_address INET NOT NULL,
|
|
user_agent TEXT,
|
|
|
|
-- Resultado
|
|
success BOOLEAN NOT NULL,
|
|
failure_reason VARCHAR(100),
|
|
|
|
-- MFA
|
|
mfa_required BOOLEAN DEFAULT FALSE,
|
|
mfa_passed BOOLEAN,
|
|
|
|
-- Metadata
|
|
tenant_id UUID REFERENCES auth.tenants(id),
|
|
user_id UUID REFERENCES auth.users(id),
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Indices para login_attempts
|
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_email ON auth.login_attempts(email, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON auth.login_attempts(created_at);
|
|
|
|
-- =====================
|
|
-- RLS POLICIES
|
|
-- =====================
|
|
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_sessions ON auth.sessions
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
ALTER TABLE auth.oauth_providers ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_oauth ON auth.oauth_providers
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
ALTER TABLE auth.mfa_devices ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY user_isolation_mfa_devices ON auth.mfa_devices
|
|
USING (user_id = current_setting('app.current_user_id', true)::uuid);
|
|
|
|
ALTER TABLE auth.mfa_backup_codes ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY user_isolation_mfa_codes ON auth.mfa_backup_codes
|
|
USING (user_id = current_setting('app.current_user_id', true)::uuid);
|
|
|
|
-- =====================
|
|
-- FUNCIONES
|
|
-- =====================
|
|
|
|
-- Funcion para limpiar sesiones expiradas
|
|
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
|
|
RETURNS INTEGER AS $$
|
|
DECLARE
|
|
deleted_count INTEGER;
|
|
BEGIN
|
|
DELETE FROM auth.sessions
|
|
WHERE expires_at < CURRENT_TIMESTAMP
|
|
OR revoked_at IS NOT NULL;
|
|
|
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
RETURN deleted_count;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Funcion para limpiar tokens expirados del blacklist
|
|
CREATE OR REPLACE FUNCTION auth.cleanup_expired_tokens()
|
|
RETURNS INTEGER AS $$
|
|
DECLARE
|
|
deleted_count INTEGER;
|
|
BEGIN
|
|
DELETE FROM auth.token_blacklist
|
|
WHERE expires_at < CURRENT_TIMESTAMP;
|
|
|
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
RETURN deleted_count;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Funcion para limpiar intentos de login antiguos (mas de 30 dias)
|
|
CREATE OR REPLACE FUNCTION auth.cleanup_old_login_attempts()
|
|
RETURNS INTEGER AS $$
|
|
DECLARE
|
|
deleted_count INTEGER;
|
|
BEGIN
|
|
DELETE FROM auth.login_attempts
|
|
WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '30 days';
|
|
|
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
RETURN deleted_count;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Funcion para verificar rate limit de login
|
|
CREATE OR REPLACE FUNCTION auth.check_login_rate_limit(
|
|
p_email VARCHAR(255),
|
|
p_ip_address INET,
|
|
p_max_attempts INTEGER DEFAULT 5,
|
|
p_window_minutes INTEGER DEFAULT 15
|
|
)
|
|
RETURNS BOOLEAN AS $$
|
|
DECLARE
|
|
attempt_count INTEGER;
|
|
BEGIN
|
|
SELECT COUNT(*) INTO attempt_count
|
|
FROM auth.login_attempts
|
|
WHERE (email = p_email OR ip_address = p_ip_address)
|
|
AND success = FALSE
|
|
AND created_at > CURRENT_TIMESTAMP - (p_window_minutes || ' minutes')::INTERVAL;
|
|
|
|
RETURN attempt_count < p_max_attempts;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Funcion para revocar todas las sesiones de un usuario
|
|
CREATE OR REPLACE FUNCTION auth.revoke_all_user_sessions(
|
|
p_user_id UUID,
|
|
p_reason VARCHAR(100) DEFAULT 'manual_revocation'
|
|
)
|
|
RETURNS INTEGER AS $$
|
|
DECLARE
|
|
revoked_count INTEGER;
|
|
BEGIN
|
|
UPDATE auth.sessions
|
|
SET revoked_at = CURRENT_TIMESTAMP,
|
|
revoked_reason = p_reason
|
|
WHERE user_id = p_user_id
|
|
AND revoked_at IS NULL;
|
|
|
|
GET DIAGNOSTICS revoked_count = ROW_COUNT;
|
|
RETURN revoked_count;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- =====================
|
|
-- COMENTARIOS
|
|
-- =====================
|
|
COMMENT ON TABLE auth.sessions IS 'Sesiones de usuario con refresh tokens para JWT';
|
|
COMMENT ON TABLE auth.token_blacklist IS 'Tokens JWT revocados antes de su expiracion';
|
|
COMMENT ON TABLE auth.oauth_providers IS 'Proveedores OAuth vinculados a cuentas de usuario';
|
|
COMMENT ON TABLE auth.mfa_devices IS 'Dispositivos MFA registrados por usuario';
|
|
COMMENT ON TABLE auth.mfa_backup_codes IS 'Codigos de respaldo para MFA';
|
|
COMMENT ON TABLE auth.login_attempts IS 'Registro de intentos de login para seguridad';
|
|
|
|
COMMENT ON FUNCTION auth.cleanup_expired_sessions IS 'Limpia sesiones expiradas o revocadas';
|
|
COMMENT ON FUNCTION auth.cleanup_expired_tokens IS 'Limpia tokens del blacklist que ya expiraron';
|
|
COMMENT ON FUNCTION auth.check_login_rate_limit IS 'Verifica si un email/IP ha excedido intentos de login';
|
|
COMMENT ON FUNCTION auth.revoke_all_user_sessions IS 'Revoca todas las sesiones activas de un usuario';
|