231 lines
9.0 KiB
JavaScript
231 lines
9.0 KiB
JavaScript
const LoginService = require('../../services/LoginService');
|
|
const MailService = require('../../services/MailService');
|
|
const { checkAndIncrementRateLimit } = require('../../middleware/rateLimiter');
|
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
|
const db = require('../../database/database'); // Use singleton pool
|
|
|
|
class LoginController {
|
|
static async login(req, res) {
|
|
try {
|
|
const { email, password, lang } = req.body;
|
|
logger.info('[LOGIN][REQ] Received lang from frontend', { lang });
|
|
|
|
// Step 1: Check rate limit BEFORE processing credentials
|
|
logger.info('[LOGIN][RATE LIMIT] Checking rate limit for IP', { ip: req.ip });
|
|
const rateLimitExceeded = await checkAndIncrementRateLimit({
|
|
rateKey: `login:${req.ip}`,
|
|
max: 10,
|
|
windowSeconds: 600
|
|
}, null); // null for res, just check, don't send response
|
|
|
|
if (rateLimitExceeded) {
|
|
logger.warn('[RATE LIMIT] Blocked login attempt', { ip: req.ip, time: new Date() });
|
|
return res.status(429).json({
|
|
success: false,
|
|
message: 'Rate limit exceeded. Please try again later.'
|
|
});
|
|
}
|
|
|
|
let result;
|
|
const uow = new UnitOfWork(); // No need to pass pool
|
|
try {
|
|
// Step 3: If not blocked, check credentials
|
|
logger.info('[LOGIN][AUTH] Checking credentials', { email });
|
|
result = await LoginService.login(email, password);
|
|
logger.info('[LOGIN][AUTH] Credentials valid', { email });
|
|
} catch (error) {
|
|
// Step 4: If credentials invalid, increment rate limit
|
|
logger.warn('[LOGIN][AUTH] Invalid credentials', { email, ip: req.ip });
|
|
await checkAndIncrementRateLimit({
|
|
rateKey: `login:${req.ip}`,
|
|
max: 10,
|
|
windowSeconds: 600
|
|
}, res); // increment and send response if needed
|
|
// If rate limit exceeded, response is already sent
|
|
if (res.headersSent) {
|
|
logger.warn('[RATE LIMIT] Rollback after failed login attempt', { ip: req.ip, time: new Date() });
|
|
return;
|
|
}
|
|
if (error.status) {
|
|
logger.warn('[AUTH] Rollback due to invalid credentials', { email, ip: req.ip, time: new Date() });
|
|
return res.status(error.status).json({ success: false, message: error.message });
|
|
}
|
|
logger.error('Login error', { error });
|
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
|
}
|
|
|
|
// Step 5: If credentials valid, allow login (do NOT increment rate limit)
|
|
// Set refresh token as HTTP-only cookie
|
|
res.cookie('refreshToken', result.refreshToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict',
|
|
expires: result.refreshTokenExpires
|
|
});
|
|
|
|
// --- Send login notification email ---
|
|
try {
|
|
await MailService.sendLoginNotificationEmail({
|
|
email: result.user.email,
|
|
ip: req.ip,
|
|
loginTime: new Date(),
|
|
userAgent: req.headers['user-agent'] || '',
|
|
lang: lang // <-- pass lang from request body
|
|
});
|
|
} catch (mailError) {
|
|
logger.error('Error sending login notification email', { error: mailError });
|
|
// Do not block login
|
|
}
|
|
|
|
logger.info('[LOGIN][SUCCESS] Login successful', { email, ip: req.ip });
|
|
return res.status(200).json({
|
|
success: true,
|
|
accessToken: result.accessToken,
|
|
user: result.user
|
|
});
|
|
} catch (error) {
|
|
// Should not reach here, but fallback
|
|
logger.error('Login error (outer catch)', { error });
|
|
if (error && error.stack) {
|
|
logger.error('Error stack', { stack: error.stack });
|
|
}
|
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
|
}
|
|
}
|
|
|
|
static async refresh(req, res) {
|
|
try {
|
|
const refreshToken = req.cookies.refreshToken;
|
|
if (!refreshToken) {
|
|
logger.warn('No refresh token provided');
|
|
return res.status(401).json({ success: false, message: 'No refresh token provided' });
|
|
}
|
|
const result = await LoginService.refresh(refreshToken);
|
|
|
|
// FIX: Set new refresh token as HTTP-only cookie after rotation
|
|
res.cookie('refreshToken', result.refreshToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict',
|
|
expires: result.refreshTokenExpires
|
|
});
|
|
|
|
logger.info('Refresh token rotated', { userId: result.user.id });
|
|
return res.status(200).json({
|
|
success: true,
|
|
accessToken: result.accessToken,
|
|
user: result.user
|
|
});
|
|
} catch (error) {
|
|
if (error.status) {
|
|
logger.warn('Refresh error', { error });
|
|
return res.status(error.status).json({ success: false, message: error.message });
|
|
}
|
|
logger.error('Refresh error', { error });
|
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
|
}
|
|
}
|
|
|
|
static async logout(req, res) {
|
|
try {
|
|
// Very detailed request-level logs
|
|
logger.info('[LOGOUT][REQ] Logout request received', {
|
|
ip: req.ip,
|
|
method: req.method,
|
|
url: req.originalUrl,
|
|
userAgent: req.headers['user-agent'],
|
|
cookieKeys: Object.keys(req.cookies || {})
|
|
});
|
|
|
|
// helper to mask tokens in logs (avoid leaking full token)
|
|
const mask = (s) => {
|
|
if (!s || typeof s !== 'string') return null;
|
|
if (s.length <= 12) return `${s.slice(0, 3)}...${s.slice(-3)}`;
|
|
return `${s.slice(0, 6)}...${s.slice(-6)}`;
|
|
};
|
|
|
|
const refreshToken = req.cookies.refreshToken;
|
|
logger.debug('[LOGOUT] refreshToken presence', { present: !!refreshToken, tokenMasked: mask(refreshToken) });
|
|
|
|
if (!refreshToken) {
|
|
logger.warn('[LOGOUT] No refresh token provided for logout; still attempting to clear cookie on backend');
|
|
|
|
// Try to set expired cookie header explicitly (backend-only for httpOnly)
|
|
try {
|
|
res.cookie('refreshToken', '', {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict',
|
|
path: '/',
|
|
expires: new Date(0)
|
|
});
|
|
const setCookieHeader = res.getHeader && res.getHeader('Set-Cookie');
|
|
logger.info('[LOGOUT] Set-Cookie header after expiring (no token)', { setCookieHeader });
|
|
} catch (cookieErr) {
|
|
logger.error('[LOGOUT] Error while setting expired cookie (no token)', { error: cookieErr });
|
|
}
|
|
|
|
try {
|
|
res.clearCookie('refreshToken', {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict',
|
|
path: '/'
|
|
});
|
|
logger.info('[LOGOUT] res.clearCookie called (no token)');
|
|
} catch (clearErr) {
|
|
logger.error('[LOGOUT] Error while calling res.clearCookie (no token)', { error: clearErr });
|
|
}
|
|
|
|
logger.info('[LOGOUT] Response headersSent (no token)', { headersSent: !!res.headersSent });
|
|
return res.status(400).json({ success: false, message: 'No refresh token provided' });
|
|
}
|
|
|
|
logger.info('[LOGOUT] Calling LoginService.logout', { tokenMasked: mask(refreshToken), ip: req.ip });
|
|
try {
|
|
await LoginService.logout(refreshToken);
|
|
logger.info('[LOGOUT] LoginService.logout completed', { ip: req.ip });
|
|
} catch (serviceErr) {
|
|
logger.error('[LOGOUT] LoginService.logout failed', { error: serviceErr });
|
|
// continue to attempt to clear cookie even if service logout fails
|
|
}
|
|
|
|
// Explicitly expire the refresh token cookie
|
|
try {
|
|
res.cookie('refreshToken', '', {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict',
|
|
path: '/',
|
|
expires: new Date(0)
|
|
});
|
|
const setCookieHeader = res.getHeader && res.getHeader('Set-Cookie');
|
|
logger.info('[LOGOUT] Set-Cookie header after expiring cookie', { setCookieHeader });
|
|
} catch (cookieErr) {
|
|
logger.error('[LOGOUT] Error while setting expired cookie', { error: cookieErr });
|
|
}
|
|
|
|
// Clear the cookie as well
|
|
try {
|
|
res.clearCookie('refreshToken', {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'strict',
|
|
path: '/'
|
|
});
|
|
logger.info('[LOGOUT] res.clearCookie called', { headersSent: !!res.headersSent });
|
|
} catch (clearErr) {
|
|
logger.error('[LOGOUT] Error while calling res.clearCookie', { error: clearErr });
|
|
}
|
|
|
|
logger.info('[LOGOUT][SUCCESS] Logged out successfully (response about to be sent)', { ip: req.ip });
|
|
return res.status(200).json({ success: true, message: 'Logged out successfully' });
|
|
} catch (error) {
|
|
logger.error('[LOGOUT][ERROR] Unexpected logout error', { error, stack: error && error.stack });
|
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = LoginController; |