UNPKG

mbkauthe

Version:

MBKTech's reusable authentication system for Node.js applications.

883 lines (766 loc) 34.5 kB
import express from "express"; import crypto from "crypto"; import csurf from "csurf"; import speakeasy from "speakeasy"; import rateLimit from 'express-rate-limit'; import { dblogin } from "#pool.js"; import { mbkautheVar } from "#config.js"; import { cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies, generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS, hashDeviceToken, upsertAccountListCookie, readAccountListFromCookie, removeAccountFromCookie, clearAccountListCookie, encryptSessionId } from "#cookies.js"; import { packageJson } from "#config.js"; import { hashPassword, hashApiToken } from "#config.js"; import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js"; const router = express.Router(); // Helper function to clear profile picture cache function clearProfilePicCache(req, username) { if (!req || !req.res || !username) return; const cookieUsername = req.cookies?.profileImageUser; if (cookieUsername && cookieUsername !== username) return; req.res.clearCookie('profileImageUrl', cachedClearCookieOptions); req.res.clearCookie('profileImageUser', cachedClearCookieOptions); } // Rate limiters for auth routes const LoginLimit = rateLimit({ windowMs: 1 * 60 * 1000, max: 8, message: { success: false, message: "Too many attempts, please try again later" }, skip: (req) => { return !!req.session.user; }, validate: { trustProxy: false, xForwardedForHeader: false } }); const LogoutLimit = rateLimit({ windowMs: 1 * 60 * 1000, max: 10, message: { success: false, message: "Too many logout attempts, please try again later" }, validate: { trustProxy: false, xForwardedForHeader: false } }); const TwoFALimit = rateLimit({ windowMs: 1 * 60 * 1000, max: 5, message: { success: false, message: "Too many 2FA attempts, please try again later" }, validate: { trustProxy: false, xForwardedForHeader: false } }); // CSRF protection middleware const csrfProtection = csurf({ cookie: true }); // Helper: load a session by DB id and validate basics async function fetchActiveSession(sessionId) { if (!sessionId || typeof sessionId !== 'string') return null; const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps" FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1`; const result = await dblogin.query({ name: 'multi-session-fetch', text: query, values: [sessionId] }); if (result.rows.length === 0) return null; const row = result.rows[0]; if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) return null; if (row.Role !== 'SuperAdmin') { const allowedApps = row.AllowedApps; const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0; if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) { return null; } } return row; } const isUuid = (val) => typeof val === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val); async function invalidateDbSession(sessionId) { if (!isUuid(sessionId)) return; try { await dblogin.query({ name: 'invalidate-app-session', text: 'DELETE FROM "Sessions" WHERE id = $1', values: [sessionId] }); } catch (err) { console.error(`[mbkauthe] Error invalidating session:`, err); } } /** * Check if the device is trusted for the given username */ export async function checkTrustedDevice(req, username) { const deviceToken = req.cookies.device_token; if (!deviceToken || typeof deviceToken !== 'string') { return null; } try { // Hash the provided device token before querying DB (we store token hashes in DB) const deviceTokenHash = hashDeviceToken(deviceToken); // Single round-trip: validate trusted device AND refresh LastUsed. const deviceQuery = ` UPDATE "TrustedDevices" td SET "LastUsed" = NOW() FROM "Users" u WHERE td."DeviceToken" = $1 AND td."UserName" = $2 AND td."ExpiresAt" > NOW() AND u."UserName" = td."UserName" AND u."Active" = TRUE RETURNING td."UserName", td."ExpiresAt", u."id" as id, u."Active", u."Role", u."AllowedApps" `; const deviceResult = await dblogin.query({ name: 'check-trusted-device', text: deviceQuery, values: [deviceTokenHash, username] }); if (deviceResult.rows.length > 0) { const deviceUser = deviceResult.rows[0]; if (!deviceUser.Active) { console.log(`[mbkauthe] Trusted device check: inactive account for username: ${username}`); return null; } if (deviceUser.Role !== "SuperAdmin") { const allowedApps = deviceUser.AllowedApps; if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) { console.warn(`[mbkauthe] Trusted device check: User "${username}" is not authorized to use the application "${mbkautheVar.APP_NAME}"`); return null; } } console.log(`[mbkauthe] Trusted device validated for user: ${username}`); return { id: deviceUser.id, username: username, role: deviceUser.Role, allowedApps: deviceUser.AllowedApps, }; } } catch (deviceErr) { console.error(`[mbkauthe] Error checking trusted device:`, deviceErr); } return null; } /** * Complete the login process by creating session and cookies */ export async function completeLoginProcess(req, res, user, redirectUrl = null, trustDevice = false, method = null) { try { // Ensure both username formats are available for compatibility const username = user.username || user.UserName; if (!username) { throw new Error('Username is required in user object'); } // Fix session fixation: Delete old session BEFORE regenerating to prevent timing window const oldSessionId = req.sessionID; // Delete old session first to prevent session fixation attacks await dblogin.query({ name: 'login-delete-old-session-before-regen', text: 'DELETE FROM "session" WHERE sid = $1', values: [oldSessionId] }); // Now regenerate with new session ID (timing window closed) await new Promise((resolve, reject) => { req.session.regenerate((err) => { if (err) reject(err); else resolve(); }); }); // Enforce max sessions per user (configurable via mbkautheVar.MAX_SESSIONS_PER_USER) // Use a transaction and lock the Sessions table to prevent concurrent logins from exceeding the configured limit. const configuredMax = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10); const MAX_SESSIONS = Number.isInteger(configuredMax) && configuredMax > 0 ? configuredMax : 5; const dbClient = await dblogin.connect(); let dbSessionId; try { await dbClient.query('BEGIN'); await dbClient.query('LOCK TABLE "Sessions" IN SHARE ROW EXCLUSIVE MODE'); // Clean up expired sessions + count active sessions in a single round-trip. const countRes = await dbClient.query({ name: 'cleanup-and-count-user-sessions', text: ` WITH deleted AS ( DELETE FROM "Sessions" WHERE "UserName" = $1 AND expires_at IS NOT NULL AND expires_at <= NOW() ) SELECT COUNT(*)::int AS count FROM "Sessions" WHERE "UserName" = $1 `, values: [username] }); const currentSessions = Number(countRes.rows?.[0]?.count ?? 0); if (currentSessions >= MAX_SESSIONS) { const sessionsToDelete = currentSessions - MAX_SESSIONS + 1; // +1 to make room for new session console.log(`[mbkauthe] User "${username}" has ${currentSessions} active sessions, exceeding max of ${MAX_SESSIONS}. Deleting ${sessionsToDelete} oldest sessions.`); await dbClient.query({ name: 'prune-oldest-user-session', text: `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at ASC LIMIT $2)`, values: [username, sessionsToDelete] }); } const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0)); const insertRes = await dbClient.query({ name: 'insert-app-session', text: `INSERT INTO "Sessions" ("UserName", expires_at, meta) VALUES ($1, $2, $3) RETURNING id`, values: [username, expiresAt, JSON.stringify({ ip: req.ip, ua: req.headers['user-agent'] || null })] }); dbSessionId = insertRes.rows[0].id; await dbClient.query('COMMIT'); } catch (err) { await dbClient.query('ROLLBACK').catch(() => {}); console.error(`[mbkauthe] Error enforcing session limit or inserting app session:`, err); throw err; } finally { dbClient.release(); } // Update last_login and fetch FullName/Image in a single query. let profileRow = null; try { const profUpdateRes = await dblogin.query({ name: 'login-update-last-login-return-profile', text: `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`, values: [user.id] }); profileRow = profUpdateRes.rows?.[0] || null; } catch (profileUpdateErr) { console.error(`[mbkauthe] Error updating last_login/returning profile:`, profileUpdateErr); } req.session.user = { id: user.id, username: username, role: user.role || user.Role, sessionId: dbSessionId, allowedApps: user.allowedApps || user.AllowedApps, }; // Clear profile picture cache to fetch fresh data clearProfilePicCache(req, username); // Store FullName/Image in session and cache cookie values. let loginProfileImage = null; if (profileRow) { if (profileRow.FullName) req.session.user.fullname = profileRow.FullName; if (typeof profileRow.Image === 'string' && profileRow.Image.trim() !== '') loginProfileImage = profileRow.Image; } else { // Fallback: try a read query if UPDATE...RETURNING failed unexpectedly. try { const profileResult = await dblogin.query({ name: 'login-get-fullname-and-image', text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1', values: [username] }); if (profileResult.rows.length > 0) { if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName; if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image; } } catch (profileErr) { console.error(`[mbkauthe] Error fetching FullName/Image for user:`, profileErr); } } if (req.session.preAuthUser) { delete req.session.preAuthUser; } req.session.save(async (err) => { if (err) { console.error(`[mbkauthe] Session save error:`, err); return res.status(500).json({ success: false, message: "Internal Server Error" }); } // Expose DB session id to client const encryptedSessionId = encryptSessionId(dbSessionId); if (encryptedSessionId) { res.cookie("sessionId", encryptedSessionId, cachedCookieOptions); } // Cache display name client-side to avoid extra DB lookups res.cookie("fullName", req.session.user.fullname || username, { ...cachedCookieOptions, httpOnly: false }); const profileImageForCookie = loginProfileImage && typeof loginProfileImage === 'string' ? loginProfileImage : 'default'; res.cookie('profileImageUrl', profileImageForCookie, { ...cachedCookieOptions, httpOnly: false }); res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false }); // Record which method was used to login (client-visible badge) if (method && typeof method === 'string') { try { res.cookie('lastLoginMethod', method, { ...cachedCookieOptions, httpOnly: false }); } catch (err) { console.error(`[mbkauthe] Failed to set lastLoginMethod cookie:`, err); } } // Remember this account on the device for quick switching (server-trusted list) upsertAccountListCookie(req, res, { sessionId: dbSessionId, username, fullName: req.session.user.fullname || username, image: loginProfileImage || null }); // Handle trusted device if requested (token no longer stored in DB as token_hash) if (trustDevice) { try { const deviceToken = generateDeviceToken(); const deviceName = req.headers['user-agent'] ? req.headers['user-agent'].substring(0, 255) : 'Unknown Device'; const userAgent = req.headers['user-agent'] || 'Unknown'; const ipAddress = req.ip || req.connection.remoteAddress || 'Unknown'; const expiresAt = new Date(Date.now() + DEVICE_TRUST_DURATION_MS); // Store only the HASH of the device token in DB; send the raw token to the client (httpOnly cookie) const deviceTokenHash = hashDeviceToken(deviceToken); await dblogin.query({ name: 'insert-trusted-device', text: `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt") VALUES ($1, $2, $3, $4, $5, $6)`, values: [username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt] }); // Send raw token to client as httpOnly cookie only res.cookie("device_token", deviceToken, getDeviceTokenCookieOptions()); console.log(`[mbkauthe] Trusted device token created for user: ${username}`); } catch (deviceErr) { console.error(`[mbkauthe] Error creating trusted device:`, deviceErr); // Continue with login even if device trust fails } } console.log(`[mbkauthe] User "${username}" logged in successfully (last_login updated)`); const responsePayload = { success: true, message: "Login successful", sessionId: dbSessionId, }; if (redirectUrl) { responsePayload.redirectUrl = redirectUrl; } res.status(200).json(responsePayload); }); } catch (err) { console.error(`[mbkauthe] Error during login completion:`, err); res.status(500).json({ success: false, message: "Internal Server Error" }); } } // POST /mbkauthe/api/login router.post("/api/login", LoginLimit, async (req, res) => { console.log(`[mbkauthe] Login request received`); const { username, password, redirect } = req.body; // Input validation if (!username || !password) { logError('Login attempt', ErrorCodes.MISSING_REQUIRED_FIELD, { username: username || 'missing' }); return res.status(400).json( createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD, { message: "Username and password are required" }) ); } // Validate username format and length if (typeof username !== 'string' || username.trim().length === 0 || username.length > 255) { logError('Login attempt', ErrorCodes.INVALID_USERNAME_FORMAT, { username }); return res.status(400).json( createErrorResponse(400, ErrorCodes.INVALID_USERNAME_FORMAT) ); } // Validate password length if (typeof password !== 'string' || password.length < 8 || password.length > 255) { logError('Login attempt', ErrorCodes.INVALID_PASSWORD_LENGTH, { username: username.trim() }); return res.status(400).json( createErrorResponse(400, ErrorCodes.INVALID_PASSWORD_LENGTH) ); } console.log(`[mbkauthe] Login attempt for username: ${username.trim()}`); const trimmedUsername = username.trim(); try { // Combined query: fetch user data and 2FA status in one query const userQuery = ` SELECT u.id, u."UserName", u."PasswordEnc", u."Active", u."Role", u."AllowedApps", tfa."TwoFAStatus" FROM "Users" u LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName" WHERE u."UserName" = $1 `; const userResult = await dblogin.query({ name: 'login-get-user', text: userQuery, values: [trimmedUsername] }); if (userResult.rows.length === 0) { logError('Login attempt', ErrorCodes.USER_NOT_FOUND, { username: trimmedUsername }); return res.status(401).json( createErrorResponse(401, ErrorCodes.INVALID_CREDENTIALS) ); } const user = userResult.rows[0]; // Password verification (hash-only). We never read/compare plaintext passwords. let passwordMatches = false; if (user.PasswordEnc) { const hashedInputPassword = hashPassword(password, user.UserName); const stored = Buffer.from(String(user.PasswordEnc), 'utf8'); const computed = Buffer.from(String(hashedInputPassword), 'utf8'); passwordMatches = stored.length === computed.length && crypto.timingSafeEqual(stored, computed); } if (!passwordMatches) { logError('Login attempt', ErrorCodes.INCORRECT_PASSWORD, { username: trimmedUsername }); return res.status(401).json( createErrorResponse(401, ErrorCodes.INCORRECT_PASSWORD) ); } if (!user.Active) { logError('Login attempt', ErrorCodes.ACCOUNT_INACTIVE, { username: trimmedUsername }); return res.status(403).json( createErrorResponse(403, ErrorCodes.ACCOUNT_INACTIVE) ); } if (user.Role !== "SuperAdmin") { const allowedApps = user.AllowedApps; if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) { logError('Login attempt', ErrorCodes.APP_NOT_AUTHORIZED, { username: user.UserName, app: mbkautheVar.APP_NAME }); return res.status(403).json( createErrorResponse(403, ErrorCodes.APP_NOT_AUTHORIZED, { message: `You are not authorized to access ${mbkautheVar.APP_NAME}`, app: mbkautheVar.APP_NAME }) ); } } // Check for trusted device AFTER password validation const trustedDeviceUser = await checkTrustedDevice(req, trimmedUsername); if (trustedDeviceUser && (mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) { console.log(`[mbkauthe] Trusted device login for user: ${trimmedUsername}, skipping 2FA only`); const userForSession = { id: user.id, username: user.UserName, role: user.Role, allowedApps: user.AllowedApps, }; const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null; return await completeLoginProcess(req, res, userForSession, requestedRedirect, false, 'password'); } if ((mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) { // 2FA is enabled, prompt for token on a separate page const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null; req.session.preAuthUser = { id: user.id, username: user.UserName, role: user.Role, allowedApps: user.AllowedApps, redirectUrl: requestedRedirect }; console.log(`[mbkauthe] 2FA required for user: ${trimmedUsername}`); return res.json({ success: true, twoFactorRequired: true, redirectUrl: requestedRedirect }); } // If 2FA is not enabled, proceed with login const userForSession = { id: user.id, username: user.UserName, role: user.Role, allowedApps: user.AllowedApps, }; const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null; await completeLoginProcess(req, res, userForSession, requestedRedirect, false, 'password'); } catch (err) { console.error(`[mbkauthe] Error during login process:`, err); res.status(500).json({ success: false, message: "Internal Server Error" }); } }); // GET /mbkauthe/2fa router.get("/2fa", csrfProtection, (req, res) => { if (!req.session.preAuthUser) { return res.redirect("/mbkauthe/login"); } // Prefer explicit redirect from query string, else from session preAuthUser redirectUrl, else fallback value let redirectFromQuery = req.query && typeof req.query.redirect === 'string' ? req.query.redirect : null; let redirectToUse = redirectFromQuery || req.session.preAuthUser.redirectUrl || (mbkautheVar.loginRedirectURL || '/dashboard'); // Validate redirectToUse to prevent open redirect attacks if (redirectToUse && !(typeof redirectToUse === 'string' && redirectToUse.startsWith('/') && !redirectToUse.startsWith('//'))) { redirectToUse = mbkautheVar.loginRedirectURL || '/dashboard'; } res.render("pages/2fa.handlebars", { layout: false, customURL: redirectToUse, csrfToken: req.csrfToken(), appName: mbkautheVar.APP_NAME.toLowerCase(), version: packageJson.version, DEVICE_TRUST_DURATION_DAYS: mbkautheVar.DEVICE_TRUST_DURATION_DAYS }); }); // POST /mbkauthe/api/verify-2fa router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => { if (!req.session.preAuthUser) { return res.status(401).json( createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND, { message: "Please log in first" }) ); } const { token, trustDevice } = req.body; const { username, id, role } = req.session.preAuthUser; // Validate 2FA token if (!token || typeof token !== 'string') { return res.status(400).json( createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD, { message: "2FA token is required" }) ); } // Validate token format (should be 6 digits) const sanitizedToken = token.trim(); if (!/^\d{6}$/.test(sanitizedToken)) { return res.status(400).json( createErrorResponse(400, ErrorCodes.INVALID_TOKEN_FORMAT) ); } // Validate trustDevice parameter if provided const shouldTrustDevice = trustDevice === true || trustDevice === 'true'; try { const query = `SELECT tfa."TwoFASecret" FROM "TwoFA" tfa WHERE tfa."UserName" = $1`; const twoFAResult = await dblogin.query({ name: 'verify-2fa-secret', text: query, values: [username] }); if (twoFAResult.rows.length === 0 || !twoFAResult.rows[0].TwoFASecret) { return res.status(500).json( createErrorResponse(500, ErrorCodes.TWO_FA_NOT_CONFIGURED) ); } const sharedSecret = twoFAResult.rows[0].TwoFASecret; const allowedApps = req.session.preAuthUser?.allowedApps; const tokenValidates = speakeasy.totp.verify({ secret: sharedSecret, encoding: "base32", token: sanitizedToken, window: 1, }); if (!tokenValidates) { logError('2FA verification', ErrorCodes.TWO_FA_INVALID_TOKEN, { username }); return res.status(401).json( createErrorResponse(401, ErrorCodes.TWO_FA_INVALID_TOKEN) ); } // 2FA successful, complete login with optional device trust const userForSession = { id, username, role, allowedApps }; // Prefer redirect stored in preAuthUser or in query/body, fallback to configured default let redirectFromSession = req.session.preAuthUser && req.session.preAuthUser.redirectUrl ? req.session.preAuthUser.redirectUrl : null; if (redirectFromSession && (!(typeof redirectFromSession === 'string') || !redirectFromSession.startsWith('/') || redirectFromSession.startsWith('//'))) { redirectFromSession = null; } const redirectUrl = redirectFromSession || mbkautheVar.loginRedirectURL || '/dashboard'; // Capture login method from preAuthUser if present (e.g., OAuth path) const methodToUse = req.session.preAuthUser && req.session.preAuthUser.loginMethod ? req.session.preAuthUser.loginMethod : 'password'; // Clear preAuthUser after successful login if (req.session.preAuthUser) delete req.session.preAuthUser; await completeLoginProcess(req, res, userForSession, redirectUrl, shouldTrustDevice, methodToUse); } catch (err) { console.error(`[mbkauthe] Error during 2FA verification:`, err); res.status(500).json({ success: false, message: "Internal Server Error" }); } }); // POST /mbkauthe/api/logout router.post("/api/logout", LogoutLimit, async (req, res) => { if (req.session.user) { try { const { id, username } = req.session.user; // Clear profile picture cache clearProfilePicCache(req, username); // Remove the application session record for this token (if present) const operations = []; if (req.session && req.session.user && req.session.user.sessionId) { operations.push(dblogin.query({ name: 'logout-delete-app-session', text: 'DELETE FROM "Sessions" WHERE id = $1', values: [req.session.user.sessionId] })); } if (req.sessionID) { operations.push(dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] })); } await Promise.all(operations); // Remove this account from the remembered list for the device if (req.session?.user?.sessionId) { removeAccountFromCookie(req, res, req.session.user.sessionId); } req.session.destroy((err) => { if (err) { console.error(`[mbkauthe] Error destroying session:`, err); return res.status(500).json({ success: false, message: "Logout failed" }); } clearSessionCookies(res); console.log(`[mbkauthe] User "${username}" logged out successfully`); res.status(200).json({ success: true, message: "Logout successful" }); }); } catch (err) { console.error(`[mbkauthe] Database query error during logout:`, err); res.status(500).json({ success: false, message: "Internal Server Error" }); } } else { res.status(400).json({ success: false, message: "Not logged in" }); } }); // List remembered accounts for this device (validates each against DB) router.get("/api/account-sessions", LoginLimit, async (req, res) => { const storedAccounts = readAccountListFromCookie(req); if (!storedAccounts.length) { return res.json({ accounts: [], currentSessionId: req.session?.user?.sessionId || null }); } const validated = []; const currentSessionId = req.session?.user?.sessionId || null; for (const acct of storedAccounts) { if (!isUuid(acct.sessionId)) { removeAccountFromCookie(req, res, acct.sessionId); continue; } try { const row = await fetchActiveSession(acct.sessionId); if (!row) { await invalidateDbSession(acct.sessionId); removeAccountFromCookie(req, res, acct.sessionId); continue; } let fullName = acct.fullName || acct.username; let image = acct.image || null; if (!acct.fullName || !acct.image) { try { const prof = await dblogin.query({ name: 'multi-session-fullname-image', text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1', values: [row.UserName] }); if (prof.rows.length > 0) { if (!acct.fullName && prof.rows[0].FullName) fullName = prof.rows[0].FullName; if (!acct.image && prof.rows[0].Image && prof.rows[0].Image.trim() !== '') image = prof.rows[0].Image; } } catch (profileErr) { console.error(`[mbkauthe] Error fetching fullname/image for account list:`, profileErr); } } validated.push({ sessionId: row.sid, username: row.UserName, fullName, image, isCurrent: currentSessionId && row.sid === currentSessionId }); } catch (err) { console.error(`[mbkauthe] Error validating remembered account:`, err); } } return res.json({ accounts: validated, currentSessionId }); }); // Switch active session to another remembered account router.post("/api/switch-session", LoginLimit, async (req, res) => { const { sessionId, redirect } = req.body || {}; if (!isUuid(sessionId)) { return res.status(400).json(createErrorResponse(400, ErrorCodes.INVALID_TOKEN_FORMAT, { message: 'Invalid session id' })); } const storedAccounts = readAccountListFromCookie(req); const acct = storedAccounts.find(a => a.sessionId === sessionId); if (!acct) { return res.status(403).json(createErrorResponse(403, ErrorCodes.SESSION_NOT_FOUND, { message: 'Account not available on this device' })); } try { const row = await fetchActiveSession(sessionId); if (!row) { await invalidateDbSession(sessionId); removeAccountFromCookie(req, res, sessionId); return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED)); } let fullName = row.UserName; let switchProfileImage = null; try { const prof = await dblogin.query({ name: 'multi-session-switch-fullname-image', text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1', values: [row.UserName] }); if (prof.rows.length > 0) { if (prof.rows[0].FullName) fullName = prof.rows[0].FullName; if (prof.rows[0].Image && prof.rows[0].Image.trim() !== '') switchProfileImage = prof.rows[0].Image; } } catch (profileErr) { console.error(`[mbkauthe] Error fetching fullname/image during switch:`, profileErr); } // Regenerate session to avoid fixation await new Promise((resolve, reject) => { req.session.regenerate((err) => err ? reject(err) : resolve()); }); req.session.user = { id: row.uid, username: row.UserName, role: row.Role, sessionId: row.sid, allowedApps: row.AllowedApps, fullname: fullName }; // Clear profile picture cache to fetch fresh data for new user clearProfilePicCache(req, row.UserName); await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); // Sync sessionId cookie and remember list res.cookie('fullName', fullName, { ...cachedCookieOptions, httpOnly: false }); const switchProfileForCookie = switchProfileImage && typeof switchProfileImage === 'string' ? switchProfileImage : 'default'; res.cookie('profileImageUrl', switchProfileForCookie, { ...cachedCookieOptions, httpOnly: false }); res.cookie('profileImageUser', row.UserName, { ...cachedCookieOptions, httpOnly: false }); const encryptedSid = encryptSessionId(row.sid); if (encryptedSid) { res.cookie('sessionId', encryptedSid, cachedCookieOptions); } upsertAccountListCookie(req, res, { sessionId: row.sid, username: row.UserName, fullName, image: switchProfileImage || null }); const safeRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : mbkautheVar.loginRedirectURL || '/dashboard'; return res.json({ success: true, username: row.UserName, fullName, redirect: safeRedirect }); } catch (err) { console.error(`[mbkauthe] Error during session switch:`, err); return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR)); } }); // Logout all remembered accounts on this device and clear session router.post("/api/logout-all", LoginLimit, async (req, res) => { try { const storedAccounts = readAccountListFromCookie(req); const sessionIds = storedAccounts.map(a => a.sessionId).filter(Boolean); const currentSessionId = req.session?.user?.sessionId; if (currentSessionId) sessionIds.push(currentSessionId); if (sessionIds.length) { await dblogin.query({ name: 'logout-all-app-sessions', text: 'DELETE FROM "Sessions" WHERE id = ANY($1)', values: [sessionIds] }); } if (req.sessionID) { await dblogin.query({ name: 'logout-all-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] }); } clearAccountListCookie(res); clearSessionCookies(res); req.session.destroy(() => { }); return res.json({ success: true, message: 'All accounts logged out' }); } catch (err) { console.error(`[mbkauthe] Error during logout-all:`, err); return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR)); } }); // GET /mbkauthe/login router.get("/login", LoginLimit, csrfProtection, (req, res) => { const lastLogin = req.cookies && typeof req.cookies.lastLoginMethod === 'string' ? req.cookies.lastLoginMethod : null; return res.render("pages/loginmbkauthe.handlebars", { layout: false, githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED, googleLoginEnabled: mbkautheVar.GOOGLE_LOGIN_ENABLED, customURL: mbkautheVar.loginRedirectURL || '/dashboard', userLoggedIn: !!req.session?.user, username: req.session?.user?.username || '', version: packageJson.version, appName: mbkautheVar.APP_NAME.toLowerCase(), csrfToken: req.csrfToken(), // Last-login method flags for immediate server-side badge rendering lastLoginMethod: lastLogin, lastLoginPassword: lastLogin === 'password', lastLoginGithub: lastLogin === 'github', lastLoginGoogle: lastLogin === 'google' }); }); // Dedicated account switch page (lists remembered accounts and allows switching) router.get("/accounts", LoginLimit, csrfProtection, (req, res) => { const redirectFromQuery = typeof req.query.redirect === 'string' ? req.query.redirect : null; const safeRedirect = redirectFromQuery && redirectFromQuery.startsWith('/') && !redirectFromQuery.startsWith('//') ? redirectFromQuery : (mbkautheVar.loginRedirectURL || '/dashboard'); return res.render("pages/accountSwitch.handlebars", { layout: false, customURL: safeRedirect, version: packageJson.version, appName: mbkautheVar.APP_NAME.toLowerCase(), csrfToken: req.csrfToken(), userLoggedIn: !!req.session?.user, username: req.session?.user?.username, fullname: req.session?.user?.fullname, role: req.session?.user?.role, }); }); export default router;