CentralBackend/controller/login/LoginController.js
2026-01-18 21:57:22 +01:00

263 lines
10 KiB
JavaScript

const LoginService = require('../../services/login/LoginService');
const MailService = require('../../services/email/MailService');
const { checkAndIncrementRateLimit } = require('../../middleware/rateLimiter');
const { logger } = require('../../middleware/logger');
const UnitOfWork = require('../../database/UnitOfWork');
const db = require('../../database/database');
// ---- Refresh token cookie helpers (keep aligned with BFF/frontend expectations) ----
function normalizeSameSite(v) {
const raw = String(v || '').toLowerCase();
if (raw === 'none') return 'none';
if (raw === 'strict') return 'strict';
return 'lax'; // default
}
function getRefreshCookieDomain() {
// In dev/localhost, setting Domain breaks cookies; only set Domain in prod (or if explicitly provided).
const envDomain = process.env.COOKIE_DOMAIN || process.env.REFRESH_COOKIE_DOMAIN;
if (envDomain) return envDomain;
if (process.env.NODE_ENV === 'production') return '.profit-planet.partners';
return undefined;
}
function shouldUseSecureCookie(req) {
// Works behind proxies if x-forwarded-proto is present; still prefer explicit env override.
const override = process.env.COOKIE_SECURE;
if (override != null) return String(override).toLowerCase() === 'true';
const xfProto = String(req.headers['x-forwarded-proto'] || '').toLowerCase();
const isHttps = req.secure || xfProto === 'https';
return process.env.NODE_ENV === 'production' || isHttps;
}
function buildRefreshCookieOptions(req, refreshTokenExpires) {
const sameSite = normalizeSameSite(process.env.COOKIE_SAMESITE || 'lax');
const secure = shouldUseSecureCookie(req);
// SameSite=None requires Secure; force it if configured that way.
const finalSecure = sameSite === 'none' ? true : secure;
const expires = refreshTokenExpires ? new Date(refreshTokenExpires) : undefined;
const maxAge = expires ? Math.max(0, expires.getTime() - Date.now()) : undefined;
return {
domain: getRefreshCookieDomain(), // e.g. .profit-planet.partners (shared across subdomains)
path: '/', // important for consistent clearing
httpOnly: true,
secure: finalSecure,
sameSite,
...(expires ? { expires } : {}),
...(typeof maxAge === 'number' ? { maxAge } : {})
};
}
function buildRefreshCookieClearOptions(req) {
// Must match Domain+Path+Secure+SameSite used when setting.
const opts = buildRefreshCookieOptions(req, null);
return {
...opts,
expires: new Date(0),
maxAge: 0
};
}
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 (shared across .profit-planet.partners)
res.cookie('refreshToken', result.refreshToken, buildRefreshCookieOptions(req, 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);
// Always set the rotated refresh token cookie with consistent attributes.
res.cookie('refreshToken', result.refreshToken, buildRefreshCookieOptions(req, 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
}
// Clear cookie using SAME Domain+Path+SameSite+Secure
try {
res.cookie('refreshToken', '', buildRefreshCookieClearOptions(req));
} catch (cookieErr) {
logger.error('[LOGOUT] Error while setting expired cookie', { error: cookieErr });
}
try {
res.clearCookie('refreshToken', buildRefreshCookieClearOptions(req));
} 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;