Skip to Content
UNIMAST
DocumentaciónDesarrollarAuth y Autorización

Autenticación y Autorización

UNIMAST ERP implementa un sistema robusto de autenticación y autorización basado en sesiones persistentes, scopes granulares y middleware de protección de rutas.

🔐 Arquitectura del Sistema

Componentes Principales

app/ ├── middleware.ts # Protección automática de rutas ├── lib/auth/ # Lógica de autenticación │ ├── index.ts # Funciones principales de auth │ └── get-session.ts # Manejo de sesiones y tokens ├── components/auth/ # Componentes de autorización │ ├── WithScopes.tsx # Componente cliente para scopes │ └── server/ # Componentes del lado del servidor │ └── WithScopes.tsx # Verificación de scopes en servidor └── contexts/ # Estado global de autenticación └── session-provider.tsx # Provider de sesión para React

Flujo de Autenticación

  1. Usuario ingresa credencialessignIn()
  2. Verificación de contraseña → bcrypt comparison
  3. Creación de sesión → Token único + cookie segura
  4. Middleware protege rutas → Verificación automática
  5. Componentes verifican scopes → Renderizado condicional

🛡️ Middleware de Protección

Configuración del Middleware (app/middleware.ts)

import { auth } from "@/lib/auth"; // Configuración de rutas a proteger export const config = { runtime: 'nodejs', matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }; export default auth((req) => { // Redirigir a login si no está autenticado if (!req.auth && req.nextUrl.pathname !== "/login") { const newUrl = new URL("/login", req.nextUrl.origin) return Response.redirect(newUrl) } // Redirigir al dashboard si ya está autenticado if (req.auth && req.nextUrl.pathname === "/login") { const newUrl = new URL("/", req.nextUrl.origin) return Response.redirect(newUrl) } })

Características del Middleware

  • Protección automática de todas las rutas excepto estáticas
  • Redirección inteligente basada en estado de autenticación
  • Configuración de matchers para excluir rutas de API y assets
  • Ejecución en runtime Node.js para máxima compatibilidad

Rutas Protegidas vs. Públicas

// Rutas PROTEGIDAS (requieren autenticación) / # Dashboard principal /invoices # Módulo de facturas /transactions # Módulo de transacciones /settings # Configuraciones del sistema // Rutas PÚBLICAS (no requieren autenticación) /login # Página de login /api/* # Endpoints de API ❌ /_next/static/* # Assets estáticos de Next.js ❌ /favicon.ico # Favicon del sitio

🔑 Sistema de Sesiones

Estructura de Sesión

