Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
187 lines
6.1 KiB
JavaScript
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
|
|
};
|
|
}
|