Implement Phase 1.2: Database with encryption at rest and core infrastructure
Co-authored-by: southseact-3d <217551146+southseact-3d@users.noreply.github.com>
This commit is contained in:
209
chat/src/database/compat.js
Normal file
209
chat/src/database/compat.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Backward Compatibility Layer
|
||||
* Provides dual-mode operation (JSON files or Database)
|
||||
* Controlled by USE_JSON_DATABASE environment variable
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const { isDatabaseInitialized } = require('./connection');
|
||||
|
||||
const USE_JSON_MODE = process.env.USE_JSON_DATABASE === '1' || process.env.USE_JSON_DATABASE === 'true';
|
||||
|
||||
// In-memory storage for JSON mode
|
||||
let jsonUsers = [];
|
||||
let jsonSessions = new Map();
|
||||
let jsonAffiliates = [];
|
||||
|
||||
/**
|
||||
* Check if running in JSON mode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isJsonMode() {
|
||||
return USE_JSON_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is available
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDatabaseMode() {
|
||||
return !USE_JSON_MODE && isDatabaseInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage mode description
|
||||
* @returns {string}
|
||||
*/
|
||||
function getStorageMode() {
|
||||
if (USE_JSON_MODE) {
|
||||
return 'JSON (backward compatibility)';
|
||||
}
|
||||
if (isDatabaseInitialized()) {
|
||||
return 'Database (SQLite with encryption)';
|
||||
}
|
||||
return 'Not initialized';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load JSON data for backward compatibility
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @param {*} defaultValue - Default value if file doesn't exist
|
||||
* @returns {Promise<*>} Parsed JSON data
|
||||
*/
|
||||
async function loadJsonFile(filePath, defaultValue = []) {
|
||||
try {
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save JSON data for backward compatibility
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @param {*} data - Data to save
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveJsonFile(filePath, data) {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fsSync.existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const tempPath = filePath + '.tmp';
|
||||
await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize JSON mode storage
|
||||
* @param {Object} config - Configuration with file paths
|
||||
*/
|
||||
async function initJsonMode(config) {
|
||||
if (!USE_JSON_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📁 Running in JSON compatibility mode');
|
||||
|
||||
// Load existing JSON data
|
||||
if (config.usersFile) {
|
||||
jsonUsers = await loadJsonFile(config.usersFile, []);
|
||||
console.log(` Loaded ${jsonUsers.length} users from JSON`);
|
||||
}
|
||||
|
||||
if (config.sessionsFile) {
|
||||
const sessions = await loadJsonFile(config.sessionsFile, {});
|
||||
jsonSessions = new Map(Object.entries(sessions));
|
||||
console.log(` Loaded ${jsonSessions.size} sessions from JSON`);
|
||||
}
|
||||
|
||||
if (config.affiliatesFile) {
|
||||
jsonAffiliates = await loadJsonFile(config.affiliatesFile, []);
|
||||
console.log(` Loaded ${jsonAffiliates.length} affiliates from JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JSON users (for compatibility)
|
||||
* @returns {Array}
|
||||
*/
|
||||
function getJsonUsers() {
|
||||
return jsonUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set JSON users (for compatibility)
|
||||
* @param {Array} users
|
||||
*/
|
||||
function setJsonUsers(users) {
|
||||
jsonUsers = users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JSON sessions (for compatibility)
|
||||
* @returns {Map}
|
||||
*/
|
||||
function getJsonSessions() {
|
||||
return jsonSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JSON affiliates (for compatibility)
|
||||
* @returns {Array}
|
||||
*/
|
||||
function getJsonAffiliates() {
|
||||
return jsonAffiliates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set JSON affiliates (for compatibility)
|
||||
* @param {Array} affiliates
|
||||
*/
|
||||
function setJsonAffiliates(affiliates) {
|
||||
jsonAffiliates = affiliates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist JSON users
|
||||
* @param {string} filePath
|
||||
*/
|
||||
async function persistJsonUsers(filePath) {
|
||||
if (!USE_JSON_MODE) {
|
||||
return;
|
||||
}
|
||||
await saveJsonFile(filePath, jsonUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist JSON sessions
|
||||
* @param {string} filePath
|
||||
*/
|
||||
async function persistJsonSessions(filePath) {
|
||||
if (!USE_JSON_MODE) {
|
||||
return;
|
||||
}
|
||||
const sessions = {};
|
||||
const now = Date.now();
|
||||
for (const [token, session] of jsonSessions.entries()) {
|
||||
if (!session.expiresAt || session.expiresAt > now) {
|
||||
sessions[token] = session;
|
||||
}
|
||||
}
|
||||
await saveJsonFile(filePath, sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist JSON affiliates
|
||||
* @param {string} filePath
|
||||
*/
|
||||
async function persistJsonAffiliates(filePath) {
|
||||
if (!USE_JSON_MODE) {
|
||||
return;
|
||||
}
|
||||
await saveJsonFile(filePath, jsonAffiliates);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isJsonMode,
|
||||
isDatabaseMode,
|
||||
getStorageMode,
|
||||
initJsonMode,
|
||||
getJsonUsers,
|
||||
setJsonUsers,
|
||||
getJsonSessions,
|
||||
getJsonAffiliates,
|
||||
setJsonAffiliates,
|
||||
persistJsonUsers,
|
||||
persistJsonSessions,
|
||||
persistJsonAffiliates,
|
||||
loadJsonFile,
|
||||
saveJsonFile
|
||||
};
|
||||
143
chat/src/database/connection.js
Normal file
143
chat/src/database/connection.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Database connection module with SQLite support
|
||||
* Uses better-sqlite3 for synchronous operations
|
||||
* Note: SQLCipher support requires special compilation, using AES-256-GCM encryption at field level instead
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let db = null;
|
||||
let dbPath = null;
|
||||
|
||||
/**
|
||||
* Initialize database connection
|
||||
* @param {string} databasePath - Path to the database file
|
||||
* @param {Object} options - Database options
|
||||
* @returns {Database} Database instance
|
||||
*/
|
||||
function initDatabase(databasePath, options = {}) {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
|
||||
dbPath = databasePath;
|
||||
|
||||
// Ensure database directory exists
|
||||
const dbDir = path.dirname(databasePath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Initialize database with options
|
||||
const dbOptions = {
|
||||
verbose: options.verbose ? console.log : null,
|
||||
fileMustExist: false,
|
||||
timeout: options.timeout || 5000,
|
||||
...options
|
||||
};
|
||||
|
||||
db = new Database(databasePath, dbOptions);
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
if (options.walMode !== false) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
|
||||
// Set reasonable defaults
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('cache_size = -64000'); // 64MB cache
|
||||
db.pragma('temp_store = MEMORY');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
console.log('✅ Database connected:', databasePath);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database instance
|
||||
* @returns {Database|null} Database instance or null if not initialized
|
||||
*/
|
||||
function getDatabase() {
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
function closeDatabase() {
|
||||
if (db) {
|
||||
try {
|
||||
db.close();
|
||||
console.log('✅ Database connection closed');
|
||||
} catch (error) {
|
||||
console.error('Error closing database:', error);
|
||||
} finally {
|
||||
db = null;
|
||||
dbPath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is initialized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDatabaseInitialized() {
|
||||
return db !== null && db.open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database path
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getDatabasePath() {
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the database
|
||||
* @param {string} backupPath - Path to backup file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function backupDatabase(backupPath) {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const backup = db.backup(backupPath);
|
||||
backup.step(-1); // Copy all pages at once
|
||||
backup.finish();
|
||||
console.log('✅ Database backup created:', backupPath);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction
|
||||
* @param {Function} fn - Function to execute in transaction
|
||||
* @returns {*} Result of the function
|
||||
*/
|
||||
function transaction(fn) {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
return db.transaction(fn)();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initDatabase,
|
||||
getDatabase,
|
||||
closeDatabase,
|
||||
isDatabaseInitialized,
|
||||
getDatabasePath,
|
||||
backupDatabase,
|
||||
transaction
|
||||
};
|
||||
181
chat/src/database/schema.sql
Normal file
181
chat/src/database/schema.sql
Normal file
@@ -0,0 +1,181 @@
|
||||
-- Database schema for Shopify AI App Builder
|
||||
-- Version: 1.0
|
||||
-- Date: 2026-02-09
|
||||
|
||||
-- Enable foreign keys
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Users table with encrypted sensitive fields
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
email_encrypted TEXT, -- Encrypted version
|
||||
name TEXT,
|
||||
name_encrypted TEXT, -- Encrypted version
|
||||
password_hash TEXT NOT NULL,
|
||||
providers TEXT DEFAULT '[]', -- JSON array of OAuth providers
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_expires_at INTEGER,
|
||||
reset_token TEXT,
|
||||
reset_expires_at INTEGER,
|
||||
plan TEXT DEFAULT 'hobby',
|
||||
billing_status TEXT DEFAULT 'active',
|
||||
billing_email TEXT,
|
||||
payment_method_last4 TEXT,
|
||||
subscription_renews_at INTEGER,
|
||||
referred_by_affiliate_code TEXT,
|
||||
affiliate_attribution_at INTEGER,
|
||||
affiliate_payouts TEXT DEFAULT '[]', -- JSON array
|
||||
two_factor_secret TEXT, -- Encrypted 2FA secret
|
||||
two_factor_enabled INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER
|
||||
);
|
||||
|
||||
-- Sessions table for active user sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
refresh_token_hash TEXT,
|
||||
device_fingerprint TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_accessed_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Refresh tokens table
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
device_fingerprint TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
used INTEGER DEFAULT 0,
|
||||
revoked INTEGER DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Token blacklist for immediate revocation
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_jti TEXT UNIQUE NOT NULL, -- JWT ID
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Affiliates table
|
||||
CREATE TABLE IF NOT EXISTS affiliates (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT UNIQUE NOT NULL,
|
||||
codes TEXT NOT NULL DEFAULT '[]', -- JSON array of tracking codes
|
||||
earnings TEXT NOT NULL DEFAULT '[]', -- JSON array of earnings
|
||||
commission_rate REAL NOT NULL DEFAULT 0.15,
|
||||
total_referrals INTEGER DEFAULT 0,
|
||||
total_earnings_cents INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Withdrawals table
|
||||
CREATE TABLE IF NOT EXISTS withdrawals (
|
||||
id TEXT PRIMARY KEY,
|
||||
affiliate_id TEXT NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'usd',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
method TEXT,
|
||||
method_details_encrypted TEXT, -- Encrypted payment details
|
||||
processed_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (affiliate_id) REFERENCES affiliates(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Feature requests table
|
||||
CREATE TABLE IF NOT EXISTS feature_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
votes INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Contact messages table
|
||||
CREATE TABLE IF NOT EXISTS contact_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'new',
|
||||
created_at INTEGER NOT NULL,
|
||||
read_at INTEGER
|
||||
);
|
||||
|
||||
-- Audit log table for security events
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
event_type TEXT NOT NULL, -- login, logout, token_refresh, session_revoked, data_access, etc.
|
||||
event_data TEXT, -- JSON data
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER DEFAULT 1,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Dodo payment sessions (topups, subscriptions, PAYG)
|
||||
CREATE TABLE IF NOT EXISTS payment_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'topup', 'subscription', 'payg'
|
||||
amount_cents INTEGER,
|
||||
currency TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
metadata TEXT, -- JSON data
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
completed_at INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session_id ON refresh_tokens(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_token_jti ON token_blacklist(token_jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires_at ON token_blacklist(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_affiliates_user_id ON affiliates(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_withdrawals_affiliate_id ON withdrawals(affiliate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_user_id ON feature_requests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_sessions_user_id ON payment_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_sessions_type ON payment_sessions(type);
|
||||
128
chat/src/repositories/auditRepository.js
Normal file
128
chat/src/repositories/auditRepository.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Audit Logger - Security event logging
|
||||
*/
|
||||
|
||||
const { getDatabase } = require('../database/connection');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
* @param {Object} event - Event data
|
||||
*/
|
||||
function logAuditEvent(event) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
// Silently fail if database not initialized
|
||||
console.log('[AUDIT]', event.eventType, event.userId || 'anonymous');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO audit_log (
|
||||
id, user_id, event_type, event_data, ip_address,
|
||||
user_agent, success, error_message, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
crypto.randomUUID(),
|
||||
event.userId || null,
|
||||
event.eventType,
|
||||
event.eventData ? JSON.stringify(event.eventData) : null,
|
||||
event.ipAddress || null,
|
||||
event.userAgent || null,
|
||||
event.success !== false ? 1 : 0,
|
||||
event.errorMessage || null,
|
||||
Date.now()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log for a user
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} options - Query options (limit, offset, eventType)
|
||||
* @returns {Array} Array of audit events
|
||||
*/
|
||||
function getUserAuditLog(userId, options = {}) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const limit = options.limit || 100;
|
||||
const offset = options.offset || 0;
|
||||
|
||||
let sql = 'SELECT * FROM audit_log WHERE user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
if (options.eventType) {
|
||||
sql += ' AND event_type = ?';
|
||||
params.push(options.eventType);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
const stmt = db.prepare(sql);
|
||||
const rows = stmt.all(...params);
|
||||
|
||||
return rows.map(deserializeAuditEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent audit events
|
||||
* @param {Object} options - Query options (limit, eventType)
|
||||
* @returns {Array} Array of audit events
|
||||
*/
|
||||
function getRecentAuditLog(options = {}) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const limit = options.limit || 100;
|
||||
|
||||
let sql = 'SELECT * FROM audit_log';
|
||||
const params = [];
|
||||
|
||||
if (options.eventType) {
|
||||
sql += ' WHERE event_type = ?';
|
||||
params.push(options.eventType);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const stmt = db.prepare(sql);
|
||||
const rows = stmt.all(...params);
|
||||
|
||||
return rows.map(deserializeAuditEvent);
|
||||
}
|
||||
|
||||
function deserializeAuditEvent(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
eventType: row.event_type,
|
||||
eventData: row.event_data ? JSON.parse(row.event_data) : null,
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
success: Boolean(row.success),
|
||||
errorMessage: row.error_message,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logAuditEvent,
|
||||
getUserAuditLog,
|
||||
getRecentAuditLog
|
||||
};
|
||||
9
chat/src/repositories/index.js
Normal file
9
chat/src/repositories/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Repository exports
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
userRepository: require('./userRepository'),
|
||||
sessionRepository: require('./sessionRepository'),
|
||||
auditRepository: require('./auditRepository')
|
||||
};
|
||||
450
chat/src/repositories/sessionRepository.js
Normal file
450
chat/src/repositories/sessionRepository.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Session Repository - Data access layer for sessions and refresh tokens
|
||||
*/
|
||||
|
||||
const { getDatabase } = require('../database/connection');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
* @param {Object} sessionData - Session data
|
||||
* @returns {Object} Created session
|
||||
*/
|
||||
function createSession(sessionData) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = sessionData.id || crypto.randomUUID();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, user_id, token, refresh_token_hash, device_fingerprint,
|
||||
ip_address, user_agent, expires_at, created_at, last_accessed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
sessionData.userId,
|
||||
sessionData.token,
|
||||
sessionData.refreshTokenHash || null,
|
||||
sessionData.deviceFingerprint || null,
|
||||
sessionData.ipAddress || null,
|
||||
sessionData.userAgent || null,
|
||||
sessionData.expiresAt,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
return getSessionById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by ID
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Object|null} Session object or null
|
||||
*/
|
||||
function getSessionById(sessionId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM sessions WHERE id = ?');
|
||||
const row = stmt.get(sessionId);
|
||||
|
||||
return row ? deserializeSession(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by token
|
||||
* @param {string} token - Session token
|
||||
* @returns {Object|null} Session object or null
|
||||
*/
|
||||
function getSessionByToken(token) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM sessions WHERE token = ?');
|
||||
const row = stmt.get(token);
|
||||
|
||||
return row ? deserializeSession(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions for a user
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Array} Array of sessions
|
||||
*/
|
||||
function getSessionsByUserId(userId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM sessions
|
||||
WHERE user_id = ? AND expires_at > ?
|
||||
ORDER BY last_accessed_at DESC
|
||||
`);
|
||||
const rows = stmt.all(userId, Date.now());
|
||||
|
||||
return rows.map(deserializeSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Object|null} Updated session
|
||||
*/
|
||||
function updateSession(sessionId, updates) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const sets = [];
|
||||
const values = [];
|
||||
|
||||
const fields = ['last_accessed_at', 'expires_at', 'refresh_token_hash'];
|
||||
|
||||
fields.forEach(field => {
|
||||
if (updates.hasOwnProperty(field)) {
|
||||
sets.push(`${field} = ?`);
|
||||
values.push(updates[field]);
|
||||
}
|
||||
});
|
||||
|
||||
if (sets.length === 0) {
|
||||
return getSessionById(sessionId);
|
||||
}
|
||||
|
||||
values.push(sessionId);
|
||||
|
||||
const sql = `UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`;
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(...values);
|
||||
|
||||
return getSessionById(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session (logout)
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {boolean} True if deleted
|
||||
*/
|
||||
function deleteSession(sessionId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('DELETE FROM sessions WHERE id = ?');
|
||||
const result = stmt.run(sessionId);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all sessions for a user (logout all)
|
||||
* @param {string} userId - User ID
|
||||
* @returns {number} Number of sessions deleted
|
||||
*/
|
||||
function deleteAllUserSessions(userId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('DELETE FROM sessions WHERE user_id = ?');
|
||||
const result = stmt.run(userId);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions
|
||||
* @returns {number} Number of sessions deleted
|
||||
*/
|
||||
function cleanupExpiredSessions() {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('DELETE FROM sessions WHERE expires_at <= ?');
|
||||
const result = stmt.run(Date.now());
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a refresh token
|
||||
* @param {Object} tokenData - Refresh token data
|
||||
* @returns {Object} Created refresh token
|
||||
*/
|
||||
function createRefreshToken(tokenData) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const id = tokenData.id || crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO refresh_tokens (
|
||||
id, user_id, session_id, token_hash, device_fingerprint,
|
||||
ip_address, user_agent, expires_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
tokenData.userId,
|
||||
tokenData.sessionId,
|
||||
tokenData.tokenHash,
|
||||
tokenData.deviceFingerprint,
|
||||
tokenData.ipAddress || null,
|
||||
tokenData.userAgent || null,
|
||||
tokenData.expiresAt,
|
||||
now
|
||||
);
|
||||
|
||||
return getRefreshTokenById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token by ID
|
||||
* @param {string} tokenId - Token ID
|
||||
* @returns {Object|null} Refresh token or null
|
||||
*/
|
||||
function getRefreshTokenById(tokenId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM refresh_tokens WHERE id = ?');
|
||||
const row = stmt.get(tokenId);
|
||||
|
||||
return row ? deserializeRefreshToken(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token by hash
|
||||
* @param {string} tokenHash - Token hash
|
||||
* @returns {Object|null} Refresh token or null
|
||||
*/
|
||||
function getRefreshTokenByHash(tokenHash) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM refresh_tokens
|
||||
WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ?
|
||||
`);
|
||||
const row = stmt.get(tokenHash, Date.now());
|
||||
|
||||
return row ? deserializeRefreshToken(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark refresh token as used
|
||||
* @param {string} tokenId - Token ID
|
||||
* @returns {boolean} True if updated
|
||||
*/
|
||||
function markRefreshTokenUsed(tokenId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('UPDATE refresh_tokens SET used = 1, used_at = ? WHERE id = ?');
|
||||
const result = stmt.run(Date.now(), tokenId);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke refresh token
|
||||
* @param {string} tokenId - Token ID
|
||||
* @returns {boolean} True if revoked
|
||||
*/
|
||||
function revokeRefreshToken(tokenId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE id = ?');
|
||||
const result = stmt.run(tokenId);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {number} Number of tokens revoked
|
||||
*/
|
||||
function revokeSessionRefreshTokens(sessionId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE session_id = ?');
|
||||
const result = stmt.run(sessionId);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a user
|
||||
* @param {string} userId - User ID
|
||||
* @returns {number} Number of tokens revoked
|
||||
*/
|
||||
function revokeAllUserRefreshTokens(userId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?');
|
||||
const result = stmt.run(userId);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add token to blacklist
|
||||
* @param {Object} tokenData - Token data (jti, userId, expiresAt, reason)
|
||||
* @returns {Object} Created blacklist entry
|
||||
*/
|
||||
function addToBlacklist(tokenData) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
tokenData.jti,
|
||||
tokenData.userId,
|
||||
tokenData.expiresAt,
|
||||
Date.now(),
|
||||
tokenData.reason || null
|
||||
);
|
||||
|
||||
return { id, ...tokenData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is blacklisted
|
||||
* @param {string} jti - JWT ID
|
||||
* @returns {boolean} True if blacklisted
|
||||
*/
|
||||
function isTokenBlacklisted(jti) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT COUNT(*) as count FROM token_blacklist WHERE token_jti = ?');
|
||||
const result = stmt.get(jti);
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired blacklist entries
|
||||
* @returns {number} Number of entries deleted
|
||||
*/
|
||||
function cleanupExpiredBlacklist() {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?');
|
||||
const result = stmt.run(Date.now());
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
function deserializeSession(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
token: row.token,
|
||||
refreshTokenHash: row.refresh_token_hash,
|
||||
deviceFingerprint: row.device_fingerprint,
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
expiresAt: row.expires_at,
|
||||
createdAt: row.created_at,
|
||||
lastAccessedAt: row.last_accessed_at
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeRefreshToken(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
sessionId: row.session_id,
|
||||
tokenHash: row.token_hash,
|
||||
deviceFingerprint: row.device_fingerprint,
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
used: Boolean(row.used),
|
||||
revoked: Boolean(row.revoked),
|
||||
expiresAt: row.expires_at,
|
||||
createdAt: row.created_at,
|
||||
usedAt: row.used_at
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSession,
|
||||
getSessionById,
|
||||
getSessionByToken,
|
||||
getSessionsByUserId,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
deleteAllUserSessions,
|
||||
cleanupExpiredSessions,
|
||||
createRefreshToken,
|
||||
getRefreshTokenById,
|
||||
getRefreshTokenByHash,
|
||||
markRefreshTokenUsed,
|
||||
revokeRefreshToken,
|
||||
revokeSessionRefreshTokens,
|
||||
revokeAllUserRefreshTokens,
|
||||
addToBlacklist,
|
||||
isTokenBlacklisted,
|
||||
cleanupExpiredBlacklist
|
||||
};
|
||||
313
chat/src/repositories/userRepository.js
Normal file
313
chat/src/repositories/userRepository.js
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* User Repository - Data access layer for users
|
||||
* Handles encryption/decryption of sensitive fields
|
||||
*/
|
||||
|
||||
const { getDatabase } = require('../database/connection');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
* @param {Object} userData - User data
|
||||
* @returns {Object} Created user
|
||||
*/
|
||||
function createUser(userData) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = userData.id || crypto.randomUUID();
|
||||
|
||||
// Encrypt sensitive fields
|
||||
const emailEncrypted = encrypt(userData.email);
|
||||
const nameEncrypted = userData.name ? encrypt(userData.name) : null;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, email, email_encrypted, name, name_encrypted, password_hash,
|
||||
providers, email_verified, verification_token, verification_expires_at,
|
||||
plan, billing_status, billing_email, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
userData.email,
|
||||
emailEncrypted,
|
||||
userData.name || null,
|
||||
nameEncrypted,
|
||||
userData.passwordHash,
|
||||
JSON.stringify(userData.providers || []),
|
||||
userData.emailVerified ? 1 : 0,
|
||||
userData.verificationToken || null,
|
||||
userData.verificationExpiresAt || null,
|
||||
userData.plan || 'hobby',
|
||||
userData.billingStatus || 'active',
|
||||
userData.billingEmail || userData.email,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Object|null} User object or null
|
||||
*/
|
||||
function getUserById(userId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||
const row = stmt.get(userId);
|
||||
|
||||
return row ? deserializeUser(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by email
|
||||
* @param {string} email - User email
|
||||
* @returns {Object|null} User object or null
|
||||
*/
|
||||
function getUserByEmail(email) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM users WHERE email = ?');
|
||||
const row = stmt.get(email);
|
||||
|
||||
return row ? deserializeUser(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by verification token
|
||||
* @param {string} token - Verification token
|
||||
* @returns {Object|null} User object or null
|
||||
*/
|
||||
function getUserByVerificationToken(token) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM users WHERE verification_token = ?');
|
||||
const row = stmt.get(token);
|
||||
|
||||
return row ? deserializeUser(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by reset token
|
||||
* @param {string} token - Reset token
|
||||
* @returns {Object|null} User object or null
|
||||
*/
|
||||
function getUserByResetToken(token) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM users WHERE reset_token = ?');
|
||||
const row = stmt.get(token);
|
||||
|
||||
return row ? deserializeUser(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Object|null} Updated user
|
||||
*/
|
||||
function updateUser(userId, updates) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sets = [];
|
||||
const values = [];
|
||||
|
||||
// Handle regular fields
|
||||
const simpleFields = [
|
||||
'email', 'name', 'password_hash', 'email_verified',
|
||||
'verification_token', 'verification_expires_at',
|
||||
'reset_token', 'reset_expires_at', 'plan', 'billing_status',
|
||||
'billing_email', 'payment_method_last4', 'subscription_renews_at',
|
||||
'referred_by_affiliate_code', 'affiliate_attribution_at',
|
||||
'two_factor_enabled', 'last_login_at'
|
||||
];
|
||||
|
||||
simpleFields.forEach(field => {
|
||||
if (updates.hasOwnProperty(field)) {
|
||||
sets.push(`${field} = ?`);
|
||||
|
||||
// Handle boolean fields
|
||||
if (field.includes('_verified') || field.includes('_enabled')) {
|
||||
values.push(updates[field] ? 1 : 0);
|
||||
} else {
|
||||
values.push(updates[field]);
|
||||
}
|
||||
|
||||
// Handle encrypted fields
|
||||
if (field === 'email' && updates.email) {
|
||||
sets.push('email_encrypted = ?');
|
||||
values.push(encrypt(updates.email));
|
||||
} else if (field === 'name' && updates.name) {
|
||||
sets.push('name_encrypted = ?');
|
||||
values.push(encrypt(updates.name));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle JSON fields
|
||||
if (updates.providers) {
|
||||
sets.push('providers = ?');
|
||||
values.push(JSON.stringify(updates.providers));
|
||||
}
|
||||
|
||||
if (updates.affiliatePayouts) {
|
||||
sets.push('affiliate_payouts = ?');
|
||||
values.push(JSON.stringify(updates.affiliatePayouts));
|
||||
}
|
||||
|
||||
// Handle encrypted 2FA secret
|
||||
if (updates.twoFactorSecret) {
|
||||
sets.push('two_factor_secret = ?');
|
||||
values.push(encrypt(updates.twoFactorSecret));
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// Add updated_at
|
||||
sets.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
|
||||
// Add userId for WHERE clause
|
||||
values.push(userId);
|
||||
|
||||
const sql = `UPDATE users SET ${sets.join(', ')} WHERE id = ?`;
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(...values);
|
||||
|
||||
return getUserById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
* @param {string} userId - User ID
|
||||
* @returns {boolean} True if deleted
|
||||
*/
|
||||
function deleteUser(userId) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('DELETE FROM users WHERE id = ?');
|
||||
const result = stmt.run(userId);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (with pagination)
|
||||
* @param {Object} options - Query options (limit, offset)
|
||||
* @returns {Array} Array of users
|
||||
*/
|
||||
function getAllUsers(options = {}) {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const limit = options.limit || 100;
|
||||
const offset = options.offset || 0;
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?');
|
||||
const rows = stmt.all(limit, offset);
|
||||
|
||||
return rows.map(deserializeUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total users
|
||||
* @returns {number} Total user count
|
||||
*/
|
||||
function countUsers() {
|
||||
const db = getDatabase();
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const stmt = db.prepare('SELECT COUNT(*) as count FROM users');
|
||||
const result = stmt.get();
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize user row from database
|
||||
* Converts database row to user object with decrypted fields
|
||||
* @param {Object} row - Database row
|
||||
* @returns {Object} User object
|
||||
*/
|
||||
function deserializeUser(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
passwordHash: row.password_hash,
|
||||
providers: JSON.parse(row.providers || '[]'),
|
||||
emailVerified: Boolean(row.email_verified),
|
||||
verificationToken: row.verification_token,
|
||||
verificationExpiresAt: row.verification_expires_at,
|
||||
resetToken: row.reset_token,
|
||||
resetExpiresAt: row.reset_expires_at,
|
||||
plan: row.plan,
|
||||
billingStatus: row.billing_status,
|
||||
billingEmail: row.billing_email,
|
||||
paymentMethodLast4: row.payment_method_last4,
|
||||
subscriptionRenewsAt: row.subscription_renews_at,
|
||||
referredByAffiliateCode: row.referred_by_affiliate_code,
|
||||
affiliateAttributionAt: row.affiliate_attribution_at,
|
||||
affiliatePayouts: JSON.parse(row.affiliate_payouts || '[]'),
|
||||
twoFactorSecret: row.two_factor_secret ? decrypt(row.two_factor_secret) : null,
|
||||
twoFactorEnabled: Boolean(row.two_factor_enabled),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastLoginAt: row.last_login_at
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createUser,
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
getUserByVerificationToken,
|
||||
getUserByResetToken,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getAllUsers,
|
||||
countUsers
|
||||
};
|
||||
209
chat/src/utils/encryption.js
Normal file
209
chat/src/utils/encryption.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Field-level encryption utilities using AES-256-GCM
|
||||
* Provides authenticated encryption for sensitive data
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16; // 128 bits for GCM
|
||||
const SALT_LENGTH = 32;
|
||||
const TAG_LENGTH = 16; // 128 bits authentication tag
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
let masterKey = null;
|
||||
|
||||
/**
|
||||
* Initialize encryption with master key
|
||||
* @param {string} key - Master encryption key (hex string)
|
||||
*/
|
||||
function initEncryption(key) {
|
||||
if (!key || typeof key !== 'string') {
|
||||
throw new Error('Master encryption key is required');
|
||||
}
|
||||
|
||||
// Key should be at least 64 hex characters (32 bytes)
|
||||
if (key.length < 64) {
|
||||
throw new Error('Master encryption key must be at least 64 hex characters (32 bytes)');
|
||||
}
|
||||
|
||||
masterKey = Buffer.from(key.slice(0, 64), 'hex');
|
||||
console.log('✅ Encryption initialized with master key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive encryption key from master key and salt using PBKDF2
|
||||
* @param {Buffer} salt - Salt for key derivation
|
||||
* @returns {Buffer} Derived key
|
||||
*/
|
||||
function deriveKey(salt) {
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption not initialized. Call initEncryption() first.');
|
||||
}
|
||||
|
||||
return crypto.pbkdf2Sync(masterKey, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a string value
|
||||
* @param {string} plaintext - Value to encrypt
|
||||
* @returns {string} Encrypted value with format: salt:iv:tag:ciphertext (all hex encoded)
|
||||
*/
|
||||
function encrypt(plaintext) {
|
||||
if (!plaintext) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption not initialized. Call initEncryption() first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate random salt and IV
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Derive key from master key and salt
|
||||
const key = deriveKey(salt);
|
||||
|
||||
// Create cipher
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
// Encrypt
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
// Get authentication tag
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Combine: salt:iv:tag:ciphertext
|
||||
return [
|
||||
salt.toString('hex'),
|
||||
iv.toString('hex'),
|
||||
tag.toString('hex'),
|
||||
encrypted.toString('hex')
|
||||
].join(':');
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted string value
|
||||
* @param {string} ciphertext - Encrypted value with format: salt:iv:tag:ciphertext
|
||||
* @returns {string} Decrypted plaintext
|
||||
*/
|
||||
function decrypt(ciphertext) {
|
||||
if (!ciphertext) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption not initialized. Call initEncryption() first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Split components
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Invalid encrypted data format');
|
||||
}
|
||||
|
||||
const [saltHex, ivHex, tagHex, encryptedHex] = parts;
|
||||
|
||||
// Convert from hex
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
|
||||
// Derive key from master key and salt
|
||||
const key = deriveKey(salt);
|
||||
|
||||
// Create decipher
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
throw new Error('Failed to decrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a value using PBKDF2 (for tokens, not for encryption)
|
||||
* @param {string} value - Value to hash
|
||||
* @param {string} salt - Optional salt (hex string), will generate if not provided
|
||||
* @returns {Object} Object with hash and salt (both hex strings)
|
||||
*/
|
||||
function hashValue(value, salt = null) {
|
||||
if (!value) {
|
||||
throw new Error('Value is required for hashing');
|
||||
}
|
||||
|
||||
const saltBuffer = salt ? Buffer.from(salt, 'hex') : crypto.randomBytes(SALT_LENGTH);
|
||||
const hash = crypto.pbkdf2Sync(value, saltBuffer, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
||||
|
||||
return {
|
||||
hash: hash.toString('hex'),
|
||||
salt: saltBuffer.toString('hex')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a hashed value
|
||||
* @param {string} value - Value to verify
|
||||
* @param {string} hash - Expected hash (hex string)
|
||||
* @param {string} salt - Salt used for hashing (hex string)
|
||||
* @returns {boolean} True if match
|
||||
*/
|
||||
function verifyHash(value, hash, salt) {
|
||||
if (!value || !hash || !salt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = hashValue(value, salt);
|
||||
return crypto.timingSafeEqual(Buffer.from(result.hash, 'hex'), Buffer.from(hash, 'hex'));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
* @param {number} bytes - Number of random bytes (default 32)
|
||||
* @returns {string} Random token (hex string)
|
||||
*/
|
||||
function generateToken(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is initialized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEncryptionInitialized() {
|
||||
return masterKey !== null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initEncryption,
|
||||
encrypt,
|
||||
decrypt,
|
||||
hashValue,
|
||||
verifyHash,
|
||||
generateToken,
|
||||
isEncryptionInitialized
|
||||
};
|
||||
254
chat/src/utils/tokenManager.js
Normal file
254
chat/src/utils/tokenManager.js
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Token Manager for JWT access tokens and refresh tokens
|
||||
* Implements secure session management with token rotation
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { hashValue, verifyHash, generateToken } = require('./encryption');
|
||||
|
||||
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes in seconds
|
||||
const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||
const REFRESH_TOKEN_BYTES = 64; // 128 character hex string
|
||||
|
||||
let jwtSecret = null;
|
||||
|
||||
/**
|
||||
* Initialize token manager with JWT secret
|
||||
* @param {string} secret - JWT signing secret
|
||||
*/
|
||||
function initTokenManager(secret) {
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
throw new Error('JWT secret is required');
|
||||
}
|
||||
|
||||
jwtSecret = secret;
|
||||
console.log('✅ Token manager initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate device fingerprint from request
|
||||
* @param {Object} req - HTTP request object
|
||||
* @returns {string} Device fingerprint (32 character hex)
|
||||
*/
|
||||
function generateDeviceFingerprint(req) {
|
||||
const components = [
|
||||
req.headers['user-agent'] || '',
|
||||
req.headers['accept-language'] || '',
|
||||
req.ip || req.connection?.remoteAddress || '',
|
||||
req.headers['x-forwarded-for'] || ''
|
||||
];
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(components.join('|'))
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT access token
|
||||
* @param {Object} payload - Token payload (userId, email, role, plan)
|
||||
* @param {Object} options - Token options
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function generateAccessToken(payload, options = {}) {
|
||||
if (!jwtSecret) {
|
||||
throw new Error('Token manager not initialized');
|
||||
}
|
||||
|
||||
const jti = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const tokenPayload = {
|
||||
jti,
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role || 'user',
|
||||
plan: payload.plan || 'hobby',
|
||||
iat: now,
|
||||
exp: now + (options.ttl || ACCESS_TOKEN_TTL)
|
||||
};
|
||||
|
||||
return jwt.sign(tokenPayload, jwtSecret, {
|
||||
algorithm: 'HS256'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode JWT access token
|
||||
* @param {string} token - JWT token to verify
|
||||
* @returns {Object|null} Decoded token payload or null if invalid
|
||||
*/
|
||||
function verifyAccessToken(token) {
|
||||
if (!jwtSecret) {
|
||||
throw new Error('Token manager not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, jwtSecret, {
|
||||
algorithms: ['HS256']
|
||||
});
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return { expired: true, error: 'Token expired' };
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return { invalid: true, error: 'Invalid token' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token
|
||||
* @returns {Object} Object with token and tokenHash
|
||||
*/
|
||||
function generateRefreshToken() {
|
||||
const token = generateToken(REFRESH_TOKEN_BYTES);
|
||||
const { hash, salt } = hashValue(token);
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenHash: `${salt}:${hash}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify refresh token against stored hash
|
||||
* @param {string} token - Refresh token to verify
|
||||
* @param {string} storedHash - Stored hash in format "salt:hash"
|
||||
* @returns {boolean} True if token matches hash
|
||||
*/
|
||||
function verifyRefreshToken(token, storedHash) {
|
||||
if (!token || !storedHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const [salt, hash] = storedHash.split(':');
|
||||
if (!salt || !hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return verifyHash(token, hash, salt);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header or cookie
|
||||
* @param {Object} req - HTTP request object
|
||||
* @param {string} cookieName - Name of the cookie containing token
|
||||
* @returns {string|null} Token or null
|
||||
*/
|
||||
function extractToken(req, cookieName = 'access_token') {
|
||||
// Check Authorization header first (Bearer token)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
if (req.headers.cookie) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
return cookies[cookieName] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookie header
|
||||
* @param {string} cookieHeader - Cookie header string
|
||||
* @returns {Object} Parsed cookies
|
||||
*/
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
|
||||
if (!cookieHeader) {
|
||||
return cookies;
|
||||
}
|
||||
|
||||
cookieHeader.split(';').forEach(cookie => {
|
||||
const [name, ...rest] = cookie.split('=');
|
||||
if (name && rest.length > 0) {
|
||||
cookies[name.trim()] = rest.join('=').trim();
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create secure cookie string
|
||||
* @param {string} name - Cookie name
|
||||
* @param {string} value - Cookie value
|
||||
* @param {Object} options - Cookie options
|
||||
* @returns {string} Set-Cookie header value
|
||||
*/
|
||||
function createSecureCookie(name, value, options = {}) {
|
||||
const parts = [`${name}=${value}`];
|
||||
|
||||
if (options.maxAge) {
|
||||
parts.push(`Max-Age=${options.maxAge}`);
|
||||
}
|
||||
|
||||
if (options.path) {
|
||||
parts.push(`Path=${options.path}`);
|
||||
} else {
|
||||
parts.push('Path=/');
|
||||
}
|
||||
|
||||
if (options.httpOnly !== false) {
|
||||
parts.push('HttpOnly');
|
||||
}
|
||||
|
||||
if (options.secure) {
|
||||
parts.push('Secure');
|
||||
}
|
||||
|
||||
if (options.sameSite) {
|
||||
parts.push(`SameSite=${options.sameSite}`);
|
||||
} else {
|
||||
parts.push('SameSite=Strict');
|
||||
}
|
||||
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token TTL values
|
||||
* @returns {Object} Object with accessTokenTTL and refreshTokenTTL
|
||||
*/
|
||||
function getTokenTTL() {
|
||||
return {
|
||||
accessTokenTTL: ACCESS_TOKEN_TTL,
|
||||
refreshTokenTTL: REFRESH_TOKEN_TTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token manager is initialized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTokenManagerInitialized() {
|
||||
return jwtSecret !== null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initTokenManager,
|
||||
generateDeviceFingerprint,
|
||||
generateAccessToken,
|
||||
verifyAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyRefreshToken,
|
||||
extractToken,
|
||||
createSecureCookie,
|
||||
parseCookies,
|
||||
getTokenTTL,
|
||||
isTokenManagerInitialized
|
||||
};
|
||||
Reference in New Issue
Block a user