export type Session = { accessToken: string; // Token único de la sesión user: User; // Información del usuario role: Role; // Rol y scopes del usuario }; // Modelo de Usuario (Prisma) interface User { id: string; name: string | null; email: string; password: string | null; roleId: string; role: Role; // ... otros campos } // Modelo de Rol (Prisma) interface Role { id: string; name: string; description: string | null; scopes: string[]; // Array de permisos }

Gestión de Sesiones (app/lib/auth/get-session.ts)

export async function getSession(): Promise<Session | null> { // 1. Obtener token de la cookie const accessToken = (await cookies()).get(COOKIES.AUTH_TOKEN)?.value; if (!accessToken) { return null; } // 2. Buscar sesión en la base de datos const session = await prisma.session.findFirst({ where: { sessionToken: accessToken } }); if (!session) { return null; } // 3. Verificar expiración if (session.expires < new Date()) { await prisma.session.delete({ where: { sessionToken: accessToken } }); return null; } // 4. Obtener usuario y rol const user = await prisma.user.findFirst({ include: { role: true }, where: { id: session.userId } }); if (!user) { return null; } return { accessToken, user, role: user.role }; }

Configuración de Cookies

const config = { maxAge: 30 * 24 * 60 * 60, // 30 días path: '/', // Ruta de la cookie domain: process.env.HOST ?? 'localhost', httpOnly: true, // No accesible desde JavaScript secure: process.env.NODE_ENV === 'production' // Solo HTTPS en producción };

🔐 Proceso de Autenticación

Inicio de Sesión (signIn)

export async function signIn(credentials: { email: string; password: string; }): Promise<StatusResponse> { // 1. Buscar usuario por email const user = await prisma.user.findFirst({ where: { email: credentials.email } }); if (!user) { return { ok: false, message: 'Credenciales inválidas' }; } // 2. Verificar contraseña con bcrypt if (!bcrypt.compareSync(credentials.password, user?.password ?? '')) { return { ok: false, message: 'Credenciales inválidas' }; } // 3. Generar token único const sessionToken = generateToken(); // 4. Crear sesión en la base de datos const session = await prisma.session.create({ data: { userId: user.id, sessionToken, expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 días } }); // 5. Establecer cookie segura (await cookies()).set(COOKIES.AUTH_TOKEN, session.sessionToken, config); return { ok: true, message: 'Sesión Iniciada' }; }

Cierre de Sesión (signOut)

export async function signOut() { const reqCookies = await cookies(); const accessToken = reqCookies.get(COOKIES.AUTH_TOKEN)?.value; if (!accessToken) { return; } // 1. Eliminar sesión de la base de datos await prisma.session.deleteMany({ where: { sessionToken: accessToken } }); // 2. Eliminar cookie reqCookies.delete(COOKIES.AUTH_TOKEN); }

Generación de Tokens

function generateToken() { return Math.random().toString(36).substring(2); }

🎯 Sistema de Scopes y Permisos

Definición de Scopes (app/lib/constants.ts)

export const SCOPE = { // Usuarios CREATE_USERS: 'users:create', READ_USERS: 'users:read', UPDATE_USERS: 'users:update', DELETE_USERS: 'users:delete', // Roles CREATE_ROLES: 'roles:create', READ_ROLES: 'roles:read', UPDATE_ROLES: 'roles:update', DELETE_ROLES: 'roles:delete', // Facturas CREATE_INVOICES: 'invoices:create', READ_INVOICES: 'invoices:read', UPDATE_INVOICES: 'invoices:update', DELETE_INVOICES: 'invoices:delete', // Pagos CREATE_PAYMENTS: 'payments:create', READ_PAYMENTS: 'payments:read', UPDATE_PAYMENTS: 'payments:update', DELETE_PAYMENTS: 'payments:delete', // Documentos Fiscales CREATE_TAX_DOCUMENT: 'tax-documents:create', READ_TAX_DOCUMENT: 'tax-documents:read', UPDATE_TAX_DOCUMENT: 'tax-documents:update', DELETE_TAX_DOCUMENT: 'tax-documents:delete', // ... más scopes }; export const SCOPE_FLAGS = { TERMINAL_OP: 'flag:terminal-operator' // Flag especial para operadores de terminal } as const;

Verificación de Scopes del Lado del Servidor

// app/lib/auth/index.ts export async function hasUserRequiredScopes(requiredScopes: string[]) { const session = await getSession(); if (!session) { throw new Error('No tienes permiso para realizar esta acción.'); } return requiredScopes.every((scope) => session.role.scopes.includes(scope)); } // Uso en componentes de servidor import { hasRequiredScopes } from '@/components/auth/server/WithScopes'; export default async function DashboardLayout() { const isTerminalOp = await hasRequiredScopes([SCOPE_FLAGS.TERMINAL_OP]); // Rutas condicionales basadas en permisos const routes = [ { href: isTerminalOp ? '/shift-history' : '/cash-shifts', label: 'Histórico de turno', requiredScopes: [SCOPE.READ_CASH_SHIFT] } ]; // ... resto del layout }

Verificación de Scopes del Lado del Cliente

// app/components/auth/WithScopes.tsx export const useHasRequiredScopes = (requiredScopes: string[]) => { const { session } = useSession(); const { scopes = [] } = session?.role || {}; return requiredScopes.every((scope) => scopes.includes(scope)); }; export const useRequiredScopesCheck = () => { const { session } = useSession(); const { scopes = [] } = session?.role || {}; return (requiredScopes: string[]) => requiredScopes.every((scope) => scopes.includes(scope)); }; // Uso en componentes const MyComponent = () => { const canCreateUsers = useHasRequiredScopes([SCOPE.CREATE_USERS]); const checkScopes = useRequiredScopesCheck(); if (canCreateUsers) { return <CreateUserForm />; } if (checkScopes([SCOPE.READ_USERS])) { return <UserList />; } return <AccessDenied />; };

🧩 Componentes de Autorización

Componente WithScopes (Cliente)

import { useHasRequiredScopes } from './WithScopes'; type WithScopesProps = { requiredScopes: string[]; children: ReactElement; fallback?: ReactElement; }; const WithScopes = (props: WithScopesProps) => { const { requiredScopes, children, fallback } = props; const hasScopes = useHasRequiredScopes(requiredScopes); if (hasScopes) { return children; } return fallback || null; }; // Uso <WithScopes requiredScopes={[SCOPE.CREATE_INVOICES]} fallback={<AccessDenied />} > <CreateInvoiceForm /> </WithScopes>

Componente WithScopes (Servidor)

import { hasRequiredScopes } from './WithScopes'; const WithScopes = async (props: WithScopesProps) => { const { requiredScopes, children, fallback } = props; const hasScopes = await hasRequiredScopes(requiredScopes); if (hasScopes) { return children; } return fallback || null; }; // Uso en layouts o páginas del servidor export default async function InvoicesPage() { return ( <WithScopes requiredScopes={[SCOPE.READ_INVOICES]} fallback={<AccessDenied />} > <InvoicesTable /> </WithScopes> ); }

Componente de Acceso Denegado

const AccessDenied = () => ( <div className="flex flex-col items-center justify-center p-8 text-center"> <div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4"> <Lock className="w-8 h-8 text-red-600" /> </div> <h2 className="text-xl font-semibold text-gray-900 mb-2"> Acceso Denegado </h2> <p className="text-gray-600 mb-4"> No tienes permisos para acceder a esta funcionalidad. </p> <Link href="/" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" > Volver al Inicio </Link> </div> );

🔄 Gestión de Estado de Autenticación

SessionProvider (app/contexts/session-provider.tsx)

"use client"; import { createContext, ReactNode, useContext } from "react"; import { Session } from "@/lib/auth/get-session"; export interface SessionContextProps { session: Session | null; } const SessionContext = createContext<SessionContextProps>({ session: null, }); export const SessionProvider = ({ children, session, }: { children: ReactNode; session: Session | null; }) => { return ( <SessionContext.Provider value={{ session }}> {children} </SessionContext.Provider> ); }; export const useSession = () => useContext(SessionContext);

Uso del Contexto de Sesión

// En cualquier componente const MyComponent = () => { const { session } = useSession(); if (!session) { return <div>Cargando...</div>; } return ( <div> <h1>Bienvenido, {session.user.name}</h1> <p>Rol: {session.role.name}</p> <p>Scopes: {session.role.scopes.join(', ')}</p> </div> ); };

🔐 Autenticación de API Keys

Verificación de API Keys

export async function getApiSession() { const requestHeaders = await headers(); const authorizationHeader = requestHeaders.get('authorization'); if (!authorizationHeader) { return null; } const [type, apiKeyToken] = authorizationHeader.split(' '); if (!apiKeyToken) { return null; } const apiKey = await prisma.apiKey.findUnique({ where: { key: apiKeyToken } }); return { type, apiKey }; } // Uso en API routes export async function POST(request: Request) { const apiSession = await getApiSession(); if (!apiSession?.apiKey) { return Response.json({ error: 'API key inválida' }, { status: 401 }); } // Verificar permisos del API key const hasPermission = apiSession.apiKey.scopes.includes('invoices:create'); if (!hasPermission) { return Response.json({ error: 'Permisos insuficientes' }, { status: 403 }); } // ... lógica de la API }

🚀 Mejores Prácticas de Seguridad

1. Validación de Permisos

// ✅ CORRECTO: Verificar permisos en múltiples niveles export default async function CreateInvoicePage() { // Nivel 1: Verificación en el layout const canCreate = await hasRequiredScopes([SCOPE.CREATE_INVOICES]); if (!canCreate) { redirect('/access-denied'); } return ( <WithScopes requiredScopes={[SCOPE.CREATE_INVOICES]}> <CreateInvoiceForm /> </WithScopes> ); } // ✅ CORRECTO: Verificación en API routes export async function POST(request: Request) { const session = await getSession(); if (!session) { return Response.json({ error: 'No autenticado' }, { status: 401 }); } const canCreate = session.role.scopes.includes(SCOPE.CREATE_INVOICES); if (!canCreate) { return Response.json({ error: 'Sin permisos' }, { status: 403 }); } // ... lógica de la API }

2. Manejo de Cookies Seguras

const config = { maxAge: 30 * 24 * 60 * 60, // 30 días máximo httpOnly: true, // No accesible desde JavaScript secure: process.env.NODE_ENV === 'production', // Solo HTTPS en producción sameSite: 'strict', // Protección CSRF path: '/', // Ruta específica domain: process.env.HOST ?? 'localhost' };

3. Expiración de Sesiones

// Verificar expiración en cada request export async function getSession(): Promise<Session | null> { // ... obtener sesión if (session.expires < new Date()) { // Limpiar sesión expirada automáticamente await prisma.session.delete({ where: { sessionToken: accessToken } }); return null; } // ... resto de la lógica }

4. Logging de Acciones Sensibles

// Registrar acciones importantes para auditoría export async function createUser(userData: CreateUserData) { const session = await getSession(); if (!session) { throw new Error('No autenticado'); } // Verificar permisos const canCreate = session.role.scopes.includes(SCOPE.CREATE_USERS); if (!canCreate) { throw new Error('Sin permisos para crear usuarios'); } // Crear usuario const user = await prisma.user.create({ data: userData }); // Registrar acción en logs await prisma.actionLog.create({ data: { action: 'CREATE_USER', userId: session.user.id, targetId: user.id, details: `Usuario ${user.email} creado por ${session.user.email}`, ipAddress: getClientIP(), userAgent: getUserAgent() } }); return user; }

🔧 Troubleshooting

Problemas Comunes

1. Sesión no persiste

# Verificar configuración de cookies console.log('Cookie config:', config); # Verificar que la cookie se establece const cookies = await cookies(); const authCookie = cookies.get(COOKIES.AUTH_TOKEN); console.log('Auth cookie:', authCookie);

2. Permisos no funcionan

// Debug de scopes del usuario const { session } = useSession(); console.log('User scopes:', session?.role.scopes); console.log('Required scopes:', requiredScopes); // Verificar en el servidor const hasScopes = await hasRequiredScopes(requiredScopes); console.log('Has required scopes:', hasScopes);

3. Middleware no protege rutas

// Verificar configuración del matcher export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }; // Verificar que el middleware se ejecuta console.log('Middleware executing for:', req.nextUrl.pathname); console.log('Auth status:', !!req.auth);

4. Componentes no se renderizan condicionalmente

// Verificar que WithScopes funciona <WithScopes requiredScopes={[SCOPE.READ_USERS]} fallback={<div>Sin permisos</div>} > <UserList /> </WithScopes> // Debug del hook const hasScopes = useHasRequiredScopes([SCOPE.READ_USERS]); console.log('Component has scopes:', hasScopes);

Debug de Autenticación

// Agregar logging para debugging useEffect(() => { console.log('Session state:', session); console.log('User scopes:', session?.role.scopes); console.log('Current permissions:', { canReadUsers: useHasRequiredScopes([SCOPE.READ_USERS]), canCreateUsers: useHasRequiredScopes([SCOPE.CREATE_USERS]), canDeleteUsers: useHasRequiredScopes([SCOPE.DELETE_USERS]) }); }, [session]);

📚 Ejemplos de Implementación

1. Página Protegida con Scopes

// app/app/(dashboard)/users/page.tsx export default async function UsersPage() { const session = await getSession(); if (!session) { redirect('/login'); } const canReadUsers = session.role.scopes.includes(SCOPE.READ_USERS); if (!canReadUsers) { redirect('/access-denied'); } return ( <div className="space-y-4"> <div className="flex items-center justify-between"> <h1 className="text-2xl font-bold">Usuarios</h1> <WithScopes requiredScopes={[SCOPE.CREATE_USERS]}> <CreateUserButton /> </WithScopes> </div> <UsersTable /> <WithScopes requiredScopes={[SCOPE.DELETE_USERS]}> <BulkDeleteUsers /> </WithScopes> </div> ); }

2. API Route Protegida

// app/app/api/users/route.ts export async function POST(request: Request) { const session = await getSession(); if (!session) { return Response.json({ error: 'No autenticado' }, { status: 401 }); } const canCreateUsers = session.role.scopes.includes(SCOPE.CREATE_USERS); if (!canCreateUsers) { return Response.json({ error: 'Sin permisos para crear usuarios' }, { status: 403 }); } try { const userData = await request.json(); const user = await prisma.user.create({ data: userData }); return Response.json(user, { status: 201 }); } catch (error) { return Response.json({ error: 'Error al crear usuario' }, { status: 500 }); } }

3. Componente con Permisos Condicionales

const UserActions = ({ userId }: { userId: string }) => { const { session } = useSession(); if (!session) return null; return ( <div className="flex gap-2"> <WithScopes requiredScopes={[SCOPE.READ_USERS]}> <ViewUserButton userId={userId} /> </WithScopes> <WithScopes requiredScopes={[SCOPE.UPDATE_USERS]}> <EditUserButton userId={userId} /> </WithScopes> <WithScopes requiredScopes={[SCOPE.DELETE_USERS]}> <DeleteUserButton userId={userId} /> </WithScopes> </div> ); };

¿Necesitas más detalles sobre algún aspecto específico? Revisa la documentación de routing y layouts para entender cómo se integra la autenticación con la navegación, o la documentación de APIs para aprender sobre la protección de endpoints.