UNPKG

mbkauthe

Version:

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

600 lines (529 loc) 20.3 kB
import express from "express"; import fetch from 'node-fetch'; import rateLimit from 'express-rate-limit'; import { mbkautheVar, packageJson, appVersion } from "#config.js"; import { renderError, renderPage } from "#response.js"; import { authenticate, sessVal, sessRole } from "../middleware/auth.js"; import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js"; import { dblogin } from "#pool.js"; import { clearSessionCookies, decryptSessionId, cachedCookieOptions } from "#cookies.js"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; import dotenv from "dotenv"; dotenv.config(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const router = express.Router(); // Rate limiter for info/test 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 } }); // Rate limiter for admin operations const AdminOperationLimit = rateLimit({ windowMs: 5 * 60 * 1000, max: 3, message: { success: false, message: "Too many admin operations, please try again later" }, validate: { trustProxy: false, xForwardedForHeader: false } }); // Static file routes router.get('/main.js', (req, res) => { res.setHeader('Cache-Control', 'public, max-age=31536000'); res.sendFile(path.join(__dirname, '..', '..', 'public', 'main.js')); }); router.get('/main.css', (req, res) => { res.setHeader('Cache-Control', 'public, max-age=31536000'); res.sendFile(path.join(__dirname, '..', '..', 'public', 'main.css')); }); router.get("/bg.webp", (req, res) => { const imgPath = path.join(__dirname, "..", "..", "public", "bg.webp"); res.setHeader('Content-Type', 'image/webp'); res.setHeader('Cache-Control', 'public, max-age=31536000'); const stream = fs.createReadStream(imgPath); stream.on('error', (err) => { console.error(`[mbkauthe] Error streaming bg.webp:`, err); res.status(404).send('Image not found'); }); stream.pipe(res); }); // Profile picture route router.get('/user/profilepic', async (req, res) => { // Helper function to serve default icon const serveDefaultIcon = () => { const iconPath = path.join(__dirname, "..", "..", "public", "M.png"); res.setHeader('Content-Type', 'image/png'); // Ensure we don't override the Cache-Control we set earlier, or set a default if not set if (!res.getHeader('Cache-Control')) { res.setHeader('Cache-Control', 'private, no-cache'); } const stream = fs.createReadStream(iconPath); stream.on('error', (err) => { console.error(`[mbkauthe] Error streaming icon.svg:`, err); res.status(404).send('Icon not found'); }); stream.pipe(res); }; try { // Check if user is logged in if (!req.session?.user?.username) { return serveDefaultIcon(); } const username = req.session.user.username; let imageUrl = null; const cookieUser = req.cookies?.profileImageUser; const cookieImageUrl = req.cookies?.profileImageUrl; if (cookieUser === username && typeof cookieImageUrl === 'string' && cookieImageUrl.length > 0) { imageUrl = cookieImageUrl; } // If not in cache, fetch from DB if (!imageUrl) { const result = await dblogin.query({ name: 'get-user-profile-pic', text: 'SELECT "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1', values: [username] }); if (result.rows.length > 0 && result.rows[0].Image && result.rows[0].Image.trim() !== '') { imageUrl = result.rows[0].Image; } else { imageUrl = 'default'; } res.cookie('profileImageUrl', imageUrl, { ...cachedCookieOptions, httpOnly: false }); res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false }); } // Generate ETag based on username and image URL const eTag = `"${Buffer.from(username + ':' + imageUrl).toString('base64')}"`; // Set caching headers res.setHeader('Cache-Control', 'private, no-cache'); res.setHeader('ETag', eTag); // Check for conditional request if (req.headers['if-none-match'] === eTag) { return res.status(304).end(); } if (imageUrl === 'default') { return serveDefaultIcon(); } // Fetch and stream the image try { const imageResponse = await fetch(imageUrl, { headers: { 'User-Agent': 'mbkauthe/1.0' }, timeout: 5000 }); if (!imageResponse.ok) { console.warn(`[mbkauthe] Failed to fetch profile pic from ${imageUrl}, status: ${imageResponse.status}`); res.cookie('profileImageUrl', 'default', { ...cachedCookieOptions, httpOnly: false }); res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false }); return serveDefaultIcon(); } const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'; res.setHeader('Content-Type', contentType); imageResponse.body.pipe(res); } catch (fetchErr) { console.error(`[mbkauthe] Error fetching external profile picture:`, fetchErr); res.cookie('profileImageUrl', 'default', { ...cachedCookieOptions, httpOnly: false }); res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false }); return serveDefaultIcon(); } } catch (err) { console.error(`[mbkauthe] Error fetching profile picture:`, err); return serveDefaultIcon(); } }); if (process.env.env === 'dev') { // Dev-only diagnostic endpoint to verify SuperAdmin role enforcement router.get(['/validate-superadmin'], sessRole("SuperAdmin"), LoginLimit, async (req, res) => { try { const user = req.session?.user || null; return res.json({ success: true, message: 'SuperAdmin access granted', user: user ? { id: user.id, username: user.username, role: user.role, sessionId: user.sessionId } : null }); } catch (err) { console.error(`[mbkauthe] debug validate-superadmin error:`, err); return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR)); } }); } // Test route router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => { const { username, fullname, role, id, sessionId, allowedApps } = req.session.user; const sessionExpiry = req.session.cookie?.expires ? new Date(req.session.cookie.expires).toISOString() : null; return renderPage(req, res, 'pages/test.handlebars', false, { username, fullname: fullname || 'N/A', role, id, sessionIdShort: sessionId.slice(0, 8), profilePicUrl: encodeURIComponent(username), displayName: fullname || username, initial: (fullname && fullname[0]) || username[0], allowedApps: Array.isArray(allowedApps) ? allowedApps.join(', ') : 'N/A', sessionExpiry }); }); router.post('/test', sessVal, LoginLimit, async (req, res) => { if (req.session?.user) { return res.json({ success: true, message: "You are logged in" }); } }); // API: check current session validity (JSON) — minimal response router.get('/api/checkSession', LoginLimit, async (req, res) => { try { if (!req.session?.user) { return res.status(200).json({ sessionValid: false, expiry: null }); } const { id, sessionId } = req.session.user; if (!sessionId) { req.session.destroy(() => { }); clearSessionCookies(res); return res.status(200).json({ sessionValid: false, expiry: null }); } // Single round-trip: fetch app-session expiry and (if needed) connect-pg-simple expiry. const result = await dblogin.query({ name: 'check-session-validity', text: ` SELECT s.expires_at, u."Active", CASE WHEN s.expires_at IS NULL THEN (SELECT expire FROM "session" WHERE sid = $2) ELSE NULL END AS connect_expire FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1 `, values: [sessionId, req.sessionID] }); if (result.rows.length === 0) { req.session.destroy(() => { }); clearSessionCookies(res); return res.status(200).json({ sessionValid: false, expiry: null }); } const row = result.rows[0]; if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) { req.session.destroy(() => { }); clearSessionCookies(res); return res.status(200).json({ sessionValid: false, expiry: null }); } // Determine expiry: prefer application session expiry if present else fallback to connect-pg-simple expiry. const expirySource = row.expires_at || row.connect_expire || null; const expiry = expirySource ? new Date(expirySource).toISOString() : null; return res.status(200).json({ sessionValid: true, expiry }); } catch (err) { console.error(`[mbkauthe] checkSession error:`, err); return res.status(200).json({ sessionValid: false, expiry: null }); } }); // UUID helper used by session endpoints 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); function normalizeSessionIdFromBody(body = {}) { const { sessionId: rawSessionId, isEncrypt, isEncryt } = body; if (!rawSessionId) return { sessionId: null, error: 'MISSING' }; const encryptedFlag = isEncrypt === true || isEncrypt === 'true' || isEncryt === true || isEncryt === 'true'; if (!encryptedFlag) return { sessionId: rawSessionId, error: null }; let toDecrypt = typeof rawSessionId === 'string' ? rawSessionId : String(rawSessionId); try { // Some clients URL-encode cookie values when posting. toDecrypt = decodeURIComponent(toDecrypt); } catch (decodeErr) { // Ignore decode errors and continue with the original value. } const decrypted = decryptSessionId(toDecrypt); if (!decrypted || !isUuid(decrypted)) { return { sessionId: null, error: 'INVALID' }; } return { sessionId: decrypted, error: null }; } async function getSessionValidationRow(sessionId, queryName = 'check-session-validity-by-id') { const result = await dblogin.query({ name: queryName, text: `SELECT s.expires_at, u."Active", u."UserName", u."Role" FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1`, values: [sessionId] }); if (result.rows.length === 0) { return null; } return result.rows[0]; } function isSessionRowValid(row) { return !((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active); } // POST /api/checkSession — accept sessionId in request body { sessionId: "<uuid>" } router.post('/api/checkSession', LoginLimit, async (req, res) => { try { const { sessionId, error } = normalizeSessionIdFromBody(req.body || {}); if (error === 'MISSING') { return res.status(400).json(createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD)); } if (error === 'INVALID') { return res.status(400).json(createErrorResponse(400, ErrorCodes.SESSION_INVALID)); } if (!sessionId) { return res.status(400).json(createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD)); } if (!isUuid(sessionId)) { return res.status(400).json(createErrorResponse(400, ErrorCodes.SESSION_INVALID)); } const row = await getSessionValidationRow(sessionId, 'check-session-validity-by-id'); if (!row || !isSessionRowValid(row)) { return res.status(200).json({ sessionValid: false, expiry: null }); } const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null; return res.status(200).json({ sessionValid: true, expiry }); } catch (err) { console.error(`[mbkauthe] checkSession (body) error:`, err); return res.status(200).json({ sessionValid: false, expiry: null }); } }); // POST /api/verifySession — returns details about sessionId provided in body router.post('/api/verifySession', LoginLimit, async (req, res) => { try { const { sessionId, error } = normalizeSessionIdFromBody(req.body || {}); if (error === 'MISSING') { return res.status(400).json(createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD)); } if (error === 'INVALID') { return res.status(400).json(createErrorResponse(400, ErrorCodes.SESSION_INVALID)); } if (!sessionId) { return res.status(400).json(createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD)); } if (!isUuid(sessionId)) { return res.status(400).json(createErrorResponse(400, ErrorCodes.SESSION_INVALID)); } const row = await getSessionValidationRow(sessionId, 'verify-session'); if (!row || !isSessionRowValid(row)) { return res.status(200).json({ valid: false, expiry: null }); } const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null; return res.status(200).json({ valid: true, expiry, username: row.UserName, role: row.Role }); } catch (err) { console.error(`[mbkauthe] verifySession error:`, err); return res.status(200).json({ valid: false, expiry: null }); } }); // Error codes page router.get("/ErrorCode", (req, res) => { try { // Helper function to get error name from ErrorCodes const getErrorName = (code) => { return Object.keys(ErrorCodes).find(key => ErrorCodes[key] === code) || 'UNKNOWN_ERROR'; }; // Dynamically organize errors by category based on code ranges const errorCategories = [ { name: 'Authentication Errors', icon: '🔑', range: '(600-699)', category: 'authentication', codes: [601, 602, 603, 604, 605] }, { name: 'Two-Factor Authentication Errors', icon: '📱', range: '(700-799)', category: '2fa', codes: [701, 702, 703, 704] }, { name: 'Session Management Errors', icon: '🔄', range: '(800-899)', category: 'session', codes: [801, 802, 803] }, { name: 'Authorization Errors', icon: '🛡️', range: '(900-999)', category: 'authorization', codes: [901, 902] }, { name: 'Input Validation Errors', icon: '✏️', range: '(1000-1099)', category: 'validation', codes: [1001, 1002, 1003, 1004] }, { name: 'Rate Limiting Errors', icon: '⏱️', range: '(1100-1199)', category: 'ratelimit', codes: [1101] }, { name: 'Server Errors', icon: '⚠️', range: '(1200-1299)', category: 'server', codes: [1201, 1202, 1203] }, { name: 'OAuth Errors', icon: '🔗', range: '(1300-1399)', category: 'oauth', codes: [1301, 1302, 1303] } ]; // Build error data from ErrorMessages const categoriesWithErrors = errorCategories.map(category => ({ ...category, errors: category.codes .filter(code => ErrorMessages[code]) // Only include if message exists .map(code => ({ code, name: getErrorName(code), ...ErrorMessages[code] })) })).filter(category => category.errors.length > 0); // Remove empty categories return renderPage(req, res, "pages/errorCodes.handlebars", false, { pageTitle: 'Error Codes', appName: mbkautheVar.APP_NAME, errorCategories: categoriesWithErrors }); } catch (err) { console.error(`[mbkauthe] Error rendering error codes page:`, err); return renderError(res, req, { layout: false, code: 500, error: "Internal Server Error", message: "Could not load error codes page.", pagename: "Error Codes", page: "/mbkauthe/info", }); } }); // Fetch latest version from GitHub\ export async function getLatestVersion() { try { const response = await fetch('https://raw.githubusercontent.com/MIbnEKhalid/mbkauthe/main/package.json'); if (!response.ok) { console.error(`[mbkauthe] GitHub API responded with status ${response.status}`); return null; } const latestPackageJson = await response.json(); return typeof latestPackageJson.version === 'string' ? latestPackageJson.version : null; } catch (error) { console.error(`[mbkauthe] Error fetching latest version from GitHub`, error); return null; } } // Version check with error handling export async function checkVersion() { try { const latestVersion = await getLatestVersion(); const hasValidLatest = typeof latestVersion === 'string' && /^\d+\.\d+\.\d+/.test(latestVersion); if (hasValidLatest && latestVersion !== packageJson.version) { console.warn(`[mbkauthe] Current version (${packageJson.version}) is outdated. Latest version: ${latestVersion}. Consider updating mbkauthe.`); } else if (hasValidLatest) { console.info(`[mbkauthe] Running latest version (${packageJson.version}).`); } else { console.info(`[mbkauthe] Skipped version check warning: latest version unavailable.`); } } catch (error) { console.warn(`[mbkauthe] Failed to check for updates: ${error.message}`); } } const { APP_NAME, DOMAIN, IS_DEPLOYED, loginRedirectURL } = mbkautheVar; const safe_mbkautheVar = { APP_NAME, DOMAIN, IS_DEPLOYED, loginRedirectURL }; // Info page router.get(["/info", "/i"], LoginLimit, async (req, res) => { let latestVersion; try { latestVersion = await getLatestVersion(); } catch (err) { console.error(`[mbkauthe] Error fetching package-lock.json:`, err); } try { renderPage(req, res, "pages/info_mbkauthe.handlebars", false, { mbkautheVar: safe_mbkautheVar, CurrentVersion: packageJson.version, APP_VERSION: appVersion, latestVersion }); } catch (err) { console.error(`[mbkauthe] Error fetching version information:`, err); res.status(500).send(` <html> <head> <title>Error</title> </head> <body> <h1>Error</h1> <p>Failed to fetch version information. Please try again later.</p> </body> </html> `); } }); router.get(["/info.json", "/i.json"], LoginLimit, async (req, res) => { let latestVersion; try { latestVersion = await getLatestVersion(); } catch (err) { console.error(`[mbkauthe] Error fetching package-lock.json:`, err); } try { res.json({ mbkautheVar: safe_mbkautheVar, CurrentVersion: packageJson.version, APP_VERSION: appVersion, latestVersion }); } catch (err) { console.error(`[mbkauthe] Error fetching version information:`, err); res.status(500).json({ success: false, message: "Failed to fetch version information" }); } }); // Terminate all sessions (admin endpoint) router.post("/api/terminateAllSessions", AdminOperationLimit, authenticate(mbkautheVar.Main_SECRET_TOKEN), async (req, res) => { try { // Run both operations in parallel for better performance await Promise.all([ dblogin.query({ name: 'terminate-all-app-sessions', text: 'DELETE FROM "Sessions"' }), dblogin.query({ name: 'terminate-all-db-sessions', text: 'DELETE FROM "session" WHERE expire > NOW()' }) ]); req.session.destroy((err) => { if (err) { console.log(`[mbkauthe] Error destroying session:`, err); return res.status(500).json({ success: false, message: "Failed to terminate sessions" }); } clearSessionCookies(res); console.log(`[mbkauthe] All sessions terminated successfully`); res.status(200).json({ success: true, message: "All sessions terminated successfully", }); }); } catch (err) { console.error(`[mbkauthe] Database query error during session termination:`, err); res.status(500).json({ success: false, message: "Internal Server Error" }); } }); export default router;