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;
};