DEV1.0: Initial commit - Workflow Portal with security fixes
- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration - Frontend: React 18 + Vite + TailwindCSS/DaisyUI - Security fixes applied (2026-07 + 2026-08): - LDAP injection prevention, CSRF protection, HttpOnly cookies - Session hashing (SHA-256), account lockout, rate limiting - Input validation (zod), file upload security, CSP/HSTS headers - V3: express-rate-limit updated (ip-address SSRF fix) - V4: postcss updated (nanoid DoS fix) - V5: Rate-limit on /health endpoint - V6: Session rotation on login (session fixation prevention) - V9: Task values array limit (DoS prevention) - V10: Frontend XSS audit completed - Docker: Multi-stage build, non-root user, PostgreSQL + backup service
This commit is contained in:
202
backend/migrations.js
Normal file
202
backend/migrations.js
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Database initialization and migrations module.
|
||||
*
|
||||
* Punkt 4: Supports both SQLite and PostgreSQL.
|
||||
* Migrations are tracked in a _migrations table to avoid re-running.
|
||||
*
|
||||
* Note: SQLite mode is synchronous, PostgreSQL mode is async.
|
||||
* The initDatabase function handles both cases.
|
||||
*/
|
||||
const db = require('./db');
|
||||
|
||||
const isPostgres = db._type === 'postgres';
|
||||
|
||||
// Helper: convert SQLite SQL to PostgreSQL-compatible SQL
|
||||
function toPg(sql) {
|
||||
return sql
|
||||
.replace(/INTEGER PRIMARY KEY AUTOINCREMENT/g, 'SERIAL PRIMARY KEY')
|
||||
.replace(/TIMESTAMP DEFAULT CURRENT_TIMESTAMP/g, 'TIMESTAMP DEFAULT NOW()')
|
||||
.replace(/`/g, '"');
|
||||
}
|
||||
|
||||
function execSql(sql) {
|
||||
if (isPostgres) {
|
||||
return db.exec(toPg(sql));
|
||||
}
|
||||
return db.exec(sql);
|
||||
}
|
||||
|
||||
async function initDatabase() {
|
||||
// Create migrations tracking table
|
||||
const migrationsTableSql = isPostgres
|
||||
? `CREATE TABLE IF NOT EXISTS _migrations (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT NOW())`
|
||||
: `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
|
||||
await execSql(migrationsTableSql);
|
||||
|
||||
const appliedRows = await db.prepare('SELECT name FROM _migrations').all();
|
||||
const applied = new Set(appliedRows.map(r => r.name));
|
||||
|
||||
async function migrate(name, sql) {
|
||||
if (applied.has(name)) return;
|
||||
console.log(`[Migration] ${name}...`);
|
||||
await execSql(sql);
|
||||
await db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name);
|
||||
console.log(`[Migration] ${name} done.`);
|
||||
}
|
||||
|
||||
// Base schema
|
||||
const baseSchema = isPostgres ? `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
|
||||
status TEXT CHECK(status IN ('aktiv', 'inaktiv')) DEFAULT 'inaktiv',
|
||||
source TEXT CHECK(source IN ('local', 'ad')) DEFAULT 'local',
|
||||
username TEXT,
|
||||
failed_login_attempts INTEGER DEFAULT 0,
|
||||
locked_until TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
||||
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS template_steps (
|
||||
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
|
||||
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
|
||||
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
|
||||
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
|
||||
file_path TEXT, created_at TIMESTAMP DEFAULT NOW(),
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_values (
|
||||
id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
|
||||
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
|
||||
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
|
||||
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
|
||||
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
|
||||
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
` : `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT CHECK(role IN ('admin','user')) DEFAULT 'user',
|
||||
status TEXT CHECK(status IN ('aktiv','inaktiv')) DEFAULT 'inaktiv',
|
||||
source TEXT CHECK(source IN ('local','ad')) DEFAULT 'local',
|
||||
username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT,
|
||||
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS template_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
|
||||
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
|
||||
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
|
||||
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
|
||||
file_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_values (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
|
||||
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
|
||||
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
|
||||
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
|
||||
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
|
||||
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
`;
|
||||
|
||||
await execSql(baseSchema);
|
||||
|
||||
// Indexes
|
||||
const indexes = [
|
||||
'idx_sessions_token', 'idx_sessions_user_id', 'idx_sessions_expires',
|
||||
'idx_tasks_user_id', 'idx_tasks_template_id', 'idx_tasks_status',
|
||||
'idx_task_values_task_id', 'idx_task_values_step_id',
|
||||
'idx_audit_log_created', 'idx_audit_log_user', 'idx_users_email', 'idx_users_source',
|
||||
];
|
||||
for (const idx of indexes) {
|
||||
const table = idx.replace('idx_', '').replace('_id', '').replace('_at', '_created').replace('_token', '_token');
|
||||
// Build CREATE INDEX statement
|
||||
let col;
|
||||
if (idx === 'idx_sessions_token') col = 'sessions(token)';
|
||||
else if (idx === 'idx_sessions_user_id') col = 'sessions(user_id)';
|
||||
else if (idx === 'idx_sessions_expires') col = 'sessions(expires_at)';
|
||||
else if (idx === 'idx_tasks_user_id') col = 'tasks(user_id)';
|
||||
else if (idx === 'idx_tasks_template_id') col = 'tasks(template_id)';
|
||||
else if (idx === 'idx_tasks_status') col = 'tasks(status)';
|
||||
else if (idx === 'idx_task_values_task_id') col = 'task_values(task_id)';
|
||||
else if (idx === 'idx_task_values_step_id') col = 'task_values(step_id)';
|
||||
else if (idx === 'idx_audit_log_created') col = 'audit_log(created_at)';
|
||||
else if (idx === 'idx_audit_log_user') col = 'audit_log(user_id)';
|
||||
else if (idx === 'idx_users_email') col = 'users(email)';
|
||||
else if (idx === 'idx_users_source') col = 'users(source)';
|
||||
await migrate(idx, `CREATE INDEX IF NOT EXISTS ${idx} ON ${col}`);
|
||||
}
|
||||
|
||||
// Seed admin user
|
||||
const bcrypt = require('bcryptjs');
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@workflow.local';
|
||||
const ADMIN_INIT_PASSWORD = process.env.ADMIN_INIT_PASSWORD || '';
|
||||
const existingAdmin = await db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
|
||||
if (!existingAdmin) {
|
||||
if (!ADMIN_INIT_PASSWORD) {
|
||||
console.warn('WARNUNG: Kein ADMIN_INIT_PASSWORD gesetzt - kein Admin-Account erstellt.');
|
||||
} else {
|
||||
const hash = bcrypt.hashSync(ADMIN_INIT_PASSWORD, 12);
|
||||
await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'admin\', \'aktiv\', \'local\')').run(ADMIN_EMAIL, hash, 'Superadmin');
|
||||
console.log('Superadmin erstellt: ' + ADMIN_EMAIL);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic session cleanup
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const cleanupSql = isPostgres ? "DELETE FROM sessions WHERE expires_at < NOW()" : "DELETE FROM sessions WHERE expires_at < datetime('now')";
|
||||
await db.prepare(cleanupSql).run();
|
||||
} catch (err) {
|
||||
console.error('Session cleanup error:', err.message);
|
||||
}
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
console.log('Datenbanktabellen initialisiert.');
|
||||
}
|
||||
|
||||
module.exports = { initDatabase };
|
||||
Reference in New Issue
Block a user