--- id: "ET-AUTH-003" title: "Database Schema for Auth" type: "Specification" status: "Done" rf_parent: "RF-AUTH-002" epic: "OQI-001" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-AUTH-003: Especificación Técnica - Esquema de Base de Datos **Version:** 1.0.0 **Fecha:** 2025-12-05 **Estado:** ✅ Implementado **Épica:** [OQI-001](../_MAP.md) --- ## Resumen Esta especificación detalla el esquema de base de datos para el sistema de autenticación de Trading Platform. --- ## Diagrama ER ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ AUTHENTICATION SCHEMA │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────┐ ┌─────────────────────┐ │ users │ │ user_profiles │ ├─────────────────────┤ ├─────────────────────┤ │ id (PK) │──1:1──│ id (PK) │ │ email │ │ user_id (FK) │ │ password_hash │ │ display_name │ │ phone │ │ bio │ │ first_name │ │ avatar_url │ │ last_name │ │ preferred_language │ │ role │ │ timezone │ │ status │ │ date_format │ │ email_verified │ │ notification_prefs │ │ phone_verified │ │ created_at │ │ two_factor_enabled │ │ updated_at │ │ two_factor_secret │ └─────────────────────┘ │ backup_codes │ │ created_at │ ┌─────────────────────┐ │ updated_at │ │ oauth_accounts │ │ deleted_at │ ├─────────────────────┤ └─────────────────────┘ │ id (PK) │ │ │ user_id (FK) │──┐ │ │ provider │ │ ├──────1:N──────────▶│ provider_user_id │ │ │ │ email │ │ │ │ access_token │ │ │ │ refresh_token │ │ │ │ token_expires_at │ │ │ │ raw_profile │ │ │ │ created_at │ │ │ │ updated_at │ │ │ └─────────────────────┘ │ │ │ │ ┌─────────────────────┐ │ │ │ sessions │ │ │ ├─────────────────────┤ │ ├──────1:N──────────▶│ id (PK) │ │ │ │ user_id (FK) │──┘ │ │ refresh_token_hash │ │ │ device_info │ │ │ ip_address │ │ │ location │ │ │ user_agent │ │ │ is_active │ │ │ created_at │ │ │ last_activity │ │ │ expires_at │ │ │ revoked_at │ │ │ revoked_reason │ │ └─────────────────────┘ │ │ ┌─────────────────────┐ │ │email_verifications │ │ ├─────────────────────┤ ├──────1:N──────────▶│ id (PK) │ │ │ user_id (FK) │ │ │ token_hash │ │ │ expires_at │ │ │ verified_at │ │ │ created_at │ │ └─────────────────────┘ │ │ ┌─────────────────────┐ │ │phone_verifications │ │ ├─────────────────────┤ ├──────1:N──────────▶│ id (PK) │ │ │ phone │ │ │ user_id (FK) │ │ │ code_hash │ │ │ channel │ │ │ attempts │ │ │ expires_at │ │ │ verified_at │ │ │ created_at │ │ └─────────────────────┘ │ │ ┌─────────────────────┐ │ │password_reset_tokens│ │ ├─────────────────────┤ └──────1:N──────────▶│ id (PK) │ │ user_id (FK) │ │ token_hash │ │ expires_at │ │ used_at │ │ created_at │ └─────────────────────┘ ``` --- ## ENUMs ```sql -- Roles de usuario CREATE TYPE user_role_enum AS ENUM ( 'investor', -- Usuario inversionista básico 'trader', -- Trader activo con más permisos 'student', -- Solo acceso a educación 'admin', -- Administrador 'superadmin' -- Super administrador ); -- Estados de usuario CREATE TYPE user_status_enum AS ENUM ( 'pending_verification', -- Email no verificado 'active', -- Cuenta activa 'suspended', -- Suspendida temporalmente 'banned', -- Baneada permanentemente 'deleted' -- Eliminada (soft delete) ); -- Proveedores OAuth CREATE TYPE auth_provider_enum AS ENUM ( 'google', 'facebook', 'twitter', 'apple', 'github' ); -- Canales de verificación telefónica CREATE TYPE phone_channel_enum AS ENUM ( 'sms', 'whatsapp' ); -- Eventos de autenticación (audit) CREATE TYPE auth_event_enum AS ENUM ( 'login_success', 'login_failed', 'logout', 'register', 'email_verified', 'phone_verified', 'password_changed', 'password_reset_requested', 'password_reset_completed', '2fa_enabled', '2fa_disabled', '2fa_verified', 'backup_code_used', 'oauth_linked', 'oauth_unlinked', 'session_revoked', 'account_suspended', 'account_reactivated' ); ``` --- ## Tablas ### users ```sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Identificadores email VARCHAR(255) UNIQUE, phone VARCHAR(20) UNIQUE, -- Credenciales password_hash VARCHAR(255), -- Datos personales first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, -- Permisos role user_role_enum NOT NULL DEFAULT 'investor', status user_status_enum NOT NULL DEFAULT 'pending_verification', -- Verificación email_verified BOOLEAN NOT NULL DEFAULT FALSE, phone_verified BOOLEAN NOT NULL DEFAULT FALSE, email_verified_at TIMESTAMPTZ, phone_verified_at TIMESTAMPTZ, -- 2FA two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, two_factor_secret VARCHAR(255), -- Encriptado backup_codes JSONB DEFAULT '[]', -- Array de hashes -- Metadata last_login_at TIMESTAMPTZ, login_count INTEGER DEFAULT 0, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMPTZ, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, -- Constraints CONSTRAINT email_or_phone_required CHECK ( email IS NOT NULL OR phone IS NOT NULL ), CONSTRAINT password_required_for_email CHECK ( email IS NULL OR password_hash IS NOT NULL OR EXISTS (SELECT 1 FROM oauth_accounts WHERE user_id = id) ) ); -- Índices CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL; CREATE INDEX idx_users_phone ON users(phone) WHERE deleted_at IS NULL; CREATE INDEX idx_users_status ON users(status) WHERE deleted_at IS NULL; CREATE INDEX idx_users_role ON users(role) WHERE deleted_at IS NULL; CREATE INDEX idx_users_created_at ON users(created_at); -- Trigger para updated_at CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### user_profiles ```sql CREATE TABLE user_profiles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, -- Display display_name VARCHAR(50), bio TEXT, avatar_url VARCHAR(500), -- Preferencias regionales preferred_language VARCHAR(5) DEFAULT 'es', timezone VARCHAR(50) DEFAULT 'America/Mexico_City', date_format VARCHAR(20) DEFAULT 'DD/MM/YYYY', currency VARCHAR(3) DEFAULT 'MXN', -- Preferencias de notificación notification_prefs JSONB DEFAULT '{ "email": { "marketing": true, "security": true, "trading": true, "education": true }, "push": { "trading_signals": true, "price_alerts": true, "course_updates": true }, "sms": { "security": true, "trading": false } }', -- Trading preferences trading_prefs JSONB DEFAULT '{ "default_timeframe": "1h", "chart_theme": "dark", "confirmations": true }', -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id); ``` ### oauth_accounts ```sql CREATE TABLE oauth_accounts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Proveedor provider auth_provider_enum NOT NULL, provider_user_id VARCHAR(255) NOT NULL, -- Datos del proveedor email VARCHAR(255), -- Tokens (encriptados) access_token TEXT, refresh_token TEXT, token_expires_at TIMESTAMPTZ, -- Perfil raw del proveedor raw_profile JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints UNIQUE(provider, provider_user_id), UNIQUE(user_id, provider) ); CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id); CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider); CREATE INDEX idx_oauth_accounts_email ON oauth_accounts(email); ``` ### sessions ```sql CREATE TABLE sessions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Token refresh_token_hash VARCHAR(255) NOT NULL, -- Device info device_info JSONB NOT NULL DEFAULT '{}', -- { -- "type": "desktop", -- "browser": "Chrome", -- "browserVersion": "120.0.0", -- "os": "Windows", -- "osVersion": "11" -- } -- Ubicación ip_address INET, location JSONB, -- { -- "country": "México", -- "countryCode": "MX", -- "region": "CDMX", -- "city": "Ciudad de México", -- "timezone": "America/Mexico_City" -- } user_agent TEXT, -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- Revocación revoked_at TIMESTAMPTZ, revoked_reason VARCHAR(100) ); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_token_hash ON sessions(refresh_token_hash); CREATE INDEX idx_sessions_active ON sessions(is_active) WHERE is_active = TRUE; CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE; ``` ### email_verifications ```sql CREATE TABLE email_verifications ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Token token_hash VARCHAR(255) NOT NULL, -- Estado expires_at TIMESTAMPTZ NOT NULL, verified_at TIMESTAMPTZ, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id); CREATE INDEX idx_email_verifications_token ON email_verifications(token_hash); ``` ### phone_verifications ```sql CREATE TABLE phone_verifications ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Teléfono (puede ser nuevo, no vinculado aún) phone VARCHAR(20) NOT NULL, user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- OTP code_hash VARCHAR(255) NOT NULL, channel phone_channel_enum NOT NULL, -- Intentos attempts INTEGER NOT NULL DEFAULT 0, -- Estado expires_at TIMESTAMPTZ NOT NULL, verified_at TIMESTAMPTZ, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_phone_verifications_phone ON phone_verifications(phone); CREATE INDEX idx_phone_verifications_user_id ON phone_verifications(user_id); ``` ### password_reset_tokens ```sql CREATE TABLE password_reset_tokens ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Token token_hash VARCHAR(255) NOT NULL, -- Estado expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token_hash); ``` ### auth_logs (Audit) ```sql CREATE TABLE auth_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- Evento event auth_event_enum NOT NULL, -- Contexto ip_address INET, user_agent TEXT, device_info JSONB, location JSONB, -- Detalles adicionales metadata JSONB DEFAULT '{}', -- Ejemplo: { "provider": "google", "reason": "invalid_password" } -- Resultado success BOOLEAN NOT NULL, error_message TEXT, -- Timestamp created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_auth_logs_user_id ON auth_logs(user_id); CREATE INDEX idx_auth_logs_event ON auth_logs(event); CREATE INDEX idx_auth_logs_created_at ON auth_logs(created_at); CREATE INDEX idx_auth_logs_ip ON auth_logs(ip_address); -- Particionar por fecha para mejor performance -- (considerar para producción con alto volumen) ``` --- ## Funciones ### update_updated_at_column ```sql CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; ``` ### log_auth_event ```sql CREATE OR REPLACE FUNCTION log_auth_event( p_user_id UUID, p_event auth_event_enum, p_ip_address INET, p_user_agent TEXT, p_device_info JSONB, p_location JSONB, p_success BOOLEAN, p_metadata JSONB DEFAULT '{}' ) RETURNS UUID AS $$ DECLARE v_log_id UUID; BEGIN INSERT INTO auth_logs ( user_id, event, ip_address, user_agent, device_info, location, success, metadata ) VALUES ( p_user_id, p_event, p_ip_address, p_user_agent, p_device_info, p_location, p_success, p_metadata ) RETURNING id INTO v_log_id; RETURN v_log_id; END; $$ LANGUAGE plpgsql; ``` ### cleanup_expired_sessions ```sql CREATE OR REPLACE FUNCTION cleanup_expired_sessions() RETURNS INTEGER AS $$ DECLARE v_count INTEGER; BEGIN UPDATE sessions SET is_active = FALSE, revoked_at = NOW(), revoked_reason = 'expired' WHERE is_active = TRUE AND expires_at < NOW(); GET DIAGNOSTICS v_count = ROW_COUNT; RETURN v_count; END; $$ LANGUAGE plpgsql; ``` --- ## Triggers ### Crear perfil al crear usuario ```sql CREATE OR REPLACE FUNCTION create_user_profile() RETURNS TRIGGER AS $$ BEGIN INSERT INTO user_profiles (user_id) VALUES (NEW.id); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_create_user_profile AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION create_user_profile(); ``` ### Log de eventos de autenticación ```sql CREATE OR REPLACE FUNCTION log_user_status_change() RETURNS TRIGGER AS $$ BEGIN IF OLD.status IS DISTINCT FROM NEW.status THEN IF NEW.status = 'suspended' THEN PERFORM log_auth_event( NEW.id, 'account_suspended', NULL, NULL, NULL, NULL, TRUE ); ELSIF NEW.status = 'active' AND OLD.status = 'suspended' THEN PERFORM log_auth_event( NEW.id, 'account_reactivated', NULL, NULL, NULL, NULL, TRUE ); END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_log_status_change AFTER UPDATE ON users FOR EACH ROW EXECUTE FUNCTION log_user_status_change(); ``` --- ## Migraciones ### Archivo: `001_create_auth_schema.sql` ```sql -- Extensiones requeridas CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ENUMs -- (copiar definiciones de ENUMs) -- Tablas -- (copiar definiciones de tablas en orden) -- Funciones -- (copiar funciones) -- Triggers -- (copiar triggers) -- Datos iniciales INSERT INTO users ( id, email, password_hash, first_name, last_name, role, status, email_verified ) VALUES ( 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'admin@trading.com', crypt('AdminPassword123!', gen_salt('bf', 12)), 'Admin', 'Trading Platform', 'superadmin', 'active', TRUE ); ``` --- ## Consideraciones de Performance ### Índices Parciales ```sql -- Solo usuarios activos CREATE INDEX idx_users_active_email ON users(email) WHERE status = 'active' AND deleted_at IS NULL; -- Solo sesiones activas no expiradas CREATE INDEX idx_sessions_valid ON sessions(user_id, expires_at) WHERE is_active = TRUE; ``` ### Particionamiento (Producción) ```sql -- Particionar auth_logs por mes CREATE TABLE auth_logs ( -- ... columnas ... ) PARTITION BY RANGE (created_at); CREATE TABLE auth_logs_2025_01 PARTITION OF auth_logs FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); -- ... más particiones ... ``` --- ## Referencias - [PostgreSQL UUID](https://www.postgresql.org/docs/current/uuid-ossp.html) - [PostgreSQL JSONB](https://www.postgresql.org/docs/current/datatype-json.html) - [PostgreSQL Partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)