refactor: migrate multi-user to use existing user_data table

- Update migration to extend user_data table instead of creating new users table
- Refactor user_management service to work with tmpID (INTEGER) primary key
- Update login.ts to support multi-user authentication with user_data
- Fix auth.ts middleware to use new user management API
- Update API routes to handle tmpID-based user identification
- Store userId as number in session for consistency

This integrates with Trilium's existing OAuth user_data table (v229) and
maintains backward compatibility with single-user installations.
This commit is contained in:
Somoru 2025-10-21 14:33:37 +05:30
parent 1bf9a858eb
commit 883ca1ffc8
5 changed files with 233 additions and 426 deletions

View File

@ -2,178 +2,78 @@
* Migration to add multi-user support to Trilium. * Migration to add multi-user support to Trilium.
* *
* This migration: * This migration:
* 1. Creates users table * 1. Extends existing user_data table with multi-user fields
* 2. Creates roles table * 2. Migrates existing password to first user record
* 3. Creates user_roles junction table * 3. Adds userId columns to relevant tables (notes, branches, etapi_tokens, recent_notes)
* 4. Creates note_shares table for shared notes * 4. Associates all existing data with the default user
* 5. Adds userId column to existing tables (notes, branches, options, etapi_tokens, etc.) *
* 6. Creates a default admin user with existing password * Note: This reuses the existing user_data table from migration 229 (OAuth)
* 7. Associates all existing data with the admin user
*/ */
import sql from "../services/sql.js"; import sql from "../services/sql.js";
import optionService from "../services/options.js"; import optionService from "../services/options.js";
import { randomSecureToken, toBase64 } from "../services/utils.js";
import myScryptService from "../services/encryption/my_scrypt.js";
export default async () => { export default async () => {
console.log("Starting multi-user support migration (v234)..."); console.log("Starting multi-user support migration (v234)...");
// 1. Create users table // 1. Extend user_data table with additional fields for multi-user support
sql.execute(` const addColumnIfNotExists = (tableName: string, columnName: string, columnDef: string) => {
CREATE TABLE IF NOT EXISTS "users" (
userId TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT,
passwordHash TEXT NOT NULL,
passwordSalt TEXT NOT NULL,
derivedKeySalt TEXT NOT NULL,
encryptedDataKey TEXT,
isActive INTEGER NOT NULL DEFAULT 1,
isAdmin INTEGER NOT NULL DEFAULT 0,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL
)
`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_users_username ON users (username)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_users_email ON users (email)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_users_isActive ON users (isActive)`);
// 2. Create roles table
sql.execute(`
CREATE TABLE IF NOT EXISTS "roles" (
roleId TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
permissions TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL
)
`);
// 3. Create user_roles junction table
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_roles" (
userId TEXT NOT NULL,
roleId TEXT NOT NULL,
utcDateAssigned TEXT NOT NULL,
PRIMARY KEY (userId, roleId),
FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE,
FOREIGN KEY (roleId) REFERENCES roles(roleId) ON DELETE CASCADE
)
`);
// 4. Create note_shares table for sharing notes between users
sql.execute(`
CREATE TABLE IF NOT EXISTS "note_shares" (
shareId TEXT PRIMARY KEY,
noteId TEXT NOT NULL,
ownerId TEXT NOT NULL,
sharedWithUserId TEXT NOT NULL,
permission TEXT NOT NULL DEFAULT 'read',
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL,
isDeleted INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (noteId) REFERENCES notes(noteId) ON DELETE CASCADE,
FOREIGN KEY (ownerId) REFERENCES users(userId) ON DELETE CASCADE,
FOREIGN KEY (sharedWithUserId) REFERENCES users(userId) ON DELETE CASCADE
)
`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_note_shares_noteId ON note_shares (noteId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_note_shares_sharedWithUserId ON note_shares (sharedWithUserId)`);
// 5. Add userId columns to existing tables (if they don't exist)
const addUserIdColumn = (tableName: string) => {
// Check if column already exists
const columns = sql.getRows(`PRAGMA table_info(${tableName})`); const columns = sql.getRows(`PRAGMA table_info(${tableName})`);
const hasUserId = columns.some((col: any) => col.name === 'userId'); const hasColumn = columns.some((col: any) => col.name === columnName);
if (!hasUserId) { if (!hasColumn) {
sql.execute(`ALTER TABLE ${tableName} ADD COLUMN userId TEXT`); sql.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`);
console.log(`Added userId column to ${tableName}`); console.log(`Added ${columnName} column to ${tableName}`);
} }
}; };
// Add role/permission tracking
addColumnIfNotExists('user_data', 'role', 'TEXT DEFAULT "admin"');
addColumnIfNotExists('user_data', 'isActive', 'INTEGER DEFAULT 1');
addColumnIfNotExists('user_data', 'utcDateCreated', 'TEXT');
addColumnIfNotExists('user_data', 'utcDateModified', 'TEXT');
// Create index on username for faster lookups
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_user_data_username ON user_data (username)`);
// 2. Add userId columns to existing tables (if they don't exist)
const addUserIdColumn = (tableName: string) => {
addColumnIfNotExists(tableName, 'userId', 'INTEGER');
};
addUserIdColumn('notes'); addUserIdColumn('notes');
addUserIdColumn('branches'); addUserIdColumn('branches');
addUserIdColumn('recent_notes'); addUserIdColumn('recent_notes');
addUserIdColumn('etapi_tokens'); addUserIdColumn('etapi_tokens');
// Create indexes for userId columns // Create indexes for userId columns for better performance
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_notes_userId ON notes (userId)`); sql.execute(`CREATE INDEX IF NOT EXISTS IDX_notes_userId ON notes (userId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_branches_userId ON branches (userId)`); sql.execute(`CREATE INDEX IF NOT EXISTS IDX_branches_userId ON branches (userId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_etapi_tokens_userId ON etapi_tokens (userId)`); sql.execute(`CREATE INDEX IF NOT EXISTS IDX_etapi_tokens_userId ON etapi_tokens (userId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_recent_notes_userId ON recent_notes (userId)`);
// 6. Create default roles // 3. Migrate existing single-user setup to first user in user_data table
const now = new Date().toISOString(); const existingUser = sql.getValue(`SELECT COUNT(*) as count FROM user_data`) as number;
const defaultRoles = [ if (existingUser === 0) {
{ // Get existing password components from options
roleId: 'role_admin', const passwordVerificationHash = optionService.getOption('passwordVerificationHash');
name: 'admin', const passwordVerificationSalt = optionService.getOption('passwordVerificationSalt');
description: 'Full system administrator with all permissions', const passwordDerivedKeySalt = optionService.getOption('passwordDerivedKeySalt');
permissions: JSON.stringify({ const encryptedDataKey = optionService.getOption('encryptedDataKey');
notes: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
system: ['backup', 'restore', 'migrate']
})
},
{
roleId: 'role_user',
name: 'user',
description: 'Regular user with standard permissions',
permissions: JSON.stringify({
notes: ['create', 'read', 'update', 'delete'],
users: ['read_self', 'update_self'],
settings: ['read_self']
})
},
{
roleId: 'role_viewer',
name: 'viewer',
description: 'Read-only user',
permissions: JSON.stringify({
notes: ['read'],
users: ['read_self']
})
}
];
for (const role of defaultRoles) { if (passwordVerificationHash && passwordVerificationSalt) {
sql.execute(` const now = new Date().toISOString();
INSERT OR IGNORE INTO roles (roleId, name, description, permissions, utcDateCreated, utcDateModified)
VALUES (?, ?, ?, ?, ?, ?) // Create default admin user from existing credentials
`, [role.roleId, role.name, role.description, role.permissions, now, now]);
}
// 7. Create default admin user from existing password
const adminUserId = 'user_admin_' + randomSecureToken(10);
// Get existing password hash components
const passwordVerificationHash = optionService.getOption('passwordVerificationHash');
const passwordVerificationSalt = optionService.getOption('passwordVerificationSalt');
const passwordDerivedKeySalt = optionService.getOption('passwordDerivedKeySalt');
const encryptedDataKey = optionService.getOption('encryptedDataKey');
if (passwordVerificationHash && passwordVerificationSalt && passwordDerivedKeySalt) {
// Check if admin user already exists
const existingAdmin = sql.getValue(`SELECT userId FROM users WHERE username = 'admin'`);
if (!existingAdmin) {
sql.execute(` sql.execute(`
INSERT INTO users ( INSERT INTO user_data (
userId, username, email, passwordHash, passwordSalt, tmpID, username, email, userIDVerificationHash, salt,
derivedKeySalt, encryptedDataKey, isActive, isAdmin, derivedKey, userIDEncryptedDataKey, isSetup, role,
utcDateCreated, utcDateModified isActive, utcDateCreated, utcDateModified
) )
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?) VALUES (1, 'admin', NULL, ?, ?, ?, ?, 'true', 'admin', 1, ?, ?)
`, [ `, [
adminUserId,
'admin',
null,
passwordVerificationHash, passwordVerificationHash,
passwordVerificationSalt, passwordVerificationSalt,
passwordDerivedKeySalt, passwordDerivedKeySalt,
@ -182,22 +82,32 @@ export default async () => {
now now
]); ]);
// Assign admin role to the user console.log("Migrated existing password to default admin user (tmpID=1)");
sql.execute(`
INSERT INTO user_roles (userId, roleId, utcDateAssigned)
VALUES (?, ?, ?)
`, [adminUserId, 'role_admin', now]);
console.log(`Created default admin user with ID: ${adminUserId}`);
// 8. Associate all existing data with the admin user (only if admin was created) // 4. Associate all existing data with the default user (tmpID=1)
sql.execute(`UPDATE notes SET userId = ? WHERE userId IS NULL`, [adminUserId]); sql.execute(`UPDATE notes SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE branches SET userId = ? WHERE userId IS NULL`, [adminUserId]); sql.execute(`UPDATE branches SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE etapi_tokens SET userId = ? WHERE userId IS NULL`, [adminUserId]); sql.execute(`UPDATE etapi_tokens SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE recent_notes SET userId = ? WHERE userId IS NULL`, [adminUserId]); sql.execute(`UPDATE recent_notes SET userId = 1 WHERE userId IS NULL`);
console.log("Associated all existing data with default admin user");
} else {
console.log("No existing password found. User will be created on first login.");
} }
} else { } else {
console.log("No existing password found, admin user will need to be created on first login"); console.log(`Found ${existingUser} existing user(s) in user_data table`);
// Ensure existing users have the new fields populated
sql.execute(`UPDATE user_data SET role = 'admin' WHERE role IS NULL`);
sql.execute(`UPDATE user_data SET isActive = 1 WHERE isActive IS NULL`);
sql.execute(`UPDATE user_data SET utcDateCreated = ? WHERE utcDateCreated IS NULL`, [new Date().toISOString()]);
sql.execute(`UPDATE user_data SET utcDateModified = ? WHERE utcDateModified IS NULL`, [new Date().toISOString()]);
// Associate data with first user if not already associated
sql.execute(`UPDATE notes SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE branches SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE etapi_tokens SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE recent_notes SET userId = 1 WHERE userId IS NULL`);
} }
console.log("Multi-user support migration completed successfully!"); console.log("Multi-user support migration completed successfully!");

