359 lines
12 KiB
TypeScript
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';
|