Forgot Passaord
This commit is contained in:
@@ -44,6 +44,10 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
avatar_color TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login DATETIME,
|
||||
security_phrase_content TEXT,
|
||||
security_phrase_iv TEXT,
|
||||
security_phrase_tag TEXT,
|
||||
security_phrase_downloaded_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -93,6 +97,83 @@ CREATE TABLE IF NOT EXISTS user_group_permissions (
|
||||
);
|
||||
`;
|
||||
|
||||
const createPermissionSectionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS permission_sections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const createPermissionGroupsTable = `
|
||||
CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(section_id, key),
|
||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
const createPermissionItemsTable = `
|
||||
CREATE TABLE IF NOT EXISTS permission_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section_id INTEGER NOT NULL,
|
||||
group_id INTEGER,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
default_level TEXT NOT NULL,
|
||||
available_levels TEXT NOT NULL,
|
||||
is_required INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
`;
|
||||
|
||||
const createPermissionDependenciesTable = `
|
||||
CREATE TABLE IF NOT EXISTS permission_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
permission_id INTEGER NOT NULL,
|
||||
depends_on_permission_id INTEGER NOT NULL,
|
||||
required_level TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||
UNIQUE(permission_id, depends_on_permission_id)
|
||||
);
|
||||
`;
|
||||
|
||||
const createPermissionDependenciesIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_permission_dependencies_required
|
||||
ON permission_dependencies (permission_id, depends_on_permission_id);
|
||||
`;
|
||||
|
||||
const createGroupPermissionValuesTable = `
|
||||
CREATE TABLE IF NOT EXISTS group_permission_values (
|
||||
group_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
effective_level TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_id, permission_id),
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
const createServersTable = `
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
@@ -364,6 +445,549 @@ console.log('✅ permissions table ready');
|
||||
db.exec(createUserGroupPermissionsTable);
|
||||
console.log('✅ user_group_permissions table ready');
|
||||
|
||||
db.exec(createPermissionSectionsTable);
|
||||
console.log('✅ permission_sections table ready');
|
||||
|
||||
db.exec(createPermissionGroupsTable);
|
||||
console.log('✅ permission_groups table ready');
|
||||
|
||||
db.exec(createPermissionItemsTable);
|
||||
console.log('✅ permission_items table ready');
|
||||
|
||||
db.exec(createPermissionDependenciesTable);
|
||||
db.exec(createPermissionDependenciesIndex);
|
||||
console.log('✅ permission_dependencies table ready');
|
||||
|
||||
db.exec(createGroupPermissionValuesTable);
|
||||
console.log('✅ group_permission_values table ready');
|
||||
|
||||
const permissionsSeeded = () => {
|
||||
try {
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM permission_sections').get();
|
||||
return row?.count > 0;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const seedPermissions = () => {
|
||||
if (permissionsSeeded()) {
|
||||
console.log('ℹ️ permission structure already seeded');
|
||||
return;
|
||||
}
|
||||
|
||||
const insertSection = db.prepare(`
|
||||
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag)
|
||||
VALUES (@key, @title, @sort_order, @has_navigation_flag)
|
||||
`);
|
||||
|
||||
const insertGroup = db.prepare(`
|
||||
INSERT INTO permission_groups (section_id, key, title, sort_order)
|
||||
VALUES (@section_id, @key, @title, @sort_order)
|
||||
`);
|
||||
|
||||
const insertItem = db.prepare(`
|
||||
INSERT INTO permission_items (
|
||||
section_id,
|
||||
group_id,
|
||||
key,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required
|
||||
) VALUES (
|
||||
@section_id,
|
||||
@group_id,
|
||||
@key,
|
||||
@label,
|
||||
@sort_order,
|
||||
@default_level,
|
||||
@available_levels,
|
||||
@is_required
|
||||
)
|
||||
`);
|
||||
|
||||
const insertDependency = db.prepare(`
|
||||
INSERT INTO permission_dependencies (permission_id, depends_on_permission_id, required_level)
|
||||
VALUES (@permission_id, @depends_on_permission_id, @required_level)
|
||||
`);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'stacks',
|
||||
title: 'Stacks',
|
||||
sortOrder: 0,
|
||||
hasNavigation: 0,
|
||||
groups: [],
|
||||
items: [
|
||||
{
|
||||
key: 'stacks-redeploy-single',
|
||||
label: 'Redeploy einzeln',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-selection',
|
||||
label: 'Redeploy Auswahl',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-all',
|
||||
label: 'Redeploy Alle',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
title: 'Logs',
|
||||
sortOrder: 1,
|
||||
hasNavigation: 1,
|
||||
groups: [],
|
||||
items: [
|
||||
{
|
||||
key: 'logs-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'logs-export',
|
||||
label: 'Logs Exportieren',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'logs-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs-delete',
|
||||
label: 'Logs löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'logs-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
title: 'Benutzer',
|
||||
sortOrder: 2,
|
||||
hasNavigation: 1,
|
||||
groups: [],
|
||||
items: [
|
||||
{
|
||||
key: 'users-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'users-edit',
|
||||
label: 'Benutzer bearbeiten',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'users-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-delete',
|
||||
label: 'Benutzer löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'users-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-security-phrase',
|
||||
label: 'Sicherheitsschlüssel',
|
||||
sortOrder: 3,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'users-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups',
|
||||
title: 'Benutzergruppen',
|
||||
sortOrder: 3,
|
||||
hasNavigation: 1,
|
||||
groups: [],
|
||||
items: [
|
||||
{
|
||||
key: 'user-groups-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'user-groups-edit',
|
||||
label: 'Benutzergruppen bearbeiten',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'user-groups-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups-delete',
|
||||
label: 'Benutzergruppen löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'user-groups-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance',
|
||||
title: 'Wartung',
|
||||
sortOrder: 4,
|
||||
hasNavigation: 1,
|
||||
groups: [
|
||||
{
|
||||
key: 'maintenance-server-group',
|
||||
title: 'Server & Endpoints',
|
||||
sortOrder: 0,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-server-manage',
|
||||
label: 'Server/Endpoint-Sektion',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'maintenance-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-server-delete',
|
||||
label: 'Server/Endpoint löschen',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'maintenance-access',
|
||||
requiredLevel: '!=none'
|
||||
},
|
||||
{
|
||||
dependsOnKey: 'maintenance-server-manage',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-portainer-group',
|
||||
title: 'Portainer',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-portainer',
|
||||
label: 'Portainer-Sektion',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'maintenance-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-ssh-update',
|
||||
label: 'SSH/Update-Skript',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'maintenance-access',
|
||||
requiredLevel: '!=none'
|
||||
},
|
||||
{
|
||||
dependsOnKey: 'maintenance-portainer',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-update',
|
||||
label: 'Update durchführen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'maintenance-access',
|
||||
requiredLevel: '!=none'
|
||||
},
|
||||
{
|
||||
dependsOnKey: 'maintenance-portainer',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-duplicates-group',
|
||||
title: 'Doppelte Stacks',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-duplicates',
|
||||
label: 'Doppelte Stacks',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
isRequired: 0,
|
||||
dependencies: [
|
||||
{
|
||||
dependsOnKey: 'maintenance-access',
|
||||
requiredLevel: '!=none'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const itemKeyToId = new Map();
|
||||
|
||||
const insertPermissions = db.transaction(() => {
|
||||
sections.forEach((section) => {
|
||||
const sectionResult = insertSection.run({
|
||||
key: section.key,
|
||||
title: section.title,
|
||||
sort_order: section.sortOrder,
|
||||
has_navigation_flag: section.hasNavigation ? 1 : 0
|
||||
});
|
||||
|
||||
const sectionId = sectionResult.lastInsertRowid;
|
||||
|
||||
const sectionItems = section.items ?? [];
|
||||
sectionItems.forEach((item, index) => {
|
||||
const itemResult = insertItem.run({
|
||||
section_id: sectionId,
|
||||
group_id: null,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
sort_order: item.sortOrder ?? index,
|
||||
default_level: item.defaultLevel,
|
||||
available_levels: JSON.stringify(item.levels),
|
||||
is_required: item.isRequired ? 1 : 0
|
||||
});
|
||||
itemKeyToId.set(item.key, itemResult.lastInsertRowid);
|
||||
});
|
||||
|
||||
const groups = section.groups ?? [];
|
||||
groups.forEach((group, groupIdx) => {
|
||||
const groupResult = insertGroup.run({
|
||||
section_id: sectionId,
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
sort_order: group.sortOrder ?? groupIdx
|
||||
});
|
||||
const groupId = groupResult.lastInsertRowid;
|
||||
const groupItems = group.items ?? [];
|
||||
groupItems.forEach((item, itemIdx) => {
|
||||
const itemResult = insertItem.run({
|
||||
section_id: sectionId,
|
||||
group_id: groupId,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
sort_order: item.sortOrder ?? itemIdx,
|
||||
default_level: item.defaultLevel,
|
||||
available_levels: JSON.stringify(item.levels),
|
||||
is_required: item.isRequired ? 1 : 0
|
||||
});
|
||||
itemKeyToId.set(item.key, itemResult.lastInsertRowid);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sections.forEach((section) => {
|
||||
const collectDependencies = (items = []) => {
|
||||
items.forEach((item) => {
|
||||
if (!item.dependencies || !item.dependencies.length) {
|
||||
return;
|
||||
}
|
||||
const permissionId = itemKeyToId.get(item.key);
|
||||
item.dependencies.forEach((dependency) => {
|
||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||
if (!dependsId) {
|
||||
console.warn(`⚠️ dependency target ${dependency.dependsOnKey} not found for ${item.key}`);
|
||||
return;
|
||||
}
|
||||
insertDependency.run({
|
||||
permission_id: permissionId,
|
||||
depends_on_permission_id: dependsId,
|
||||
required_level: dependency.requiredLevel ?? null
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
collectDependencies(section.items);
|
||||
(section.groups ?? []).forEach((group) => collectDependencies(group.items));
|
||||
});
|
||||
});
|
||||
|
||||
insertPermissions();
|
||||
console.log('✅ permission structure seeded');
|
||||
};
|
||||
|
||||
seedPermissions();
|
||||
|
||||
const ensureUserSecurityPhraseColumns = () => {
|
||||
const columns = db.prepare('PRAGMA table_info(users)').all();
|
||||
const columnNames = new Set(columns.map((column) => column.name));
|
||||
|
||||
const addColumnIfMissing = (name, sql) => {
|
||||
if (!columnNames.has(name)) {
|
||||
db.exec(sql);
|
||||
}
|
||||
};
|
||||
|
||||
addColumnIfMissing('security_phrase_content', 'ALTER TABLE users ADD COLUMN security_phrase_content TEXT');
|
||||
addColumnIfMissing('security_phrase_iv', 'ALTER TABLE users ADD COLUMN security_phrase_iv TEXT');
|
||||
addColumnIfMissing('security_phrase_tag', 'ALTER TABLE users ADD COLUMN security_phrase_tag TEXT');
|
||||
addColumnIfMissing('security_phrase_downloaded_at', 'ALTER TABLE users ADD COLUMN security_phrase_downloaded_at DATETIME');
|
||||
};
|
||||
|
||||
ensureUserSecurityPhraseColumns();
|
||||
|
||||
const ensurePermissionDefaultLevels = () => {
|
||||
const desiredDefaults = [
|
||||
{ key: 'stacks-redeploy-single', defaultLevel: 'none' },
|
||||
{ key: 'stacks-redeploy-selection', defaultLevel: 'none' },
|
||||
{ key: 'stacks-redeploy-all', defaultLevel: 'none' },
|
||||
{ key: 'logs-access', defaultLevel: 'none' },
|
||||
{ key: 'logs-export', defaultLevel: 'none' },
|
||||
{ key: 'logs-delete', defaultLevel: 'none' },
|
||||
{ key: 'users-access', defaultLevel: 'none' },
|
||||
{ key: 'users-edit', defaultLevel: 'read' },
|
||||
{ key: 'users-delete', defaultLevel: 'none' },
|
||||
{ key: 'users-security-phrase', defaultLevel: 'none' },
|
||||
{ key: 'user-groups-access', defaultLevel: 'none' },
|
||||
{ key: 'user-groups-edit', defaultLevel: 'read' },
|
||||
{ key: 'user-groups-delete', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-access', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-server-manage', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-server-delete', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-portainer', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-ssh-update', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-update', defaultLevel: 'none' },
|
||||
{ key: 'maintenance-duplicates', defaultLevel: 'none' }
|
||||
];
|
||||
|
||||
const updateDefaultLevel = db.prepare(`
|
||||
UPDATE permission_items
|
||||
SET default_level = @default_level
|
||||
WHERE key = @key AND default_level != @default_level
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
desiredDefaults.forEach(({ key, defaultLevel }) => {
|
||||
updateDefaultLevel.run({ key, default_level: defaultLevel });
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
ensurePermissionDefaultLevels();
|
||||
|
||||
db.exec(
|
||||
"DELETE FROM group_permission_values WHERE group_id IN (SELECT id FROM user_groups WHERE lower(name) = 'superuser')"
|
||||
);
|
||||
|
||||
db.exec(createServersTable);
|
||||
console.log('✅ servers table ready');
|
||||
|
||||
|
||||
+510
-46
@@ -18,9 +18,9 @@ import {
|
||||
registerSuperuser,
|
||||
removeSuperuser,
|
||||
findUserByIdentifier,
|
||||
findUserById,
|
||||
markUserLogin,
|
||||
verifyPassword
|
||||
verifyPassword,
|
||||
SUPERUSER_GROUP_NAME
|
||||
} from './auth/superuser.js';
|
||||
import {
|
||||
logEvent,
|
||||
@@ -45,14 +45,46 @@ import {
|
||||
removeServer,
|
||||
setServerApiKey
|
||||
} from './setup/index.js';
|
||||
import { listUsers, getUserById, updateUserGroups, createUser, updateUserDetails, deleteUser, updateUserActiveStatus } from './users/index.js';
|
||||
import {
|
||||
listUsers,
|
||||
getUserById,
|
||||
updateUserGroups,
|
||||
createUser,
|
||||
updateUserDetails,
|
||||
deleteUser,
|
||||
updateUserActiveStatus,
|
||||
getUserSecurityPhrase,
|
||||
renewUserSecurityPhrase,
|
||||
markSecurityPhraseDownloaded,
|
||||
ensureSecurityPhrasesForExistingUsers,
|
||||
verifySecurityPhraseForUsername,
|
||||
setUserPassword
|
||||
} from './users/index.js';
|
||||
import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js';
|
||||
import {
|
||||
getPermissionStructure,
|
||||
getPermissionValuesByGroup,
|
||||
saveGroupPermissionValues,
|
||||
clearGroupPermissionValues,
|
||||
getSuperuserPermissionMap,
|
||||
getEffectivePermissionsForGroups,
|
||||
hasRequiredPermission
|
||||
} from './permissions/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
ensureSuperuserFromEnv();
|
||||
ensureDefaultsFromEnv();
|
||||
|
||||
try {
|
||||
const initializedCount = ensureSecurityPhrasesForExistingUsers();
|
||||
if (initializedCount > 0) {
|
||||
console.log(`ℹ️ Sicherheitsschlüssel für ${initializedCount} bestehende Benutzer initialisiert`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('⚠️ Initialisierung der Sicherheitsschlüssel für bestehende Benutzer fehlgeschlagen:', error);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -131,6 +163,8 @@ const AUTH_COOKIE_OPTIONS = {
|
||||
};
|
||||
|
||||
const activeSessions = new Map();
|
||||
const passwordResetTickets = new Map();
|
||||
const PASSWORD_RESET_TTL_MS = 1000 * 60 * 15;
|
||||
|
||||
const PUBLIC_API_ROUTES = [
|
||||
{ method: 'GET', matcher: /^\/api\/auth\/superuser\/status$/ },
|
||||
@@ -138,6 +172,8 @@ const PUBLIC_API_ROUTES = [
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/login$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/logout$/ },
|
||||
{ method: 'GET', matcher: /^\/api\/auth\/session$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/recover\/verify$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/recover\/reset$/ },
|
||||
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ }
|
||||
];
|
||||
@@ -149,6 +185,40 @@ const sanitizeUser = (user) => ({
|
||||
avatarColor: user.avatar_color || null
|
||||
});
|
||||
|
||||
const buildUserSessionPayload = (userId) => {
|
||||
const record = getUserById(userId);
|
||||
if (!record || !record.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = Array.isArray(record.groups) ? record.groups : [];
|
||||
const groupIds = groups
|
||||
.map((group) => Number(group.id))
|
||||
.filter((groupId) => Number.isFinite(groupId) && groupId > 0);
|
||||
|
||||
const isSuperuser = groups.some(
|
||||
(group) => typeof group?.name === 'string' && group.name.toLowerCase() === SUPERUSER_GROUP_NAME
|
||||
);
|
||||
|
||||
const permissions = isSuperuser
|
||||
? getSuperuserPermissionMap()
|
||||
: getEffectivePermissionsForGroups(groupIds);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: record.id,
|
||||
username: record.username,
|
||||
email: record.email || null,
|
||||
avatarColor: record.avatarColor || null,
|
||||
groups,
|
||||
isSuperuser,
|
||||
securityPhraseDownloadedAt: record.securityPhraseDownloadedAt || null,
|
||||
requiresSecurityPhraseDownload: !record.securityPhraseDownloadedAt
|
||||
},
|
||||
permissions
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupExpiredSessions = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, session] of activeSessions.entries()) {
|
||||
@@ -167,6 +237,49 @@ const removeSessionsForUser = (userId) => {
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupExpiredPasswordResetTickets = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, ticket] of passwordResetTickets.entries()) {
|
||||
if (!ticket || ticket.expiresAt <= now) {
|
||||
passwordResetTickets.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removePasswordResetTicketsForUser = (userId) => {
|
||||
if (!userId) return;
|
||||
for (const [token, ticket] of passwordResetTickets.entries()) {
|
||||
if (ticket?.userId === userId) {
|
||||
passwordResetTickets.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createPasswordResetTicketForUser = (userId) => {
|
||||
cleanupExpiredPasswordResetTickets();
|
||||
removePasswordResetTicketsForUser(userId);
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = Date.now() + PASSWORD_RESET_TTL_MS;
|
||||
passwordResetTickets.set(token, { userId, expiresAt });
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
const getPasswordResetTicket = (token) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
cleanupExpiredPasswordResetTickets();
|
||||
const record = passwordResetTickets.get(token);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
if (record.expiresAt <= Date.now()) {
|
||||
passwordResetTickets.delete(token);
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
const createSessionForUser = (user) => {
|
||||
cleanupExpiredSessions();
|
||||
removeSessionsForUser(user.id);
|
||||
@@ -892,6 +1005,52 @@ const maintenanceGuard = (req, res, next) => {
|
||||
});
|
||||
};
|
||||
|
||||
const requirePermission = (permissionKey, requiredLevel = 'full') => (req, res, next) => {
|
||||
if (!permissionKey) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.user?.isSuperuser) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const permissions = req.userPermissions || {};
|
||||
if (hasRequiredPermission(permissions, permissionKey, requiredLevel)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'INSUFFICIENT_PERMISSIONS',
|
||||
permission: permissionKey,
|
||||
requiredLevel
|
||||
});
|
||||
};
|
||||
|
||||
const requireAnyPermission = (candidates = []) => (req, res, next) => {
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.user?.isSuperuser) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const permissions = req.userPermissions || {};
|
||||
const granted = candidates.some(({ key, level }) =>
|
||||
typeof key === 'string' && hasRequiredPermission(permissions, key, level || 'full')
|
||||
);
|
||||
|
||||
if (granted) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'INSUFFICIENT_PERMISSIONS',
|
||||
permission: candidates.map((entry) => entry.key).filter(Boolean),
|
||||
requiredLevel: candidates.map((entry) => entry.level || 'full')
|
||||
});
|
||||
};
|
||||
|
||||
const logScriptOutput = (data, level) => {
|
||||
if (!data) return;
|
||||
const text = data.toString();
|
||||
@@ -1425,14 +1584,37 @@ app.use((req, res, next) => {
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
const userRecord = getUserById(session.userId);
|
||||
if (!userRecord || !userRecord.isActive) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
req.user = sanitizeUser(user);
|
||||
const userGroups = Array.isArray(userRecord.groups) ? userRecord.groups : [];
|
||||
const groupIds = userGroups
|
||||
.map((group) => Number(group.id))
|
||||
.filter((groupId) => Number.isFinite(groupId) && groupId > 0);
|
||||
|
||||
const isSuperuser = userGroups.some(
|
||||
(group) => typeof group?.name === 'string' && group.name.toLowerCase() === SUPERUSER_GROUP_NAME
|
||||
);
|
||||
|
||||
const permissionsMap = isSuperuser
|
||||
? getSuperuserPermissionMap()
|
||||
: getEffectivePermissionsForGroups(groupIds);
|
||||
|
||||
req.user = {
|
||||
id: userRecord.id,
|
||||
username: userRecord.username,
|
||||
email: userRecord.email,
|
||||
avatarColor: userRecord.avatarColor || null,
|
||||
groups: userGroups,
|
||||
isSuperuser,
|
||||
securityPhraseDownloadedAt: userRecord.securityPhraseDownloadedAt || null,
|
||||
requiresSecurityPhraseDownload: !userRecord.securityPhraseDownloadedAt
|
||||
};
|
||||
req.userPermissions = permissionsMap;
|
||||
req.authToken = token;
|
||||
touchSession(token);
|
||||
setAuthCookie(res, token);
|
||||
@@ -1618,7 +1800,7 @@ app.post('/api/setup/complete', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/setup/endpoints/:id', (req, res) => {
|
||||
app.delete('/api/setup/endpoints/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
@@ -1643,7 +1825,7 @@ app.delete('/api/setup/endpoints/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/setup/servers/:id', (req, res) => {
|
||||
app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
@@ -1668,7 +1850,7 @@ app.delete('/api/setup/servers/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/setup/servers/:id/api-key', (req, res) => {
|
||||
app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-manage', 'full'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
@@ -1732,7 +1914,12 @@ app.post('/api/auth/login', (req, res) => {
|
||||
markUserLogin(user.id);
|
||||
setAuthCookie(res, session.token);
|
||||
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
const payload = buildUserSessionPayload(user.id);
|
||||
if (payload) {
|
||||
res.json(payload);
|
||||
} else {
|
||||
res.json({ user: sanitizeUser(user), permissions: {} });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
@@ -1744,6 +1931,74 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/auth/recover/verify', (req, res) => {
|
||||
const { username, phrase, words } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const candidate = typeof phrase === 'string' && phrase.trim() ? phrase : words;
|
||||
const verification = verifySecurityPhraseForUsername(username, candidate);
|
||||
const ticket = createPasswordResetTicketForUser(verification.userId);
|
||||
res.json({ token: ticket.token, expiresIn: PASSWORD_RESET_TTL_MS });
|
||||
} catch (error) {
|
||||
if (error.code === 'USERNAME_REQUIRED' || error.code === 'PHRASE_REQUIRED') {
|
||||
return res.status(400).json({ error: error.code });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
if (error.code === 'PHRASE_NOT_INITIALIZED') {
|
||||
return res.status(409).json({ error: 'PHRASE_NOT_INITIALIZED' });
|
||||
}
|
||||
if (error.code === 'PHRASE_MISMATCH') {
|
||||
return res.status(401).json({ error: 'PHRASE_MISMATCH' });
|
||||
}
|
||||
if (error.code === 'INVALID_PASSWORD') {
|
||||
return res.status(400).json({ error: 'INVALID_PASSWORD' });
|
||||
}
|
||||
console.error('⚠️ [Auth] Sicherheitsphrase-Überprüfung fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'RECOVERY_VERIFY_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/recover/reset', (req, res) => {
|
||||
const { token, password, confirmPassword } = req.body ?? {};
|
||||
|
||||
if (!token || typeof token !== 'string' || !token.trim()) {
|
||||
return res.status(400).json({ error: 'TOKEN_REQUIRED' });
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || !password.trim()) {
|
||||
return res.status(400).json({ error: 'PASSWORD_REQUIRED' });
|
||||
}
|
||||
|
||||
if (typeof confirmPassword === 'string' && password !== confirmPassword) {
|
||||
return res.status(400).json({ error: 'PASSWORD_MISMATCH' });
|
||||
}
|
||||
|
||||
const trimmedToken = token.trim();
|
||||
const ticket = getPasswordResetTicket(trimmedToken);
|
||||
if (!ticket) {
|
||||
return res.status(410).json({ error: 'TOKEN_INVALID_OR_EXPIRED' });
|
||||
}
|
||||
|
||||
try {
|
||||
setUserPassword(ticket.userId, password, { resetMethod: 'security-phrase' });
|
||||
passwordResetTickets.delete(trimmedToken);
|
||||
removeSessionsForUser(ticket.userId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') {
|
||||
return res.status(400).json({ error: error.code });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
passwordResetTickets.delete(trimmedToken);
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
console.error('⚠️ [Auth] Passwort-Zurücksetzung fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'RECOVERY_RESET_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/session', (req, res) => {
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
@@ -1760,8 +2015,8 @@ app.get('/api/auth/session', (req, res) => {
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
const payload = buildUserSessionPayload(session.userId);
|
||||
if (!payload) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
@@ -1769,10 +2024,10 @@ app.get('/api/auth/session', (req, res) => {
|
||||
|
||||
touchSession(token);
|
||||
setAuthCookie(res, token);
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
app.get('/api/users', (req, res) => {
|
||||
app.get('/api/users', requirePermission('users-access', 'read'), (req, res) => {
|
||||
try {
|
||||
const users = listUsers();
|
||||
res.json({ items: users, total: users.length });
|
||||
@@ -1782,11 +2037,11 @@ app.get('/api/users', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users', (req, res) => {
|
||||
app.post('/api/users', requirePermission('users-edit', 'full'), (req, res) => {
|
||||
const { username, email, password, groupId, avatarColor } = req.body ?? {};
|
||||
|
||||
try {
|
||||
const user = createUser({ username, email, password, groupId, avatarColor });
|
||||
const user = createUser({ username, email, password, groupId, avatarColor }, { actor: req.user });
|
||||
res.status(201).json({ item: user });
|
||||
} catch (error) {
|
||||
if (error.code === 'USERNAME_REQUIRED' || error.code === 'INVALID_GROUP_ID') {
|
||||
@@ -1809,7 +2064,62 @@ app.post('/api/users', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/users/:userId', (req, res) => {
|
||||
app.get('/api/users/me/security-phrase', (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
try {
|
||||
const phrase = getUserSecurityPhrase(userId, { actor: req.user });
|
||||
if (phrase.downloadedAt) {
|
||||
return res.status(409).json({
|
||||
error: 'SECURITY_PHRASE_ALREADY_DOWNLOADED',
|
||||
downloadedAt: phrase.downloadedAt
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
item: {
|
||||
userId: phrase.userId,
|
||||
words: phrase.words,
|
||||
downloadedAt: phrase.downloadedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
console.error(`⚠️ [Users] Sicherheitsschlüssel konnte für Benutzer ${userId} nicht geladen werden:`, error);
|
||||
res.status(500).json({ error: 'USER_SECURITY_PHRASE_FETCH_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users/me/security-phrase/downloaded', (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = markSecurityPhraseDownloaded(userId, { actor: req.user });
|
||||
const sessionPayload = buildUserSessionPayload(userId);
|
||||
res.json({
|
||||
item: {
|
||||
userId: result.userId,
|
||||
downloadedAt: result.downloadedAt
|
||||
},
|
||||
session: sessionPayload
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
console.error(`⚠️ [Users] Sicherheits-Download konnte nicht vermerkt werden (${userId}):`, error);
|
||||
res.status(500).json({ error: 'USER_SECURITY_PHRASE_DOWNLOAD_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/users/:userId', requirePermission('users-access', 'read'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
@@ -1829,7 +2139,53 @@ app.get('/api/users/:userId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId', (req, res) => {
|
||||
app.get('/api/users/:userId/security-phrase', requirePermission('users-security-phrase', 'read'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const phrase = getUserSecurityPhrase(numericId, { actor: req.user });
|
||||
res.json({ item: phrase });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_USER_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
console.error(`⚠️ [Users] Sicherheitsschlüssel konnte nicht geladen werden (${userId}):`, error);
|
||||
res.status(500).json({ error: 'USER_SECURITY_PHRASE_FETCH_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users/:userId/security-phrase/renew', requirePermission('users-security-phrase', 'full'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const renewed = renewUserSecurityPhrase(numericId, { actor: req.user });
|
||||
res.json({ item: renewed });
|
||||
} catch (error) {
|
||||
if (error.code === 'INVALID_USER_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||
}
|
||||
if (error.code === 'USER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
}
|
||||
console.error(`⚠️ [Users] Sicherheitsschlüssel konnte nicht erneuert werden (${userId}):`, error);
|
||||
res.status(500).json({ error: 'USER_SECURITY_PHRASE_RENEW_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId', requirePermission('users-edit', 'full'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
@@ -1886,7 +2242,7 @@ app.put('/api/users/:userId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId/groups', (req, res) => {
|
||||
app.put('/api/users/:userId/groups', requirePermission('users-edit', 'full'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
const { groupIds } = req.body ?? {};
|
||||
@@ -1913,7 +2269,7 @@ app.put('/api/users/:userId/groups', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/users/:userId', (req, res) => {
|
||||
app.delete('/api/users/:userId', requirePermission('users-delete', 'full'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
|
||||
@@ -1940,7 +2296,7 @@ app.delete('/api/users/:userId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/users/:userId/active', (req, res) => {
|
||||
app.put('/api/users/:userId/active', requirePermission('users-edit', 'full'), (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const numericId = Number(userId);
|
||||
const { isActive } = req.body ?? {};
|
||||
@@ -1968,7 +2324,13 @@ app.put('/api/users/:userId/active', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/groups', (req, res) => {
|
||||
app.get(
|
||||
'/api/groups',
|
||||
requireAnyPermission([
|
||||
{ key: 'user-groups-access', level: 'read' },
|
||||
{ key: 'users-edit', level: 'read' }
|
||||
]),
|
||||
(req, res) => {
|
||||
try {
|
||||
const groups = listGroups();
|
||||
res.json({ items: groups, total: groups.length });
|
||||
@@ -1976,9 +2338,16 @@ app.get('/api/groups', (req, res) => {
|
||||
console.error('⚠️ [Groups] Abruf der Benutzergruppenliste fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'GROUPS_FETCH_FAILED' });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/api/groups/:groupId', (req, res) => {
|
||||
app.get(
|
||||
'/api/groups/:groupId',
|
||||
requireAnyPermission([
|
||||
{ key: 'user-groups-access', level: 'read' },
|
||||
{ key: 'users-edit', level: 'read' }
|
||||
]),
|
||||
(req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
@@ -1996,9 +2365,88 @@ app.get('/api/groups/:groupId', (req, res) => {
|
||||
console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error);
|
||||
res.status(500).json({ error: 'GROUP_FETCH_FAILED' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/api/groups/:groupId/permissions', requirePermission('user-groups-edit', 'read'), (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const group = getGroupById(numericId);
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const sections = getPermissionStructure();
|
||||
const isSuperuser = (group.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
let values = {};
|
||||
|
||||
if (isSuperuser) {
|
||||
clearGroupPermissionValues(numericId);
|
||||
} else {
|
||||
values = getPermissionValuesByGroup(numericId);
|
||||
}
|
||||
|
||||
res.json({ sections, values });
|
||||
} catch (error) {
|
||||
console.error(`⚠️ [Groups] Abruf der Gruppenberechtigungen fehlgeschlagen (${groupId}):`, error);
|
||||
res.status(500).json({ error: 'GROUP_PERMISSIONS_FETCH_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/groups', (req, res) => {
|
||||
app.put('/api/groups/:groupId/permissions', requirePermission('user-groups-edit', 'full'), (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const group = getGroupById(numericId);
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const isSuperuser = (group.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
if (isSuperuser) {
|
||||
clearGroupPermissionValues(numericId);
|
||||
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
|
||||
}
|
||||
|
||||
const valuesPayload = req.body?.values ?? {};
|
||||
const savedValues = saveGroupPermissionValues(numericId, valuesPayload);
|
||||
|
||||
res.json({ success: true, values: savedValues });
|
||||
} catch (error) {
|
||||
const serverError = error?.code;
|
||||
if (serverError === 'GROUP_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||
}
|
||||
if (serverError === 'PERMISSION_INVALID_PAYLOAD') {
|
||||
return res.status(400).json({ error: 'PERMISSION_INVALID_PAYLOAD' });
|
||||
}
|
||||
if (serverError === 'PERMISSION_INVALID_LEVEL') {
|
||||
return res.status(400).json({ error: 'PERMISSION_INVALID_LEVEL', permissionKey: error.permissionKey, level: error.level });
|
||||
}
|
||||
if (serverError === 'PERMISSION_UNKNOWN_KEY') {
|
||||
return res.status(400).json({ error: 'PERMISSION_UNKNOWN_KEY', permissionKey: error.permissionKey });
|
||||
}
|
||||
if (serverError === 'INVALID_GROUP_ID') {
|
||||
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||
}
|
||||
|
||||
console.error(`⚠️ [Groups] Aktualisierung der Berechtigungen fehlgeschlagen (${groupId}):`, error);
|
||||
res.status(500).json({ error: 'GROUP_PERMISSIONS_UPDATE_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/groups', requirePermission('user-groups-edit', 'full'), (req, res) => {
|
||||
const { name, description } = req.body ?? {};
|
||||
try {
|
||||
const group = createGroup({ name, description });
|
||||
@@ -2015,7 +2463,7 @@ app.post('/api/groups', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/groups/:groupId', (req, res) => {
|
||||
app.put('/api/groups/:groupId', requirePermission('user-groups-edit', 'full'), (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
@@ -2053,7 +2501,7 @@ app.put('/api/groups/:groupId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/groups/:groupId', (req, res) => {
|
||||
app.delete('/api/groups/:groupId', requirePermission('user-groups-delete', 'full'), (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const numericId = Number(groupId);
|
||||
|
||||
@@ -2197,7 +2645,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/maintenance/portainer-status', async (req, res) => {
|
||||
app.get('/api/maintenance/portainer-status', requirePermission('maintenance-portainer', 'read'), async (req, res) => {
|
||||
console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet");
|
||||
try {
|
||||
const payload = await fetchPortainerStatusSummary();
|
||||
@@ -2212,7 +2660,7 @@ app.get('/api/maintenance/portainer-status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/maintenance/mode', (req, res) => {
|
||||
app.post('/api/maintenance/mode', requirePermission('maintenance-access', 'full'), (req, res) => {
|
||||
try {
|
||||
const { active, message } = req.body ?? {};
|
||||
const normalizedMessage = typeof message === 'string' && message.trim() ? message.trim() : null;
|
||||
@@ -2231,7 +2679,7 @@ app.post('/api/maintenance/mode', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/maintenance/config', (req, res) => {
|
||||
app.get('/api/maintenance/config', requirePermission('maintenance-server-manage', 'read'), (req, res) => {
|
||||
const custom = getCustomPortainerScript();
|
||||
const effective = getEffectivePortainerScript();
|
||||
const ssh = getPortainerSshConfig();
|
||||
@@ -2257,7 +2705,7 @@ app.get('/api/maintenance/config', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/maintenance/ssh-config', (req, res) => {
|
||||
app.put('/api/maintenance/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||
try {
|
||||
const config = savePortainerSshConfig(req.body || {});
|
||||
res.json({
|
||||
@@ -2276,7 +2724,7 @@ app.put('/api/maintenance/ssh-config', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/maintenance/ssh-config', (req, res) => {
|
||||
app.delete('/api/maintenance/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||
const config = deletePortainerSshConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -2290,7 +2738,7 @@ app.delete('/api/maintenance/ssh-config', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/maintenance/test-ssh', async (req, res) => {
|
||||
app.post('/api/maintenance/test-ssh', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => {
|
||||
try {
|
||||
const override = req.body && Object.keys(req.body).length ? req.body : null;
|
||||
const result = await testSshConnection(override);
|
||||
@@ -2301,7 +2749,7 @@ app.post('/api/maintenance/test-ssh', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/maintenance/update-script', (req, res) => {
|
||||
app.put('/api/maintenance/update-script', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||
if (portainerUpdateState.running) {
|
||||
return res.status(409).json({
|
||||
error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.',
|
||||
@@ -2332,7 +2780,7 @@ app.put('/api/maintenance/update-script', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/maintenance/update-script', (req, res) => {
|
||||
app.delete('/api/maintenance/update-script', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||
if (portainerUpdateState.running) {
|
||||
return res.status(409).json({
|
||||
error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.',
|
||||
@@ -2356,14 +2804,14 @@ app.delete('/api/maintenance/update-script', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/maintenance/update-status', (req, res) => {
|
||||
app.get('/api/maintenance/update-status', requirePermission('maintenance-update', 'read'), (req, res) => {
|
||||
res.json({
|
||||
maintenance: getMaintenanceState(),
|
||||
update: getPortainerUpdateStatus()
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/maintenance/portainer-update', async (req, res) => {
|
||||
app.post('/api/maintenance/portainer-update', requirePermission('maintenance-update', 'full'), async (req, res) => {
|
||||
if (portainerUpdateState.running) {
|
||||
return res.status(409).json({
|
||||
error: 'Ein Portainer-Update läuft bereits.',
|
||||
@@ -2429,7 +2877,11 @@ app.post('/api/maintenance/portainer-update', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/maintenance/duplicates', maintenanceGuard, async (req, res) => {
|
||||
app.get(
|
||||
'/api/maintenance/duplicates',
|
||||
maintenanceGuard,
|
||||
requirePermission('maintenance-duplicates', 'read'),
|
||||
async (req, res) => {
|
||||
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
|
||||
try {
|
||||
const { duplicates } = await loadStackCollections();
|
||||
@@ -2464,7 +2916,11 @@ app.get('/api/maintenance/duplicates', maintenanceGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, res) => {
|
||||
app.post(
|
||||
'/api/maintenance/duplicates/cleanup',
|
||||
maintenanceGuard,
|
||||
requirePermission('maintenance-duplicates', 'full'),
|
||||
async (req, res) => {
|
||||
const canonicalId = req.body?.canonicalId;
|
||||
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
|
||||
const duplicateIds = duplicateIdsInput
|
||||
@@ -2592,7 +3048,7 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
||||
});
|
||||
|
||||
// Event-Logs abrufen
|
||||
app.get('/api/logs', (req, res) => {
|
||||
app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => {
|
||||
const perPageParam = req.query.perPage ?? req.query.limit;
|
||||
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
|
||||
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||
@@ -2677,7 +3133,7 @@ app.get('/api/logs', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/logs/:id', (req, res) => {
|
||||
app.delete('/api/logs/:id', requirePermission('logs-delete', 'full'), (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Ungültige ID' });
|
||||
@@ -2695,7 +3151,7 @@ app.delete('/api/logs/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/logs', (req, res) => {
|
||||
app.delete('/api/logs', requirePermission('logs-delete', 'full'), (req, res) => {
|
||||
try {
|
||||
const deleted = deleteEventLogsByFilters(req.query);
|
||||
res.json({ success: true, deleted });
|
||||
@@ -2705,7 +3161,7 @@ app.delete('/api/logs', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/logs/export', (req, res) => {
|
||||
app.get('/api/logs/export', requirePermission('logs-export', 'full'), (req, res) => {
|
||||
const format = (req.query.format || 'txt').toLowerCase();
|
||||
if (!['txt', 'sql'].includes(format)) {
|
||||
return res.status(400).json({ error: 'Ungültiges Export-Format' });
|
||||
@@ -2723,7 +3179,7 @@ app.get('/api/logs/export', (req, res) => {
|
||||
});
|
||||
|
||||
// Einzel-Redeploy
|
||||
app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
||||
app.put('/api/stacks/:id/redeploy', requirePermission('stacks-redeploy-single', 'full'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
||||
|
||||
@@ -2739,7 +3195,11 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
||||
});
|
||||
|
||||
// Redeploy ALL
|
||||
app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
app.put(
|
||||
'/api/stacks/redeploy-all',
|
||||
maintenanceGuard,
|
||||
requirePermission('stacks-redeploy-all', 'full'),
|
||||
async (req, res) => {
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||
|
||||
let endpointId;
|
||||
@@ -2840,7 +3300,11 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
// Redeploy selection
|
||||
app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) => {
|
||||
app.put(
|
||||
'/api/stacks/redeploy-selection',
|
||||
maintenanceGuard,
|
||||
requirePermission('stacks-redeploy-selection', 'full'),
|
||||
async (req, res) => {
|
||||
const { stackIds } = req.body || {};
|
||||
const totalCount = Array.isArray(stackIds) ? stackIds.length : 0;
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`);
|
||||
|
||||
Generated
+32
@@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"dockerode": "^4.0.7",
|
||||
@@ -59,6 +60,17 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -113,6 +125,26 @@
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"socket.io": "^4.8.1"
|
||||
"socket.io": "^4.8.1",
|
||||
"@scure/bip39": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const LEVEL_PRIORITY = {
|
||||
none: 0,
|
||||
read: 1,
|
||||
full: 2
|
||||
};
|
||||
|
||||
const selectSections = db.prepare(`
|
||||
SELECT id, key, title, sort_order, has_navigation_flag
|
||||
FROM permission_sections
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectGroups = db.prepare(`
|
||||
SELECT id, section_id, key, title, sort_order
|
||||
FROM permission_groups
|
||||
ORDER BY section_id ASC, sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectItems = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
section_id,
|
||||
group_id,
|
||||
key,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required
|
||||
FROM permission_items
|
||||
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectDependencies = db.prepare(`
|
||||
SELECT permission_id, depends_on_permission_id, required_level
|
||||
FROM permission_dependencies
|
||||
`);
|
||||
|
||||
const selectPermissionItemsForMap = db.prepare(`
|
||||
SELECT id, key, available_levels, default_level
|
||||
FROM permission_items
|
||||
`);
|
||||
|
||||
const selectGroupPermissionValues = db.prepare(`
|
||||
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
|
||||
FROM group_permission_values gp
|
||||
JOIN permission_items pi ON pi.id = gp.permission_id
|
||||
WHERE gp.group_id = ?
|
||||
`);
|
||||
|
||||
const deleteGroupPermissionValuesStmt = db.prepare(`
|
||||
DELETE FROM group_permission_values
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const insertGroupPermissionValueStmt = db.prepare(`
|
||||
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const parseAvailableLevels = (raw) => {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return ['full', 'read', 'none'];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
|
||||
}
|
||||
return ['full', 'read', 'none'];
|
||||
};
|
||||
|
||||
const getLevelPriority = (level) => {
|
||||
if (!level) return 0;
|
||||
return LEVEL_PRIORITY[level] ?? 0;
|
||||
};
|
||||
|
||||
let cachedPermissionItems = null;
|
||||
|
||||
const getPermissionItems = () => {
|
||||
if (!cachedPermissionItems) {
|
||||
cachedPermissionItems = selectPermissionItemsForMap.all();
|
||||
}
|
||||
return cachedPermissionItems;
|
||||
};
|
||||
|
||||
const resetPermissionItemsCache = () => {
|
||||
cachedPermissionItems = null;
|
||||
};
|
||||
|
||||
export function getPermissionStructure() {
|
||||
const sections = selectSections.all();
|
||||
const groups = selectGroups.all();
|
||||
const items = selectItems.all();
|
||||
const dependencies = selectDependencies.all();
|
||||
|
||||
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
|
||||
|
||||
const dependenciesByItemId = new Map();
|
||||
dependencies.forEach((dependency) => {
|
||||
const list = dependenciesByItemId.get(dependency.permission_id) || [];
|
||||
list.push(dependency);
|
||||
dependenciesByItemId.set(dependency.permission_id, list);
|
||||
});
|
||||
|
||||
const groupsBySectionId = new Map();
|
||||
groups.forEach((group) => {
|
||||
const list = groupsBySectionId.get(group.section_id) || [];
|
||||
list.push(group);
|
||||
groupsBySectionId.set(group.section_id, list);
|
||||
});
|
||||
|
||||
const itemsBySectionId = new Map();
|
||||
const itemsByGroupId = new Map();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.group_id) {
|
||||
const list = itemsByGroupId.get(item.group_id) || [];
|
||||
list.push(item);
|
||||
itemsByGroupId.set(item.group_id, list);
|
||||
} else {
|
||||
const list = itemsBySectionId.get(item.section_id) || [];
|
||||
list.push(item);
|
||||
itemsBySectionId.set(item.section_id, list);
|
||||
}
|
||||
});
|
||||
|
||||
const formatItem = (item) => {
|
||||
const availableLevels = parseAvailableLevels(item.available_levels);
|
||||
|
||||
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
|
||||
const formattedDependencies = dependencyEntries
|
||||
.map((dependency) => {
|
||||
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
|
||||
if (!dependsOnKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: dependsOnKey,
|
||||
requiredLevel: dependency.required_level || null
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
sortOrder: item.sort_order ?? 0,
|
||||
defaultLevel: item.default_level || 'none',
|
||||
availableLevels,
|
||||
isRequired: Boolean(item.is_required),
|
||||
dependencies: formattedDependencies
|
||||
};
|
||||
};
|
||||
|
||||
const formattedSections = sections.map((section) => {
|
||||
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
sortOrder: group.sort_order ?? 0,
|
||||
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
|
||||
}));
|
||||
|
||||
return {
|
||||
key: section.key,
|
||||
title: section.title,
|
||||
sortOrder: section.sort_order ?? 0,
|
||||
hasNavigation: Boolean(section.has_navigation_flag),
|
||||
items: sectionItems,
|
||||
groups: sectionGroups
|
||||
};
|
||||
});
|
||||
|
||||
return formattedSections;
|
||||
}
|
||||
|
||||
export function getPermissionValuesByGroup(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = selectGroupPermissionValues.all(numericId);
|
||||
const values = {};
|
||||
rows.forEach((row) => {
|
||||
if (row?.key && typeof row.level === 'string') {
|
||||
values[row.key] = row.level;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
export function clearGroupPermissionValues(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return;
|
||||
}
|
||||
deleteGroupPermissionValuesStmt.run(numericId);
|
||||
}
|
||||
|
||||
export function saveGroupPermissionValues(groupId, values = {}) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
||||
const error = new Error('PERMISSION_INVALID_PAYLOAD');
|
||||
error.code = 'PERMISSION_INVALID_PAYLOAD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const items = getPermissionItems();
|
||||
const itemMap = new Map();
|
||||
items.forEach((item) => {
|
||||
itemMap.set(item.key, {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
defaultLevel: item.default_level || 'none',
|
||||
availableLevels: parseAvailableLevels(item.available_levels)
|
||||
});
|
||||
});
|
||||
|
||||
const entries = Object.entries(values);
|
||||
const normalizedEntries = [];
|
||||
|
||||
entries.forEach(([key, rawValue]) => {
|
||||
const item = itemMap.get(key);
|
||||
if (!item) {
|
||||
const error = new Error('PERMISSION_UNKNOWN_KEY');
|
||||
error.code = 'PERMISSION_UNKNOWN_KEY';
|
||||
error.permissionKey = key;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
|
||||
if (!level) {
|
||||
level = 'none';
|
||||
}
|
||||
|
||||
if (!item.availableLevels.includes(level)) {
|
||||
const error = new Error('PERMISSION_INVALID_LEVEL');
|
||||
error.code = 'PERMISSION_INVALID_LEVEL';
|
||||
error.permissionKey = key;
|
||||
error.level = level;
|
||||
throw error;
|
||||
}
|
||||
|
||||
normalizedEntries.push({ item, level });
|
||||
});
|
||||
|
||||
const runSave = db.transaction(() => {
|
||||
deleteGroupPermissionValuesStmt.run(numericId);
|
||||
normalizedEntries.forEach(({ item, level }) => {
|
||||
if (level === (item.defaultLevel || 'none')) {
|
||||
return;
|
||||
}
|
||||
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
|
||||
});
|
||||
});
|
||||
|
||||
runSave();
|
||||
resetPermissionItemsCache();
|
||||
|
||||
return getPermissionValuesByGroup(numericId);
|
||||
}
|
||||
|
||||
export function getDefaultPermissionMap() {
|
||||
const items = getPermissionItems();
|
||||
const defaults = {};
|
||||
items.forEach((item) => {
|
||||
defaults[item.key] = item.default_level || 'none';
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function getSuperuserPermissionMap() {
|
||||
const items = getPermissionItems();
|
||||
const result = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
const available = parseAvailableLevels(item.available_levels);
|
||||
let chosen = 'full';
|
||||
if (!available.includes('full')) {
|
||||
chosen = available.reduce(
|
||||
(best, candidate) =>
|
||||
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
|
||||
'none'
|
||||
);
|
||||
}
|
||||
result[item.key] = chosen;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getEffectivePermissionsForGroup(groupId) {
|
||||
const defaults = getDefaultPermissionMap();
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const overrides = getPermissionValuesByGroup(numericId);
|
||||
const map = { ...defaults };
|
||||
|
||||
Object.keys(overrides).forEach((key) => {
|
||||
const value = overrides[key];
|
||||
if (typeof value === 'string') {
|
||||
map[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getEffectivePermissionsForGroups(groupIds = []) {
|
||||
const defaults = getDefaultPermissionMap();
|
||||
const finalMap = { ...defaults };
|
||||
|
||||
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
|
||||
iterableIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.forEach((groupId) => {
|
||||
const map = getEffectivePermissionsForGroup(groupId);
|
||||
Object.entries(map).forEach(([key, level]) => {
|
||||
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
|
||||
finalMap[key] = level;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return finalMap;
|
||||
}
|
||||
|
||||
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
|
||||
return true;
|
||||
}
|
||||
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
|
||||
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
|
||||
return getLevelPriority(current) >= getLevelPriority(required);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -34,8 +34,8 @@
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-8d2b4f50.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-11780ccd.css">
|
||||
<script type="module" crossorigin src="/assets/index-f91011a0.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-f6cd1c87.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+385
-6
@@ -1,5 +1,12 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
import {
|
||||
generateSecurityPhraseWords,
|
||||
encryptSecurityPhrase,
|
||||
decryptSecurityPhrase,
|
||||
canonicalizeWords,
|
||||
canonicalizePhraseInput
|
||||
} from './securityPhrase.js';
|
||||
import {
|
||||
hashPassword,
|
||||
normalizeAvatarColor,
|
||||
@@ -18,6 +25,7 @@ const selectUsersWithGroups = db.prepare(`
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.security_phrase_downloaded_at,
|
||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||
FROM users u
|
||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||
@@ -36,6 +44,10 @@ const selectUserWithGroupsById = db.prepare(`
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.security_phrase_content,
|
||||
u.security_phrase_iv,
|
||||
u.security_phrase_tag,
|
||||
u.security_phrase_downloaded_at,
|
||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||
FROM users u
|
||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||
@@ -63,6 +75,20 @@ const insertMembershipForUser = db.prepare(`
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
const selectSecurityPhraseByUsername = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
is_active,
|
||||
security_phrase_content AS content,
|
||||
security_phrase_iv AS iv,
|
||||
security_phrase_tag AS tag,
|
||||
security_phrase_downloaded_at AS downloaded_at
|
||||
FROM users
|
||||
WHERE lower(username) = lower(?)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
|
||||
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
|
||||
|
||||
@@ -75,8 +101,21 @@ const isUserInGroupStatement = db.prepare(`
|
||||
`);
|
||||
|
||||
const insertUserStatement = db.prepare(`
|
||||
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
INSERT INTO users (
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
password_salt,
|
||||
avatar_color,
|
||||
is_active,
|
||||
security_phrase_content,
|
||||
security_phrase_iv,
|
||||
security_phrase_tag,
|
||||
security_phrase_downloaded_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateUserCoreStatement = db.prepare(`
|
||||
@@ -97,6 +136,14 @@ const updateUserActiveStatement = db.prepare(`
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateUserPasswordStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?,
|
||||
password_salt = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseGroupPairs = (rawPairs) => {
|
||||
if (!rawPairs) {
|
||||
return [];
|
||||
@@ -127,7 +174,8 @@ const sanitizeUserRecord = (row) => ({
|
||||
lastLogin: row.last_login || null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
groups: parseGroupPairs(row.group_pairs)
|
||||
groups: parseGroupPairs(row.group_pairs),
|
||||
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
|
||||
});
|
||||
|
||||
export function listUsers() {
|
||||
@@ -147,13 +195,76 @@ const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
|
||||
});
|
||||
});
|
||||
|
||||
const insertUserWithGroups = db.transaction(({ username, email, passwordHash, passwordSalt, avatarColor, groupId }) => {
|
||||
const result = insertUserStatement.run(username, email, passwordHash, passwordSalt, avatarColor);
|
||||
const insertUserWithGroups = db.transaction(({
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor,
|
||||
groupId,
|
||||
securityPhrase
|
||||
}) => {
|
||||
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
|
||||
? {
|
||||
content: securityPhrase.content ?? null,
|
||||
iv: securityPhrase.iv ?? null,
|
||||
tag: securityPhrase.tag ?? null
|
||||
}
|
||||
: { content: null, iv: null, tag: null };
|
||||
|
||||
const result = insertUserStatement.run(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor,
|
||||
phraseToPersist.content,
|
||||
phraseToPersist.iv,
|
||||
phraseToPersist.tag
|
||||
);
|
||||
const userId = Number(result.lastInsertRowid);
|
||||
applyUserGroupAssignments(userId, [groupId]);
|
||||
return userId;
|
||||
});
|
||||
|
||||
const selectSecurityPhraseByUserId = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
security_phrase_content AS content,
|
||||
security_phrase_iv AS iv,
|
||||
security_phrase_tag AS tag,
|
||||
security_phrase_downloaded_at AS downloaded_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateSecurityPhraseStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET
|
||||
security_phrase_content = ?,
|
||||
security_phrase_iv = ?,
|
||||
security_phrase_tag = ?,
|
||||
security_phrase_downloaded_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const markSecurityPhraseDownloadedStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE security_phrase_content IS NULL
|
||||
OR security_phrase_iv IS NULL
|
||||
OR security_phrase_tag IS NULL
|
||||
`);
|
||||
|
||||
const resolveActorFields = (actor) => {
|
||||
if (!actor || actor.id === undefined || actor.id === null) {
|
||||
return {};
|
||||
@@ -175,6 +286,265 @@ const normalizeEmail = (value) => {
|
||||
return trimmed.toLowerCase();
|
||||
};
|
||||
|
||||
export function getUserSecurityPhrase(userId, options = {}) {
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { actor = null, allowAutoGenerate = true } = options;
|
||||
|
||||
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||
if (!record) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const words = decryptSecurityPhrase(record);
|
||||
|
||||
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
|
||||
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: numericUserId,
|
||||
username: record.username,
|
||||
words,
|
||||
downloadedAt: record.downloaded_at || null
|
||||
};
|
||||
}
|
||||
|
||||
export function renewUserSecurityPhrase(userId, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const suppressLog = Boolean(options.suppressLog);
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
|
||||
|
||||
const words = generateSecurityPhraseWords(8);
|
||||
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||
|
||||
updateSecurityPhraseStatement.run(
|
||||
encryptedPhrase.content,
|
||||
encryptedPhrase.iv,
|
||||
encryptedPhrase.tag,
|
||||
numericUserId
|
||||
);
|
||||
|
||||
if (!suppressLog) {
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-sicherheitsschluessel-erneuert',
|
||||
action: 'sicherheitsschluessel-erneuern',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
|
||||
metadata: {
|
||||
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId: numericUserId,
|
||||
username: existingUser.username,
|
||||
words,
|
||||
downloadedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
export function markSecurityPhraseDownloaded(userId, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||
if (!record) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
markSecurityPhraseDownloadedStatement.run(numericUserId);
|
||||
|
||||
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
|
||||
const downloadedAt = updatedRecord?.downloaded_at || null;
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
|
||||
action: 'sicherheitsschluessel-herunterladen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: record.username ?? `ID ${numericUserId}`,
|
||||
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
|
||||
metadata: {
|
||||
downloadedAt
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return {
|
||||
userId: numericUserId,
|
||||
username: record.username,
|
||||
downloadedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function verifySecurityPhraseForUsername(username, phraseInput) {
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||
if (!normalizedUsername) {
|
||||
const error = new Error('USERNAME_REQUIRED');
|
||||
error.code = 'USERNAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
|
||||
if (!record) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const candidateCanonical = canonicalizePhraseInput(phraseInput);
|
||||
if (!candidateCanonical) {
|
||||
const error = new Error('PHRASE_REQUIRED');
|
||||
error.code = 'PHRASE_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const words = decryptSecurityPhrase(record);
|
||||
if (!Array.isArray(words) || words.length === 0) {
|
||||
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const storedCanonical = canonicalizeWords(words);
|
||||
if (!storedCanonical) {
|
||||
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (storedCanonical !== candidateCanonical) {
|
||||
const error = new Error('PHRASE_MISMATCH');
|
||||
error.code = 'PHRASE_MISMATCH';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: record.id,
|
||||
username: record.username,
|
||||
isActive: Boolean(record.is_active),
|
||||
words
|
||||
};
|
||||
}
|
||||
|
||||
export function setUserPassword(userId, newPassword, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const resetMethod = options.resetMethod || 'security-phrase';
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let passwordHash;
|
||||
let passwordSalt;
|
||||
try {
|
||||
const hashed = hashPassword(newPassword);
|
||||
passwordHash = hashed.hash;
|
||||
passwordSalt = hashed.salt;
|
||||
} catch (err) {
|
||||
if (err && err.code) {
|
||||
throw err;
|
||||
}
|
||||
const error = new Error('INVALID_PASSWORD');
|
||||
error.code = 'INVALID_PASSWORD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
|
||||
const updated = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-passwort-zurueckgesetzt',
|
||||
action: 'passwort-zuruecksetzen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
|
||||
metadata: {
|
||||
resetMethod
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function ensureSecurityPhrasesForExistingUsers() {
|
||||
const rows = selectUsersMissingSecurityPhraseStatement.all();
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
const initialize = db.transaction((users) => {
|
||||
users.forEach((entry) => {
|
||||
if (!entry || entry.id === undefined || entry.id === null) {
|
||||
return;
|
||||
}
|
||||
const words = generateSecurityPhraseWords(8);
|
||||
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||
updateSecurityPhraseStatement.run(
|
||||
encryptedPhrase.content,
|
||||
encryptedPhrase.iv,
|
||||
encryptedPhrase.tag,
|
||||
entry.id
|
||||
);
|
||||
processed += 1;
|
||||
});
|
||||
});
|
||||
|
||||
initialize(rows);
|
||||
return processed;
|
||||
}
|
||||
|
||||
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||
@@ -245,13 +615,22 @@ export function createUser({ username, email, password, groupId, avatarColor },
|
||||
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
||||
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||
|
||||
const securityPhraseWords = generateSecurityPhraseWords(8);
|
||||
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
|
||||
const securityPhrasePayload = {
|
||||
content: encryptedPhrase.content,
|
||||
iv: encryptedPhrase.iv,
|
||||
tag: encryptedPhrase.tag
|
||||
};
|
||||
|
||||
const userId = insertUserWithGroups({
|
||||
username: normalizedUsername,
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor: avatarColorToPersist,
|
||||
groupId: numericGroupId
|
||||
groupId: numericGroupId,
|
||||
securityPhrase: securityPhrasePayload
|
||||
});
|
||||
|
||||
const userRecord = getUserById(userId);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from 'crypto';
|
||||
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
|
||||
|
||||
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
|
||||
|
||||
const WORD_LIST = englishWordlist;
|
||||
|
||||
|
||||
const SECURITY_PHRASE_KEY = crypto
|
||||
.createHash('sha256')
|
||||
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
|
||||
.digest();
|
||||
|
||||
const encryptValue = (value) => {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
iv: iv.toString('base64'),
|
||||
content: encrypted.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
};
|
||||
|
||||
const decryptValue = ({ content, iv, tag }) => {
|
||||
if (!content || !iv || !tag) {
|
||||
return '';
|
||||
}
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(content, 'base64')),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
};
|
||||
|
||||
export const generateSecurityPhraseWords = (count = 8) => {
|
||||
const words = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = crypto.randomInt(0, WORD_LIST.length);
|
||||
words.push(WORD_LIST[index]);
|
||||
}
|
||||
return words;
|
||||
};
|
||||
|
||||
export const encodeWords = (words = []) => {
|
||||
return words
|
||||
.map((word) => {
|
||||
const index = WORD_LIST.indexOf(word);
|
||||
if (index === -1) {
|
||||
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
|
||||
}
|
||||
return String(index);
|
||||
})
|
||||
.join('-');
|
||||
};
|
||||
|
||||
export const decodeWords = (encoded) => {
|
||||
if (!encoded) {
|
||||
return [];
|
||||
}
|
||||
return encoded.split('-').map((entry) => {
|
||||
const index = Number(entry);
|
||||
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
|
||||
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
|
||||
}
|
||||
return WORD_LIST[index];
|
||||
});
|
||||
};
|
||||
|
||||
export const encryptSecurityPhrase = (words) => {
|
||||
const encoded = encodeWords(words);
|
||||
const encrypted = encryptValue(encoded);
|
||||
return {
|
||||
encrypted,
|
||||
encoded
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptSecurityPhrase = (record) => {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
const encoded = decryptValue(record);
|
||||
if (!encoded) {
|
||||
return [];
|
||||
}
|
||||
return decodeWords(encoded);
|
||||
};
|
||||
|
||||
export const formatPhraseForDisplay = (words) => {
|
||||
const rows = [];
|
||||
for (let i = 0; i < words.length; i += 4) {
|
||||
rows.push(words.slice(i, i + 4));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const normalizeWord = (value) => {
|
||||
if (!value && value !== 0) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim().toLowerCase();
|
||||
};
|
||||
|
||||
export const canonicalizeWords = (words = []) => {
|
||||
return words
|
||||
.map(normalizeWord)
|
||||
.filter((word) => word.length > 0)
|
||||
.join('');
|
||||
};
|
||||
|
||||
export const extractWordsFromString = (input) => {
|
||||
if (!input && input !== 0) {
|
||||
return [];
|
||||
}
|
||||
const normalized = String(input)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return normalized.split(/\s+/).filter(Boolean);
|
||||
};
|
||||
|
||||
export const canonicalizePhraseInput = (input) => {
|
||||
if (Array.isArray(input)) {
|
||||
return canonicalizeWords(input);
|
||||
}
|
||||
return canonicalizeWords(extractWordsFromString(input));
|
||||
};
|
||||
|
||||
export default WORD_LIST;
|
||||
Reference in New Issue
Block a user