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:
copilot-swe-agent[bot]
2026-02-09 19:33:00 +00:00
parent 95a2d1b98d
commit 650d849ad2
17 changed files with 2716 additions and 0 deletions

View 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
};

View File

@@ -0,0 +1,9 @@
/**
* Repository exports
*/
module.exports = {
userRepository: require('./userRepository'),
sessionRepository: require('./sessionRepository'),
auditRepository: require('./auditRepository')
};

View 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
};

View 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
};