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 ReactFlujo de Autenticación
- Usuario ingresa credenciales →
signIn() - Verificación de contraseña → bcrypt comparison
- Creación de sesión → Token único + cookie segura
- Middleware protege rutas → Verificación automática
- 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.