Inicio / Inteligencia Artificial / AI-First Full Stack: Construye Apps con IA / Autenticación y Gestión de Usuarios

Autenticación y Gestión de Usuarios

JWT, OAuth, sesiones, planes de suscripción, rate limiting por usuario y persistencia de conversaciones.

Intermedio
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Autenticación y Gestión de Usuarios

Las aplicaciones AI-First necesitan autenticación robusta para controlar acceso, rastrear el uso de tokens por usuario, implementar planes de suscripción y persistir conversaciones de forma segura.


JWT Authentication

Generación y verificación de tokens

import jwt from 'jsonwebtoken';
import { env } from '../config/env';

interface TokenPayload {
  userId: string;
  email: string;
  plan: 'FREE' | 'PRO' | 'ENTERPRISE';
}

export function generateToken(payload: TokenPayload): string {
  return jwt.sign(payload, env.JWT_SECRET, { expiresIn: '7d' });
}

export function verifyToken(token: string): TokenPayload {
  return jwt.verify(token, env.JWT_SECRET) as TokenPayload;
}

export function generateRefreshToken(userId: string): string {
  return jwt.sign({ userId }, env.JWT_SECRET, { expiresIn: '30d' });
}

Middleware de autenticación

import { Context, Next } from 'hono';
import { verifyToken } from '../utils/jwt';

export async function authMiddleware(c: Context, next: Next) {
  const header = c.req.header('Authorization');

  if (!header?.startsWith('Bearer ')) {
    return c.json({ error: 'Token requerido' }, 401);
  }

  try {
    const token = header.slice(7);
    const payload = verifyToken(token);
    c.set('userId', payload.userId);
    c.set('userPlan', payload.plan);
    await next();
  } catch {
    return c.json({ error: 'Token inválido o expirado' }, 401);
  }
}

Rutas de Auth

import { Hono } from 'hono';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { prisma } from '../db/client';
import { generateToken, generateRefreshToken } from '../utils/jwt';

const authRoutes = new Hono();

// Registro
const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(2).optional(),
});

authRoutes.post('/register', async (c) => {
  const { email, password, name } = registerSchema.parse(await c.req.json());

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    return c.json({ error: 'Email ya registrado' }, 409);
  }

  const hashedPassword = await bcrypt.hash(password, 12);
  const user = await prisma.user.create({
    data: { email, password: hashedPassword, name, plan: 'FREE' },
  });

  const token = generateToken({
    userId: user.id,
    email: user.email,
    plan: user.plan,
  });

  return c.json({ token, user: { id: user.id, email, name, plan: user.plan } });
});

// Login
authRoutes.post('/login', async (c) => {
  const { email, password } = z
    .object({ email: z.string().email(), password: z.string() })
    .parse(await c.req.json());

  const user = await prisma.user.findUnique({ where: { email } });
  if (!user || !(await bcrypt.compare(password, user.password))) {
    return c.json({ error: 'Credenciales inválidas' }, 401);
  }

  const token = generateToken({
    userId: user.id,
    email: user.email,
    plan: user.plan,
  });

  return c.json({ token, user: { id: user.id, email, name: user.name, plan: user.plan } });
});

export { authRoutes };

Planes de suscripción y límites

// config/plans.ts
export const PLAN_LIMITS = {
  FREE: {
    messagesPerDay: 20,
    tokensPerMonth: 100_000,
    maxConversations: 10,
    models: ['gpt-4o-mini'],
    maxFileSize: 5 * 1024 * 1024, // 5MB
    features: ['chat'],
  },
  PRO: {
    messagesPerDay: 200,
    tokensPerMonth: 2_000_000,
    maxConversations: 100,
    models: ['gpt-4o-mini', 'gpt-4o', 'claude-sonnet'],
    maxFileSize: 25 * 1024 * 1024, // 25MB
    features: ['chat', 'rag', 'file-upload', 'export'],
  },
  ENTERPRISE: {
    messagesPerDay: Infinity,
    tokensPerMonth: Infinity,
    maxConversations: Infinity,
    models: ['gpt-4o-mini', 'gpt-4o', 'claude-sonnet', 'claude-opus'],
    maxFileSize: 100 * 1024 * 1024, // 100MB
    features: ['chat', 'rag', 'file-upload', 'export', 'api-access', 'agents'],
  },
} as const;

export type Plan = keyof typeof PLAN_LIMITS;

Middleware de plan

export function requirePlan(minimumPlan: Plan) {
  return async (c: Context, next: Next) => {
    const userPlan = c.get('userPlan') as Plan;
    const planHierarchy = { FREE: 0, PRO: 1, ENTERPRISE: 2 };

    if (planHierarchy[userPlan] < planHierarchy[minimumPlan]) {
      return c.json({
        error: `Esta funcionalidad requiere plan ${minimumPlan}`,
        currentPlan: userPlan,
        requiredPlan: minimumPlan,
      }, 403);
    }

    await next();
  };
}

