mbkauthe
Version:
MBKTech's reusable authentication system for Node.js applications.
247 lines (207 loc) • 8.86 kB
JavaScript
import crypto from "crypto";
import { mbkautheVar } from "#config.js";
// Maximum number of remembered accounts per device
const MAX_REMEMBERED_ACCOUNTS = 5;
const ACCOUNT_LIST_COOKIE = 'mbkauthe_accounts';
// Cookie security: encryption and signing
const COOKIE_ENCRYPTION_KEY = mbkautheVar.SESSION_SECRET_KEY || 'fallback-secret-key-change-this';
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
// Derive encryption key from session secret
const getEncryptionKey = () => {
return crypto.createHash('sha256').update(COOKIE_ENCRYPTION_KEY).digest();
};
// Encrypt and sign cookie payload
const encryptCookiePayload = (data) => {
try {
const iv = crypto.randomBytes(12);
const key = getEncryptionKey();
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Combine iv + authTag + encrypted data
return {
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
data: encrypted
};
} catch (error) {
console.error(`[mbkauthe] Cookie encryption error:`, error);
return null;
}
};
// Decrypt and verify cookie payload
const decryptCookiePayload = (payload) => {
try {
if (!payload || !payload.iv || !payload.authTag || !payload.data) {
return null;
}
const key = getEncryptionKey();
const decipher = crypto.createDecipheriv(
ENCRYPTION_ALGORITHM,
key,
Buffer.from(payload.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(payload.authTag, 'hex'));
let decrypted = decipher.update(payload.data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
} catch (error) {
console.error(`[mbkauthe] Cookie decryption error:`, error);
return null;
}
};
// Generate fingerprint from user-agent only (salted)
const generateFingerprint = (req) => {
const userAgent = req.headers['user-agent'] || '';
// Use SESSION_SECRET_KEY as salt if available, otherwise fallback to encryption key
const salt = mbkautheVar.SESSION_SECRET_KEY || COOKIE_ENCRYPTION_KEY;
// Hash user-agent with salt to prevent rainbow table attacks on UAs
return crypto
.createHash('sha256')
.update(`${userAgent}:${salt}`)
.digest('hex')
.substring(0, 32);
};
// Encrypt sessionId for cookie storage
export const encryptSessionId = (sessionId) => {
if (!sessionId) return null;
const encrypted = encryptCookiePayload({ sessionId });
return encrypted ? JSON.stringify(encrypted) : null;
};
// Decrypt sessionId from cookie
export const decryptSessionId = (encryptedSessionId) => {
if (!encryptedSessionId) return null;
try {
const parsed = JSON.parse(encryptedSessionId);
const decrypted = decryptCookiePayload(parsed);
return decrypted?.sessionId || null;
} catch (error) {
console.error(`[mbkauthe] SessionId decryption error:`, error);
return null;
}
};
// Shared cookie options functions
const getCookieOptions = () => ({
maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
secure: mbkautheVar.IS_DEPLOYED === 'true',
sameSite: 'lax',
path: '/',
httpOnly: true
});
const getClearCookieOptions = () => ({
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
secure: mbkautheVar.IS_DEPLOYED === 'true',
sameSite: 'lax',
path: '/',
httpOnly: true
});
// Cache cookie options for performance
export const cachedCookieOptions = getCookieOptions();
export const cachedClearCookieOptions = getClearCookieOptions();
// Constants for device trust feature
export const DEVICE_TRUST_DURATION_DAYS = mbkautheVar.DEVICE_TRUST_DURATION_DAYS;
export const DEVICE_TRUST_DURATION_MS = DEVICE_TRUST_DURATION_DAYS * 24 * 60 * 60 * 1000;
// Device token utilities
export const generateDeviceToken = () => {
return crypto.randomBytes(32).toString('hex');
};
// Hash a device token for safe storage in the database
export const hashDeviceToken = (token) => {
if (!token || typeof token !== 'string') return null;
return crypto.createHmac('sha256').update(token).digest('hex');
};
export const getDeviceTokenCookieOptions = () => ({
maxAge: DEVICE_TRUST_DURATION_MS,
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
secure: mbkautheVar.IS_DEPLOYED === 'true',
sameSite: 'lax',
path: '/',
httpOnly: true
});
// Helper to clear all session cookies
export const clearSessionCookies = (res) => {
res.clearCookie("mbkauthe.sid", cachedClearCookieOptions);
res.clearCookie("sessionId", cachedClearCookieOptions);
res.clearCookie("fullName", cachedClearCookieOptions);
res.clearCookie("profileImageUrl", cachedClearCookieOptions);
res.clearCookie("profileImageUser", cachedClearCookieOptions);
res.clearCookie("device_token", cachedClearCookieOptions);
};
export { getCookieOptions, getClearCookieOptions };
// ---- Multi-account helpers ----
const parseAccountList = (raw, req) => {
if (!raw) return [];
try {
// First, decrypt the cookie payload
const parsed = JSON.parse(raw);
const decrypted = decryptCookiePayload(parsed);
if (!decrypted || !decrypted.accounts || !decrypted.fingerprint) {
return [];
}
// Verify fingerprint matches current request
const currentFingerprint = generateFingerprint(req);
if (decrypted.fingerprint !== currentFingerprint) {
console.warn(`[mbkauthe] Cookie fingerprint mismatch - possible cookie theft attempt`);
return [];
}
const accounts = decrypted.accounts;
if (!Array.isArray(accounts)) return [];
// Accept only minimal safe fields
return accounts
.filter(item => item && typeof item === 'object')
.map(item => ({
sessionId: typeof item.sessionId === 'string' ? item.sessionId : null,
username: typeof item.username === 'string' ? item.username : null,
fullName: typeof item.fullName === 'string' ? item.fullName : null,
image: typeof item.image === 'string' ? item.image : null
}))
.filter(item => item.sessionId && item.username)
.slice(0, MAX_REMEMBERED_ACCOUNTS);
} catch (error) {
console.error(`[mbkauthe] Error parsing account list:`, error);
return [];
}
};
const writeAccountList = (res, list, req) => {
const sanitized = Array.isArray(list) ? list.slice(0, MAX_REMEMBERED_ACCOUNTS) : [];
// Clean and limit fields to safe values (limit image URL length)
const cleaned = sanitized.map(item => ({
sessionId: item && item.sessionId ? item.sessionId : null,
username: item && item.username ? item.username : null,
fullName: item && item.fullName ? item.fullName : null,
image: (item && typeof item.image === 'string' && item.image.length <= 2048) ? item.image : null
})).filter(i => i && i.sessionId && i.username);
// Create payload with fingerprint
const payload = {
accounts: cleaned,
fingerprint: generateFingerprint(req)
};
// Encrypt the payload
const encrypted = encryptCookiePayload(payload);
if (!encrypted) {
console.error(`[mbkauthe] Failed to encrypt account list cookie`);
return;
}
res.cookie(ACCOUNT_LIST_COOKIE, JSON.stringify(encrypted), cachedCookieOptions);
};
export const readAccountListFromCookie = (req) => {
const raw = req?.cookies ? req.cookies[ACCOUNT_LIST_COOKIE] : null;
return parseAccountList(raw, req);
};
export const upsertAccountListCookie = (req, res, entry) => {
if (!entry || !entry.sessionId || !entry.username) return;
const current = readAccountListFromCookie(req);
const filtered = current.filter(item => item.sessionId !== entry.sessionId && item.username !== entry.username);
const next = [{ sessionId: entry.sessionId, username: entry.username, fullName: entry.fullName || entry.username, image: entry.image || null }, ...filtered];
writeAccountList(res, next, req);
};
export const removeAccountFromCookie = (req, res, sessionId) => {
const current = readAccountListFromCookie(req);
const next = current.filter(item => item.sessionId !== sessionId);
writeAccountList(res, next, req);
};
export const clearAccountListCookie = (res) => {
res.clearCookie(ACCOUNT_LIST_COOKIE, cachedClearCookieOptions);
};