359 lines
12 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { apiClient } from '@/lib/utils/api-client';
import { logInfo, logWarn, logError } from '@/lib/logger/server-logger';
const LOGIN_ENDPOINT = '/auth/login';
const ACCESS_TOKEN_COOKIE = 'access_token';
const REFRESH_TOKEN_COOKIE = 'refresh_token';
const MAX_PASSWORD_ATTEMPTS = 3;
const MAX_TOKEN_ATTEMPTS = 3;
// Contadores de intentos en memoria y mapa de sesiones activas
const userAttempts = new Map<string, { passwordAttempts: number; tokenAttempts: number; lastAttempt: Date }>();
const activeSessions = new Map<string, { sessionId: string; lastActivity: Date }>();
interface LoginError {
code: string;
message: string;
status: number;
}
function validateUsername(username: string): LoginError | null {
if (!username || username.trim() === '') {
return {
code: 'USERNAME_REQUIRED',
message: 'Este campo es obligatorio',
status: 400,
};
}
if (username.length < 6) {
return {
code: 'USERNAME_TOO_SHORT',
message: 'Debe ser mayor o igual a seis caracteres',
status: 400,
};
}
if (username.length > 12) {
return {
code: 'USERNAME_TOO_LONG',
message: 'El sistema solo debe permitir ingresar 12 caracteres',
status: 400,
};
}
return null;
}
function validatePassword(password: string): LoginError | null {
if (!password || password.trim() === '') {
return {
code: 'PASSWORD_REQUIRED',
message: 'Este campo es obligatorio',
status: 400,
};
}
return null;
}
function getUserAttempts(username: string) {
let attempts = userAttempts.get(username);
if (!attempts) {
attempts = { passwordAttempts: 0, tokenAttempts: 0, lastAttempt: new Date() };
userAttempts.set(username, attempts);
}
return attempts;
}
function resetPasswordAttempts(username: string) {
const attempts = getUserAttempts(username);
attempts.passwordAttempts = 0;
attempts.lastAttempt = new Date();
}
function incrementPasswordAttempts(username: string): number {
const attempts = getUserAttempts(username);
attempts.passwordAttempts += 1;
attempts.lastAttempt = new Date();
return attempts.passwordAttempts;
}
function incrementTokenAttempts(username: string): number {
const attempts = getUserAttempts(username);
attempts.tokenAttempts += 1;
attempts.lastAttempt = new Date();
return attempts.tokenAttempts;
}
function resetTokenAttempts(username: string) {
const attempts = getUserAttempts(username);
attempts.tokenAttempts = 0;
attempts.lastAttempt = new Date();
}
function checkActiveSession(username: string, sessionId?: string): boolean {
const session = activeSessions.get(username);
if (session && session.sessionId !== sessionId) {
return true;
}
return false;
}
function setActiveSession(username: string, sessionId: string) {
activeSessions.set(username, { sessionId, lastActivity: new Date() });
}
function getErrorMessage(error: LoginError, userStatus?: string, userType?: string): string {
const { code, message } = error;
if (['USERNAME_REQUIRED', 'PASSWORD_REQUIRED', 'TOKEN_REQUIRED'].includes(code)) {
return message;
}
const staticMessages: Record<string, string> = {
USERNAME_TOO_SHORT: 'Debe ser mayor o igual a seis caracteres',
USERNAME_TOO_LONG: 'El sistema solo debe permitir ingresar 12 caracteres',
USER_NOT_FOUND: 'Usuario o Clave Inválida',
USER_SUSPENDED: 'Tu usuario ha sido suspendido. Ingresa en la opción Autogestión de Usuario',
USER_INACTIVE: 'Usuario inactivo. Por favor comunícate con Soporte Empresas a través del (0212-501.5500)',
ACTIVE_SESSION: 'Usuario activo en otra sesión. Por tu seguridad cierra la sesión y vuelve a ingresar tu usuario',
COMPANY_INACTIVE: 'Empresa inactiva. Por favor comunícate con Soporte',
};
if (staticMessages[code]) {
return staticMessages[code];
}
if (code === 'USER_BLOCKED') {
return userType === 'master'
? 'Usuario bloqueado. Por favor comunícate con Soporte Exterior Empresas a través del (0212-501.5500)'
: 'Tu usuario se encuentra bloqueado. Ingresa en la opción Autogestión de Usuario';
}
if (code === 'INVALID_PASSWORD') {
return getInvalidPasswordMessage(message);
}
if (code === 'INVALID_TOKEN') {
return getInvalidTokenMessage(message);
}
return message || 'Error al iniciar sesión';
}
function getInvalidPasswordMessage(message: string): string {
const username = message.split('|')[1] ?? '';
if (username) {
const attempts = getUserAttempts(username);
if (attempts.passwordAttempts >= MAX_PASSWORD_ATTEMPTS) {
return 'Tu usuario ha sido suspendido. Ingresa a la opción Autogestión de Usuario';
}
}
return 'Usuario o Clave Inválida';
}
function getInvalidTokenMessage(message: string): string {
const username = message.split('|')[1] ?? '';
if (username) {
const { tokenAttempts } = getUserAttempts(username);
switch (tokenAttempts) {
case 1:
return 'Código inválido';
case 2:
return 'Código inválido, al tercer intento tu usuario será bloqueado';
default:
if (tokenAttempts >= MAX_TOKEN_ATTEMPTS) {
return 'Código inválido, tu usuario ha sido bloqueado. Por favor ingresa a la opción Autogestión de Usuario';
}
}
}
return 'Código inválido';
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { userName, password, softToken } = body;
// 1. Validaciones perimetrales
const usernameError = validateUsername(userName);
if (usernameError) {
return NextResponse.json({ error: getErrorMessage(usernameError) }, { status: usernameError.status });
}
const passwordError = validatePassword(password);
if (passwordError) {
return NextResponse.json({ error: getErrorMessage(passwordError) }, { status: passwordError.status });
}
// 2. Control de sesión activa simultánea
const existingSessionId = request.headers.get('x-session-id');
const sessionId = existingSessionId || `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
if (checkActiveSession(userName, sessionId)) {
logWarn('Intento de doble sesión denegado', { userName });
return NextResponse.json(
{ error: getErrorMessage({ code: 'ACTIVE_SESSION', message: '', status: 403 }) },
{ status: 403 }
);
}
try {
const loginPayload: any = { userName, password };
if (softToken) {
loginPayload.softToken = softToken;
}
// 3. Consumir backend Spring Boot mockeado
const response = await apiClient.post<any>(
LOGIN_ENDPOINT,
loginPayload,
{
headers: { 'Content-Type': 'application/json' },
timeoutMs: 10000,
}
);
// 4. Retornar desafío 2FA si corresponde
if (response.requires2FA && !softToken) {
logInfo('Segundo factor (2FA) requerido para el usuario', { userName });
return NextResponse.json({
requires2FA: true,
message: 'Ingresa el código de autenticación',
});
}
// 5. Validaciones de estado de cuenta
const userStatus = response.user?.status || response.accountStatus;
const userType = response.user?.userType;
if (userStatus === 'bloqueado') {
const errorMsg = getErrorMessage({ code: 'USER_BLOCKED', message: '', status: 403 }, userStatus, userType);
return NextResponse.json({ error: errorMsg }, { status: 403 });
}
if (userStatus === 'suspendido') {
return NextResponse.json(
{ error: getErrorMessage({ code: 'USER_SUSPENDED', message: '', status: 403 }) },
{ status: 403 }
);
}
if (userStatus === 'inactivo') {
return NextResponse.json(
{ error: getErrorMessage({ code: 'USER_INACTIVE', message: '', status: 403 }) },
{ status: 403 }
);
}
if (userStatus === 'reiniciado') {
return NextResponse.json(
{ error: getErrorMessage({ code: 'USER_RESET', message: '', status: 403 }) },
{ status: 403 }
);
}
if (userStatus !== 'activo') {
return NextResponse.json({ error: 'Usuario no activo' }, { status: 403 });
}
// 6. Login exitoso - Resetear contadores de intentos
resetPasswordAttempts(userName);
resetTokenAttempts(userName);
setActiveSession(userName, sessionId);
const nextResponse = NextResponse.json({
success: true,
user: response.user,
requires2FA: false,
message: 'Login exitoso',
});
// 7. Establecer cookies JWT HTTP-only
if (response.token) {
const maxAge = response.expiresIn || 3600;
nextResponse.cookies.set(ACCESS_TOKEN_COOKIE, response.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: maxAge,
path: '/',
});
}
if (response.refreshToken) {
nextResponse.cookies.set(REFRESH_TOKEN_COOKIE, response.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
}
logInfo('Login exitoso con 2FA verificado', {
userId: response.user?.id,
userName: response.user?.userName,
userType: response.user?.userType,
});
return nextResponse;
} catch (backendError: any) {
const errorStatus = backendError.response?.status || 500;
const errorMessage = backendError.message || '';
// 8. Control de intentos fallidos
if (errorStatus === 401 || errorMessage.includes('Invalid') || errorMessage.includes('not found')) {
if (softToken) {
const tokenAttempts = incrementTokenAttempts(userName);
logWarn('Código 2FA ingresado inválido', { userName, tokenAttempts });
if (tokenAttempts >= MAX_TOKEN_ATTEMPTS) {
logError('Usuario bloqueado por superar intentos de token 2FA', { userName });
const errorMsg = getErrorMessage({
code: 'INVALID_TOKEN',
message: `|${userName}`,
status: 403,
});
return NextResponse.json({ error: errorMsg }, { status: 403 });
}
const errorMsg = getErrorMessage({
code: 'INVALID_TOKEN',
message: `|${userName}`,
status: 400,
});
return NextResponse.json({ error: errorMsg }, { status: 400 });
} else {
const passwordAttempts = incrementPasswordAttempts(userName);
logWarn('Credenciales de contraseña inválidas', { userName, passwordAttempts });
if (passwordAttempts >= MAX_PASSWORD_ATTEMPTS) {
logError('Usuario suspendido por superar intentos de contraseña', { userName });
const errorMsg = getErrorMessage({
code: 'INVALID_PASSWORD',
message: `|${userName}`,
status: 403,
});
return NextResponse.json({ error: errorMsg }, { status: 403 });
}
return NextResponse.json(
{ error: getErrorMessage({ code: 'INVALID_PASSWORD', message: `|${userName}`, status: 401 }) },
{ status: 401 }
);
}
}
return NextResponse.json(
{ error: 'Error al iniciar sesión. Por favor, intenta de nuevo.' },
{ status: errorStatus }
);
}
} catch (error: any) {
logError('Fallo en el servidor de Login', { error: error.message });
return NextResponse.json({ error: 'Error al procesar la solicitud de login' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';