// Middleware para verificar modelo permitido
export function requireModel() {
  return async (c: Context, next: Next) => {
    const userPlan = c.get('userPlan') as Plan;
    const body = await c.req.json();
    const model = body.model || 'gpt-4o-mini';

    if (!PLAN_LIMITS[userPlan].models.includes(model)) {
      return c.json({
        error: `El modelo "${model}" no está disponible en tu plan ${userPlan}`,
        availableModels: PLAN_LIMITS[userPlan].models,
      }, 403);
    }

    await next();
  };
}

Tracking de uso de tokens

// services/usage.service.ts
export class UsageService {
  async trackTokenUsage(userId: string, tokens: number, model: string) {
    await prisma.tokenUsage.create({
      data: { userId, tokens, model, date: new Date() },
    });

    // Actualizar contador del usuario
    await prisma.user.update({
      where: { id: userId },
      data: { tokensUsed: { increment: tokens } },
    });
  }

  async checkUsageLimits(userId: string): Promise<{
    allowed: boolean;
    remaining: { messages: number; tokens: number };
  }> {
    const user = await prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new Error('User not found');

    const limits = PLAN_LIMITS[user.plan];

    // Mensajes hoy
    const todayMessages = await prisma.message.count({
      where: {
        conversation: { userId },
        role: 'user',
        createdAt: { gte: startOfDay(new Date()) },
      },
    });

    // Tokens este mes
    const monthTokens = await prisma.tokenUsage.aggregate({
      where: {
        userId,
        date: { gte: startOfMonth(new Date()) },
      },
      _sum: { tokens: true },
    });

    const tokensUsedThisMonth = monthTokens._sum.tokens || 0;

    return {
      allowed:
        todayMessages < limits.messagesPerDay &&
        tokensUsedThisMonth < limits.tokensPerMonth,
      remaining: {
        messages: limits.messagesPerDay - todayMessages,
        tokens: limits.tokensPerMonth - tokensUsedThisMonth,
      },
    };
  }
}

Persistencia de conversaciones

// services/conversation.service.ts
export class ConversationService {
  // Auto-generar título de conversación con IA
  async generateTitle(conversationId: string) {
    const messages = await prisma.message.findMany({
      where: { conversationId },
      take: 4,
      orderBy: { createdAt: 'asc' },
    });

    if (messages.length < 2) return;

    const response = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        {
          role: 'system',
          content: 'Genera un título corto (máx 6 palabras) para esta conversación. Solo el título, nada más.',
        },
        ...messages.map(m => ({
          role: m.role as 'user' | 'assistant',
          content: m.content.slice(0, 200),
        })),
      ],
      max_tokens: 20,
    });

    const title = response.choices[0].message.content!.replace(/['"]/g, '');

    await prisma.conversation.update({
      where: { id: conversationId },
      data: { title },
    });
  }

  // Exportar conversación como Markdown
  async exportAsMarkdown(conversationId: string, userId: string): Promise<string> {
    const conv = await prisma.conversation.findFirst({
      where: { id: conversationId, userId },
      include: { messages: { orderBy: { createdAt: 'asc' } } },
    });

    if (!conv) throw new Error('Conversación no encontrada');

    let md = `# ${conv.title}\n\n`;
    md += `Fecha: ${conv.createdAt.toISOString()}\n\n---\n\n`;

    for (const msg of conv.messages) {
      md += `## ${msg.role === 'user' ? '👤 Usuario' : '🤖 Asistente'}\n\n`;
      md += `${msg.content}\n\n---\n\n`;
    }

    return md;
  }
}

React: AuthProvider

// context/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';

interface User {
  id: string;
  email: string;
  name: string;
  plan: 'FREE' | 'PRO' | 'ENTERPRISE';
}

interface AuthContextType {
  user: User | null;
  token: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(
    () => localStorage.getItem('token')
  );
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (token) {
      fetch('/api/auth/me', {
        headers: { Authorization: `Bearer ${token}` },
      })
        .then(res => res.ok ? res.json() : Promise.reject())
        .then(data => setUser(data.user))
        .catch(() => { setToken(null); localStorage.removeItem('token'); })
        .finally(() => setIsLoading(false));
    } else {
      setIsLoading(false);
    }
  }, [token]);

  const login = async (email: string, password: string) => {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) throw new Error('Credenciales inválidas');

    const data = await res.json();
    setToken(data.token);
    setUser(data.user);
    localStorage.setItem('token', data.token);
  };

  const logout = () => {
    setToken(null);
    setUser(null);
    localStorage.removeItem('token');
  };

  return (
    <AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
};
🔒

Ejercicio práctico disponible

Sistema de planes y token budget

Desbloquear ejercicios
// Sistema de planes y token budget
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes