godcrm/backend/services/AuthService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

187 lines
6.1 KiB
JavaScript

// Authentication Service - v0.002.006
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { dbRun, dbGet } from '../database/connection.js';
import { autoCreateDefaultProjects } from './ProjectService.js';
import { applyStarterPack } from './starter-pack/StarterPackService.js';
import { authLogger } from '../utils/logger.js';
const SALT_ROUNDS = 10;
const JWT_SECRET = process.env.JWT_SECRET || 'dev_jwt_secret_change_in_production';
// Lazy initialization - get master key when needed
function getMasterKey() {
const key = process.env.MASTER_ENCRYPTION_KEY;
if (!key) {
throw new Error('MASTER_ENCRYPTION_KEY must be set in environment');
}
return key;
}
/**
* Generate a unique personal encryption key for a user
* @returns {string} 256-bit encryption key (base64)
*/
export function generatePersonalKey() {
// Generate 32 bytes (256 bits) random key
return crypto.randomBytes(32).toString('base64');
}
/**
* Encrypt personal key with master key
* @param {string} personalKey - Personal encryption key
* @returns {string} Encrypted key (format: iv:ciphertext)
*/
export function encryptPersonalKey(personalKey) {
// Use AES-256-CBC
const iv = crypto.randomBytes(16);
const masterKey = getMasterKey();
const cipher = crypto.createCipheriv(
'aes-256-cbc',
Buffer.from(masterKey.padEnd(32).slice(0, 32)), // Ensure 32 bytes
iv
);
let encrypted = cipher.update(personalKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Return format: iv:ciphertext
return `${iv.toString('hex')}:${encrypted}`;
}
/**
* Decrypt personal key with master key
* @param {string} encryptedKey - Encrypted key (format: iv:ciphertext)
* @returns {string} Decrypted personal key
*/
export function decryptPersonalKey(encryptedKey) {
const [ivHex, ciphertext] = encryptedKey.split(':');
const iv = Buffer.from(ivHex, 'hex');
const masterKey = getMasterKey();
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
Buffer.from(masterKey.padEnd(32).slice(0, 32)),
iv
);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Register a new user
* @param {object} userData - { email, password, name }
* @returns {object} Created user
*/
export async function registerUser(userData) {
const { email, password, name } = userData;
// Check if user exists
const existingUser = await dbGet('SELECT id FROM users WHERE email = ?', [email]);
if (existingUser) {
throw new Error('User with this email already exists');
}
// Hash password
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
// Generate and encrypt personal key
const personalKey = generatePersonalKey();
const encryption_key_encrypted = encryptPersonalKey(personalKey);
// Check if this is the first user (owner)
const userCount = await dbGet('SELECT COUNT(*) as count FROM users', []);
// Postgres COUNT(*) comes back as a string ("0"), so coerce before comparing —
// otherwise the first user on a fresh install never gets the owner role.
const isFirstUser = Number(userCount.count) === 0;
const role = isFirstUser ? 'owner' : 'user';
// Insert user
const result = await dbRun(`
INSERT INTO users (email, password_hash, name, encryption_key_encrypted, role)
VALUES (?, ?, ?, ?, ?)
`, [email, password_hash, name, encryption_key_encrypted, role]);
// Return user data (without sensitive info)
const userId = result.lastInsertRowid;
const user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]);
// Auto-create default projects
await autoCreateDefaultProjects(user.id, user.name);
// ADR-0079: provision Personal Space Starter Pack (6 tables + Welcome widget + Tor first-message).
// Best-effort — registration must not fail because of cosmetics.
await applyStarterPack(user.id, user.name);
return user;
}
/**
* Login user
* @param {string} email - User email
* @param {string} password - Password
* @param {string} ipAddress - IP address (optional)
* @param {string} userAgent - User agent (optional)
* @returns {object} { success, user?, token?, error? }
*/
export async function loginUser(email, password, ipAddress = null, userAgent = null) {
// Find user
const user = await dbGet('SELECT * FROM users WHERE email = ?', [email]);
if (!user) {
// Log failed login attempt
await dbRun(`
INSERT INTO audit_log (user_id, action, entity_type, details, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
`, [null, 'login_failed', 'user', JSON.stringify({ email, reason: 'user_not_found' }), ipAddress, userAgent]);
return { success: false, error: 'Invalid email or password' };
}
// Verify password
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
// Log failed login attempt
await dbRun(`
INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [user.id, 'login_failed', 'user', user.id.toString(), JSON.stringify({ email, reason: 'wrong_password' }), ipAddress, userAgent]);
return { success: false, error: 'Invalid email or password' };
}
// Log successful login
await dbRun(`
INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [user.id, 'login_success', 'user', user.id.toString(), JSON.stringify({ email }), ipAddress, userAgent]);
// Check if user has Personal Space - if not, create default projects/spaces
const personalSpace = await dbGet('SELECT id FROM spaces WHERE owner_id = ? AND type = ?', [user.id, 'personal']);
if (!personalSpace) {
authLogger.info({ userId: user.id }, 'First login - creating default spaces and projects');
await autoCreateDefaultProjects(user.id, user.name);
}
// Generate JWT token
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '30m' }
);
// Remove sensitive data
delete user.password_hash;
delete user.encryption_key_encrypted;
return {
success: true,
user,
token
};
}