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(); const activeSessions = new Map(); 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 = { 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( 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';