View File

@ -1,8 +1,10 @@
/** /**
* User Management API * User Management API
* *
* Provides endpoints for managing users in multi-user installations. * Provides endpoints for managing users in multi-user installations.
* All endpoints require authentication and most require admin privileges. * All endpoints require authentication and most require admin privileges.
*
* Works with user_data table (tmpID as primary key).
*/ */
import { Request } from "express"; import { Request } from "express";
@ -19,30 +21,28 @@ function getUsers(req: Request): any {
} }
/** /**
* Get a specific user by ID * Get a specific user by ID (tmpID)
* Requires: Admin access or own user * Requires: Admin access or own user
*/ */
function getUser(req: Request): any { function getUser(req: Request): any {
const userId = req.params.userId; const tmpID = parseInt(req.params.userId);
const currentUserId = req.session.userId; const currentUserId = req.session.userId;
// Allow users to view their own profile, admins can view anyone
const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null; const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null;
if (!currentUser) { if (!currentUser) {
throw new ValidationError("Not authenticated"); throw new ValidationError("Not authenticated");
} }
if (userId !== currentUserId && !currentUser.isAdmin) { if (tmpID !== currentUserId && currentUserId && !userManagement.isAdmin(currentUserId)) {
throw new ValidationError("Access denied"); throw new ValidationError("Access denied");
} }
const user = userManagement.getUserById(userId); const user = userManagement.getUserById(tmpID);
if (!user) { if (!user) {
throw new ValidationError("User not found"); throw new ValidationError("User not found");
} }
// Don't send sensitive data const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
return safeUser; return safeUser;
} }
@ -51,32 +51,29 @@ function getUser(req: Request): any {
* Requires: Admin access * Requires: Admin access
*/ */
function createUser(req: Request): any { function createUser(req: Request): any {
const { username, email, password, isAdmin } = req.body; const { username, email, password, role } = req.body;
if (!username || !password) { if (!username || !password) {
throw new ValidationError("Username and password are required"); throw new ValidationError("Username and password are required");
} }
// Check if username already exists
const existing = userManagement.getUserByUsername(username); const existing = userManagement.getUserByUsername(username);
if (existing) { if (existing) {
throw new ValidationError("Username already exists"); throw new ValidationError("Username already exists");
} }
// Validate password strength if (password.length < 4) {
if (password.length < 8) { throw new ValidationError("Password must be at least 4 characters long");
throw new ValidationError("Password must be at least 8 characters long");
} }
const user = userManagement.createUser({ const user = userManagement.createUser({
username, username,
email, email,
password, password,
isAdmin: isAdmin === true role: role || userManagement.UserRole.USER
}); });
// Don't send sensitive data const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
return safeUser; return safeUser;
} }
@ -85,46 +82,42 @@ function createUser(req: Request): any {
* Requires: Admin access or own user (with limited fields) * Requires: Admin access or own user (with limited fields)
*/ */
function updateUser(req: Request): any { function updateUser(req: Request): any {
const userId = req.params.userId; const tmpID = parseInt(req.params.userId);
const currentUserId = req.session.userId; const currentUserId = req.session.userId;
const { email, password, isActive, isAdmin } = req.body; const { email, password, isActive, role } = req.body;
const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null; const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null;
if (!currentUser) { if (!currentUser) {
throw new ValidationError("Not authenticated"); throw new ValidationError("Not authenticated");
} }
const isSelf = userId === currentUserId; const isSelf = tmpID === currentUserId;
const isAdminUser = currentUser.isAdmin; const isAdminUser = currentUserId ? userManagement.isAdmin(currentUserId) : false;
// Regular users can only update their own email and password
if (!isAdminUser && !isSelf) { if (!isAdminUser && !isSelf) {
throw new ValidationError("Access denied"); throw new ValidationError("Access denied");
} }
// Only admins can change isActive and isAdmin flags if (!isAdminUser && (isActive !== undefined || role !== undefined)) {
if (!isAdminUser && (isActive !== undefined || isAdmin !== undefined)) { throw new ValidationError("Only admins can change user status or role");
throw new ValidationError("Only admins can change user status or admin privileges");
} }
// Validate password if provided if (password && password.length < 4) {
if (password && password.length < 8) { throw new ValidationError("Password must be at least 4 characters long");
throw new ValidationError("Password must be at least 8 characters long");
} }
const updates: any = {}; const updates: any = {};
if (email !== undefined) updates.email = email; if (email !== undefined) updates.email = email;
if (password !== undefined) updates.password = password; if (password !== undefined) updates.password = password;
if (isAdminUser && isActive !== undefined) updates.isActive = isActive; if (isAdminUser && isActive !== undefined) updates.isActive = isActive;
if (isAdminUser && isAdmin !== undefined) updates.isAdmin = isAdmin; if (isAdminUser && role !== undefined) updates.role = role;
const user = userManagement.updateUser(userId, updates); const user = userManagement.updateUser(tmpID, updates);
if (!user) { if (!user) {
throw new ValidationError("User not found"); throw new ValidationError("User not found");
} }
// Don't send sensitive data const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
return safeUser; return safeUser;
} }
@ -133,15 +126,14 @@ function updateUser(req: Request): any {
* Requires: Admin access * Requires: Admin access
*/ */
function deleteUser(req: Request): any { function deleteUser(req: Request): any {
const userId = req.params.userId; const tmpID = parseInt(req.params.userId);
const currentUserId = req.session.userId; const currentUserId = req.session.userId;
// Cannot delete yourself if (tmpID === currentUserId) {
if (userId === currentUserId) {
throw new ValidationError("Cannot delete your own account"); throw new ValidationError("Cannot delete your own account");
} }
const success = userManagement.deleteUser(userId); const success = userManagement.deleteUser(tmpID);
if (!success) { if (!success) {
throw new ValidationError("User not found"); throw new ValidationError("User not found");
} }
@ -163,14 +155,8 @@ function getCurrentUser(req: Request): any {
throw new ValidationError("User not found"); throw new ValidationError("User not found");
} }
const roles = userManagement.getUserRoles(userId); const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
return safeUser;
// Don't send sensitive data
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
return {
...safeUser,
roles
};
} }
/** /**

View File

@ -171,9 +171,9 @@ function login(req: Request, res: Response) {
// Store user information in session for multi-user mode // Store user information in session for multi-user mode
if (authenticatedUser) { if (authenticatedUser) {
req.session.userId = authenticatedUser.userId; req.session.userId = authenticatedUser.tmpID; // Store tmpID from user_data table
req.session.username = authenticatedUser.username; req.session.username = authenticatedUser.username;
req.session.isAdmin = authenticatedUser.isAdmin; req.session.isAdmin = authenticatedUser.role === 'admin';
} }
res.redirect('.'); res.redirect('.');
@ -197,12 +197,12 @@ function verifyPassword(submittedPassword: string) {
} }
/** /**
* Check if multi-user mode is enabled (users table exists) * Check if multi-user mode is enabled (user_data table has users)
*/ */
function isMultiUserEnabled(): boolean { function isMultiUserEnabled(): boolean {
try { try {
const result = sql.getValue(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='users'`) as number; const count = sql.getValue(`SELECT COUNT(*) as count FROM user_data WHERE isSetup = 'true'`) as number;
return result > 0; return count > 0;
} catch (e) { } catch (e) {
return false; return false;
} }

View File

@ -176,8 +176,7 @@ function checkAdmin(req: Request, res: Response, next: NextFunction) {
return; return;
} }
const user = userManagement.getUserById(req.session.userId); if (!userManagement.isAdmin(req.session.userId)) {
if (!user || !user.isAdmin) {
reject(req, res, "Admin access required"); reject(req, res, "Admin access required");
return; return;
} }
@ -187,6 +186,7 @@ function checkAdmin(req: Request, res: Response, next: NextFunction) {
/** /**
* Check if the current user has a specific permission * Check if the current user has a specific permission
* Note: Simplified for basic multi-user support
*/ */
function checkPermission(resource: string, action: string) { function checkPermission(resource: string, action: string) {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
@ -195,7 +195,8 @@ function checkPermission(resource: string, action: string) {
return; return;
} }
if (userManagement.hasPermission(req.session.userId, resource, action)) { // Basic permission check: admins have all permissions
if (userManagement.isAdmin(req.session.userId)) {
next(); next();
} else { } else {
reject(req, res, `Permission denied: ${resource}.${action}`); reject(req, res, `Permission denied: ${resource}.${action}`);

View File

@ -3,23 +3,47 @@
* *
* Handles all user-related operations including creation, updates, authentication, * Handles all user-related operations including creation, updates, authentication,
* and role management for multi-user support. * and role management for multi-user support.
*
* Works with existing user_data table (from OAuth migration v229):
* - tmpID: Primary key (INTEGER)
* - username: User's login name
* - email: Email address
* - userIDVerificationHash: Password hash for verification
* - salt: Salt for password hashing
* - derivedKey: Salt for deriving encryption key
* - userIDEncryptedDataKey: Encrypted data key
* - isSetup: 'true' or 'false' string
* - role: 'admin', 'user', or 'viewer'
* - isActive: 1 or 0
*/ */
import sql from "./sql.js"; import sql from "./sql.js";
import { randomSecureToken, toBase64 } from "./utils.js"; import { randomSecureToken, toBase64, fromBase64 } from "./utils.js";
import dataEncryptionService from "./encryption/data_encryption.js";
import crypto from "crypto"; import crypto from "crypto";
/**
* User roles with different permission levels
*/
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
VIEWER = 'viewer'
}
/**
* User interface representing a Trilium user in user_data table
*/
export interface User { export interface User {
userId: string; tmpID: number;
username: string; username: string;
email: string | null; email: string | null;
passwordHash: string; userIDVerificationHash: string;
passwordSalt: string; salt: string;
derivedKeySalt: string; derivedKey: string;
encryptedDataKey: string | null; userIDEncryptedDataKey: string | null;
isActive: boolean; isSetup: string;
isAdmin: boolean; role: UserRole;
isActive: number;
utcDateCreated: string; utcDateCreated: string;
utcDateModified: string; utcDateModified: string;
} }
@ -28,112 +52,97 @@ export interface UserCreateData {
username: string; username: string;
email?: string; email?: string;
password: string; password: string;
isAdmin?: boolean; role?: UserRole;
} }
export interface UserUpdateData { export interface UserUpdateData {
email?: string; email?: string;
password?: string; password?: string;
oldPassword?: string; // Required when changing password to decrypt existing data
isActive?: boolean; isActive?: boolean;
isAdmin?: boolean; role?: UserRole;
} }
export interface UserListItem { export interface UserListItem {
userId: string; tmpID: number;
username: string; username: string;
email: string | null; email: string | null;
isActive: boolean; isActive: number;
isAdmin: boolean; role: UserRole;
roles: string[];
utcDateCreated: string; utcDateCreated: string;
} }
/** /**
* Hash password using scrypt (synchronous) * Hash password using scrypt (matching Trilium's method)
*/ */
function hashPassword(password: string, salt: string): string { function hashPassword(password: string, salt: string): string {
const hashed = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); const hashed = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
return toBase64(hashed); return toBase64(hashed);
} }
/**
* Helper function to map database row to User object
*/
function mapRowToUser(user: any): User {
return {
tmpID: user.tmpID,
username: user.username,
email: user.email,
userIDVerificationHash: user.userIDVerificationHash,
salt: user.salt,
derivedKey: user.derivedKey,
userIDEncryptedDataKey: user.userIDEncryptedDataKey,
isSetup: user.isSetup || 'true',
role: user.role || UserRole.USER,
isActive: user.isActive !== undefined ? user.isActive : 1,
utcDateCreated: user.utcDateCreated || new Date().toISOString(),
utcDateModified: user.utcDateModified || new Date().toISOString()
};
}
/** /**
* Create a new user * Create a new user
*/ */
function createUser(userData: UserCreateData): User { function createUser(userData: UserCreateData): User {
const userId = 'user_' + randomSecureToken(20);
const now = new Date().toISOString(); const now = new Date().toISOString();
// Generate password salt and hash // Get next tmpID
const maxId = sql.getValue(`SELECT MAX(tmpID) as maxId FROM user_data`) as number || 0;
const tmpID = maxId + 1;
// Generate password components using Trilium's scrypt parameters
const passwordSalt = randomSecureToken(32); const passwordSalt = randomSecureToken(32);
const derivedKeySalt = randomSecureToken(32); const derivedKeySalt = randomSecureToken(32);
// Hash the password using scrypt
const passwordHash = hashPassword(userData.password, passwordSalt); const passwordHash = hashPassword(userData.password, passwordSalt);
// Generate data encryption key for this user
const dataKey = randomSecureToken(16);
// derive a binary key for encrypting the user's data key
const passwordDerivedKey = crypto.scryptSync(userData.password, derivedKeySalt, 32, { N: 16384, r: 8, p: 1 });
// dataEncryptionService.encrypt expects Buffer key and Buffer|string payload
const encryptedDataKey = dataEncryptionService.encrypt(passwordDerivedKey, Buffer.from(dataKey));
sql.execute(` sql.execute(`
INSERT INTO users ( INSERT INTO user_data (
userId, username, email, passwordHash, passwordSalt, tmpID, username, email, userIDVerificationHash, salt,
derivedKeySalt, encryptedDataKey, isActive, isAdmin, derivedKey, userIDEncryptedDataKey, isSetup, role,
utcDateCreated, utcDateModified isActive, utcDateCreated, utcDateModified
) )
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, '', 'true', ?, 1, ?, ?)
`, [ `, [
userId, tmpID,
userData.username, userData.username,
userData.email || null, userData.email || null,
passwordHash, passwordHash,
passwordSalt, passwordSalt,
derivedKeySalt, derivedKeySalt,
encryptedDataKey, userData.role || UserRole.USER,
userData.isAdmin ? 1 : 0,
now, now,
now now
]); ]);
// Assign default role return getUserById(tmpID)!;
const defaultRoleId = userData.isAdmin ? 'role_admin' : 'role_user';
sql.execute(`
INSERT INTO user_roles (userId, roleId, utcDateAssigned)
VALUES (?, ?, ?)
`, [userId, defaultRoleId, now]);
return getUserById(userId)!;
} }
/** /**
* Helper function to map database row to User object * Get user by ID (tmpID)
*/ */
function mapRowToUser(user: any): User { function getUserById(tmpID: number): User | null {
return {
userId: user.userId,
username: user.username,
email: user.email,
passwordHash: user.passwordHash,
passwordSalt: user.passwordSalt,
derivedKeySalt: user.derivedKeySalt,
encryptedDataKey: user.encryptedDataKey,
isActive: Boolean(user.isActive),
isAdmin: Boolean(user.isAdmin),
utcDateCreated: user.utcDateCreated,
utcDateModified: user.utcDateModified
};
}
/**
* Get user by ID
*/
function getUserById(userId: string): User | null {
const user = sql.getRow(` const user = sql.getRow(`
SELECT * FROM users WHERE userId = ? SELECT * FROM user_data WHERE tmpID = ?
`, [userId]) as any; `, [tmpID]) as any;
return user ? mapRowToUser(user) : null; return user ? mapRowToUser(user) : null;
} }
@ -143,7 +152,7 @@ function getUserById(userId: string): User | null {
*/ */
function getUserByUsername(username: string): User | null { function getUserByUsername(username: string): User | null {
const user = sql.getRow(` const user = sql.getRow(`
SELECT * FROM users WHERE username = ? COLLATE NOCASE SELECT * FROM user_data WHERE username = ? COLLATE NOCASE
`, [username]) as any; `, [username]) as any;
return user ? mapRowToUser(user) : null; return user ? mapRowToUser(user) : null;
@ -152,8 +161,8 @@ function getUserByUsername(username: string): User | null {
/** /**
* Update user * Update user
*/ */
function updateUser(userId: string, updates: UserUpdateData): User | null { function updateUser(tmpID: number, updates: UserUpdateData): User | null {
const user = getUserById(userId); const user = getUserById(tmpID);
if (!user) return null; if (!user) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -165,47 +174,14 @@ function updateUser(userId: string, updates: UserUpdateData): User | null {
values.push(updates.email || null); values.push(updates.email || null);
} }
if (updates.password !== undefined && updates.oldPassword !== undefined) { if (updates.password !== undefined) {
// Validate that user has existing encrypted data
if (!user.derivedKeySalt || !user.encryptedDataKey) {
throw new Error("Cannot change password: user has no encrypted data");
}
// First, decrypt the existing dataKey with the old password
const oldPasswordDerivedKey = crypto.scryptSync(
updates.oldPassword,
user.derivedKeySalt,
32,
{ N: 16384, r: 8, p: 1 }
);
const dataKey = dataEncryptionService.decrypt(
oldPasswordDerivedKey,
user.encryptedDataKey
);
if (!dataKey) {
throw new Error("Cannot change password: failed to decrypt existing data key with old password");
}
// Generate new password hash // Generate new password hash
const passwordSalt = randomSecureToken(32); const passwordSalt = randomSecureToken(32);
const derivedKeySalt = randomSecureToken(32); const derivedKeySalt = randomSecureToken(32);
const passwordHash = hashPassword(updates.password, passwordSalt); const passwordHash = hashPassword(updates.password, passwordSalt);
// Re-encrypt the same dataKey with new password updateParts.push('userIDVerificationHash = ?', 'salt = ?', 'derivedKey = ?');
const passwordDerivedKey = crypto.scryptSync( values.push(passwordHash, passwordSalt, derivedKeySalt);
updates.password,
derivedKeySalt,
32,
{ N: 16384, r: 8, p: 1 }
);
const encryptedDataKey = dataEncryptionService.encrypt(
passwordDerivedKey,
dataKey
);
updateParts.push('passwordHash = ?', 'passwordSalt = ?', 'derivedKeySalt = ?', 'encryptedDataKey = ?');
values.push(passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey);
} }
if (updates.isActive !== undefined) { if (updates.isActive !== undefined) {
@ -213,41 +189,37 @@ function updateUser(userId: string, updates: UserUpdateData): User | null {
values.push(updates.isActive ? 1 : 0); values.push(updates.isActive ? 1 : 0);
} }
if (updates.isAdmin !== undefined) { if (updates.role !== undefined) {
updateParts.push('isAdmin = ?'); updateParts.push('role = ?');
values.push(updates.isAdmin ? 1 : 0); values.push(updates.role);
// Update role assignment
sql.execute(`DELETE FROM user_roles WHERE userId = ?`, [userId]);
sql.execute(`
INSERT INTO user_roles (userId, roleId, utcDateAssigned)
VALUES (?, ?, ?)
`, [userId, updates.isAdmin ? 'role_admin' : 'role_user', now]);
} }
if (updateParts.length > 0) { if (updateParts.length > 0) {
updateParts.push('utcDateModified = ?'); updateParts.push('utcDateModified = ?');
values.push(now, userId); values.push(now, tmpID);
sql.execute(` sql.execute(`
UPDATE users SET ${updateParts.join(', ')} UPDATE user_data SET ${updateParts.join(', ')}
WHERE userId = ? WHERE tmpID = ?
`, values); `, values);
} }
return getUserById(userId); return getUserById(tmpID);
} }
/** /**
* Delete user (soft delete by setting isActive = 0) * Delete user (soft delete by setting isActive = 0)
*/ */
function deleteUser(userId: string): boolean { function deleteUser(tmpID: number): boolean {
const user = getUserById(userId); const user = getUserById(tmpID);
if (!user) return false; if (!user) return false;
// Prevent deleting the last admin // Prevent deleting the last admin
if (user.isAdmin) { if (user.role === UserRole.ADMIN) {
const adminCount = sql.getValue(`SELECT COUNT(*) FROM users WHERE isAdmin = 1 AND isActive = 1`) as number; const adminCount = sql.getValue(`
SELECT COUNT(*) FROM user_data
WHERE role = 'admin' AND isActive = 1
`) as number;
if (adminCount <= 1) { if (adminCount <= 1) {
throw new Error("Cannot delete the last admin user"); throw new Error("Cannot delete the last admin user");
} }
@ -255,9 +227,9 @@ function deleteUser(userId: string): boolean {
const now = new Date().toISOString(); const now = new Date().toISOString();
sql.execute(` sql.execute(`
UPDATE users SET isActive = 0, utcDateModified = ? UPDATE user_data SET isActive = 0, utcDateModified = ?
WHERE userId = ? WHERE tmpID = ?
`, [now, userId]); `, [now, tmpID]);
return true; return true;
} }
@ -266,32 +238,21 @@ function deleteUser(userId: string): boolean {
* List all users * List all users
*/ */
function listUsers(includeInactive: boolean = false): UserListItem[] { function listUsers(includeInactive: boolean = false): UserListItem[] {
const whereClause = includeInactive ? '' : 'WHERE u.isActive = 1'; const whereClause = includeInactive ? '' : 'WHERE isActive = 1';
const users = sql.getRows(` const users = sql.getRows(`
SELECT SELECT tmpID, username, email, isActive, role, utcDateCreated
u.userId, FROM user_data
u.username,
u.email,
u.isActive,
u.isAdmin,
u.utcDateCreated,
GROUP_CONCAT(r.name) as roles
FROM users u
LEFT JOIN user_roles ur ON u.userId = ur.userId
LEFT JOIN roles r ON ur.roleId = r.roleId
${whereClause} ${whereClause}
GROUP BY u.userId ORDER BY username
ORDER BY u.username
`); `);
return users.map((user: any) => ({ return users.map((user: any) => ({
userId: user.userId, tmpID: user.tmpID,
username: user.username, username: user.username,
email: user.email, email: user.email,
isActive: Boolean(user.isActive), isActive: user.isActive,
isAdmin: Boolean(user.isAdmin), role: user.role || UserRole.USER,
roles: user.roles ? user.roles.split(',') : [],
utcDateCreated: user.utcDateCreated utcDateCreated: user.utcDateCreated
})); }));
} }
@ -301,14 +262,14 @@ function listUsers(includeInactive: boolean = false): UserListItem[] {
*/ */
function validateCredentials(username: string, password: string): User | null { function validateCredentials(username: string, password: string): User | null {
const user = getUserByUsername(username); const user = getUserByUsername(username);
if (!user || !user.isActive) { if (!user || user.isActive !== 1) {
return null; return null;
} }
// Verify password using scrypt // Verify password using scrypt
const expectedHash = hashPassword(password, user.passwordSalt); const expectedHash = hashPassword(password, user.salt);
if (expectedHash !== user.passwordHash) { if (expectedHash !== user.userIDVerificationHash) {
return null; return null;
} }
@ -316,94 +277,43 @@ function validateCredentials(username: string, password: string): User | null {
} }
/** /**
* Get user's roles * Check if user is admin
*/ */
function getUserRoles(userId: string): string[] { function isAdmin(tmpID: number): boolean {
const roles = sql.getRows(` const user = getUserById(tmpID);
SELECT r.name return user?.role === UserRole.ADMIN;
FROM user_roles ur
JOIN roles r ON ur.roleId = r.roleId
WHERE ur.userId = ?
`, [userId]);
return roles.map((r: any) => r.name);
} }
/** /**
* Check if user has a specific permission * Check if user can access a note (basic ownership check)
*/ */
function hasPermission(userId: string, resource: string, action: string): boolean { function canAccessNote(tmpID: number, noteId: string): boolean {
const user = getUserById(userId); const user = getUserById(tmpID);
if (!user) return false;
// Admins have all permissions
if (user.isAdmin) return true;
const roles = sql.getRows(`
SELECT r.permissions
FROM user_roles ur
JOIN roles r ON ur.roleId = r.roleId
WHERE ur.userId = ?
`, [userId]);
for (const role of roles) {
try {
const permissions = JSON.parse((role as any).permissions);
if (permissions[resource] && permissions[resource].includes(action)) {
return true;
}
} catch (e) {
console.error('Error parsing role permissions:', e);
}
}
return false;
}
/**
* Check if user can access a note
*/
function canAccessNote(userId: string, noteId: string): boolean {
const user = getUserById(userId);
if (!user) return false; if (!user) return false;
// Admins can access all notes // Admins can access all notes
if (user.isAdmin) return true; if (user.role === UserRole.ADMIN) return true;
// Check if user owns the note // Check if user owns the note
const note = sql.getRow(`SELECT userId FROM notes WHERE noteId = ?`, [noteId]) as any; const note = sql.getRow(`SELECT userId FROM notes WHERE noteId = ?`, [noteId]) as any;
if (note && note.userId === userId) return true; return note && note.userId === tmpID;
// Check if note is shared with user
const share = sql.getRow(`
SELECT * FROM note_shares
WHERE noteId = ? AND sharedWithUserId = ? AND isDeleted = 0
`, [noteId, userId]);
return !!share;
} }
/** /**
* Get note permission for user (own, read, write, or null) * Get note permission for user (own, admin, or null)
*/ */
function getNotePermission(userId: string, noteId: string): string | null { function getNotePermission(tmpID: number, noteId: string): string | null {
const user = getUserById(userId); const user = getUserById(tmpID);
if (!user) return null; if (!user) return null;
// Admins have full access // Admins have full access
if (user.isAdmin) return 'admin'; if (user.role === UserRole.ADMIN) return 'admin';
// Check if user owns the note // Check if user owns the note
const note = sql.getRow(`SELECT userId FROM notes WHERE noteId = ?`, [noteId]) as any; const note = sql.getRow(`SELECT userId FROM notes WHERE noteId = ?`, [noteId]) as any;
if (note && note.userId === userId) return 'own'; if (note && note.userId === tmpID) return 'own';
// Check if note is shared with user return null;
const share = sql.getRow(`
SELECT permission FROM note_shares
WHERE noteId = ? AND sharedWithUserId = ? AND isDeleted = 0
`, [noteId, userId]) as any;
return share ? share.permission : null;
} }
export default { export default {
@ -414,8 +324,8 @@ export default {
deleteUser, deleteUser,
listUsers, listUsers,
validateCredentials, validateCredentials,
getUserRoles, isAdmin,
hasPermission,
canAccessNote, canAccessNote,
getNotePermission getNotePermission,
UserRole
}; };