Somoru 883ca1ffc8 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.
2025-10-21 14:33:37 +05:30

257 lines
8.0 KiB
TypeScript

import utils from "../services/utils.js";
import optionService from "../services/options.js";
import myScryptService from "../services/encryption/my_scrypt.js";
import log from "../services/log.js";
import passwordService from "../services/encryption/password.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import appPath from "../services/app_path.js";
import ValidationError from "../errors/validation_error.js";
import type { Request, Response } from 'express';
import totp from '../services/totp.js';
import recoveryCodeService from '../services/encryption/recovery_codes.js';
import openID from '../services/open_id.js';
import openIDEncryption from '../services/encryption/open_id_encryption.js';
import { getCurrentLocale } from "../services/i18n.js";
import userManagement from "../services/user_management.js";
import sql from "../services/sql.js";
function loginPage(req: Request, res: Response) {
// Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed.
res.render('login', {
wrongPassword: false,
wrongTotp: false,
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled(),
ssoIssuerName: openID.getSSOIssuerName(),
ssoIssuerIcon: openID.getSSOIssuerIcon(),
assetPath: assetPath,
assetPathFragment: assetUrlFragment,
appPath: appPath,
currentLocale: getCurrentLocale()
});
}
function setPasswordPage(req: Request, res: Response) {
res.render("set_password", {
error: false,
assetPath,
appPath,
currentLocale: getCurrentLocale()
});
}
function setPassword(req: Request, res: Response) {
if (passwordService.isPasswordSet()) {
throw new ValidationError("Password has been already set");
}
let { password1, password2 } = req.body;
password1 = password1.trim();
password2 = password2.trim();
let error;
if (password1 !== password2) {
error = "Entered passwords don't match.";
} else if (password1.length < 4) {
error = "Password must be at least 4 characters long.";
}
if (error) {
res.render("set_password", {
error,
assetPath,
appPath,
currentLocale: getCurrentLocale()
});
return;
}
passwordService.setPassword(password1);
res.redirect("login");
}
/**
* @swagger
* /login:
* post:
* tags:
* - auth
* summary: Log in using password
* description: This will give you a Trilium session, which is required for some other API endpoints. `totpToken` is only required if the user configured TOTP authentication.
* operationId: login-normal
* externalDocs:
* description: HMAC calculation
* url: https://github.com/TriliumNext/Trilium/blob/v0.91.6/src/services/utils.ts#L62-L66
* requestBody:
* content:
* application/x-www-form-urlencoded:
* schema:
* type: object
* required:
* - password
* properties:
* password:
* type: string
* totpToken:
* type: string
* responses:
* '200':
* description: Successful operation
* '401':
* description: Password / TOTP mismatch
*/
function login(req: Request, res: Response) {
if (openID.isOpenIDEnabled()) {
res.oidc.login({
returnTo: '/',
authorizationParams: {
prompt: 'consent',
access_type: 'offline'
}
});
return;
}
const submittedPassword = req.body.password;
const submittedTotpToken = req.body.totpToken;
const submittedUsername = req.body.username; // New field for multi-user mode
if (totp.isTotpEnabled()) {
if (!verifyTOTP(submittedTotpToken)) {
sendLoginError(req, res, 'totp');
return;
}
}
// Check if multi-user mode is enabled
const multiUserMode = isMultiUserEnabled();
let authenticatedUser: any = null;
if (multiUserMode) {
if (submittedUsername) {
// Multi-user authentication when username is provided
authenticatedUser = verifyMultiUserCredentials(submittedUsername, submittedPassword);
if (!authenticatedUser) {
sendLoginError(req, res, 'credentials');
return;
}
} else {
// Backward-compatible fallback: allow legacy password-only login
if (!verifyPassword(submittedPassword)) {
sendLoginError(req, res, 'password');
return;
}
}
} else {
// Legacy single-user authentication
if (!verifyPassword(submittedPassword)) {
sendLoginError(req, res, 'password');
return;
}
}
const rememberMe = req.body.rememberMe;
req.session.regenerate(() => {
if (!rememberMe) {
// unset default maxAge set by sessionParser
// Cookie becomes non-persistent and expires
// after current browser session (e.g. when browser is closed)
req.session.cookie.maxAge = undefined;
}
req.session.lastAuthState = {
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled()
};
req.session.loggedIn = true;
// Store user information in session for multi-user mode
if (authenticatedUser) {
req.session.userId = authenticatedUser.tmpID; // Store tmpID from user_data table
req.session.username = authenticatedUser.username;
req.session.isAdmin = authenticatedUser.role === 'admin';
}
res.redirect('.');
});
}
function verifyTOTP(submittedTotpToken: string) {
if (totp.validateTOTP(submittedTotpToken)) return true;
const recoveryCodeValidates = recoveryCodeService.verifyRecoveryCode(submittedTotpToken);
return recoveryCodeValidates;
}
function verifyPassword(submittedPassword: string) {
const hashed_password = utils.fromBase64(optionService.getOption("passwordVerificationHash"));
const guess_hashed = myScryptService.getVerificationHash(submittedPassword);
return guess_hashed.equals(hashed_password);
}
/**
* Check if multi-user mode is enabled (user_data table has users)
*/
function isMultiUserEnabled(): boolean {
try {
const count = sql.getValue(`SELECT COUNT(*) as count FROM user_data WHERE isSetup = 'true'`) as number;
return count > 0;
} catch (e) {
return false;
}
}
/**
* Authenticate using multi-user credentials (username + password)
*/
function verifyMultiUserCredentials(username: string, password: string) {
return userManagement.validateCredentials(username, password);
}
function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' | 'credentials' = 'password') {
// note that logged IP address is usually meaningless since the traffic should come from a reverse proxy
if (totp.isTotpEnabled()) {
log.info(`WARNING: Wrong ${errorType} from ${req.ip}, rejecting.`);
} else {
log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`);
}
res.status(401).render('login', {
wrongPassword: errorType === 'password',
wrongTotp: errorType === 'totp',
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled(),
assetPath: assetPath,
assetPathFragment: assetUrlFragment,
appPath: appPath,
currentLocale: getCurrentLocale()
});
}
function logout(req: Request, res: Response) {
req.session.regenerate(() => {
req.session.loggedIn = false;
if (openID.isOpenIDEnabled() && openIDEncryption.isSubjectIdentifierSaved()) {
res.oidc.logout({ returnTo: '/' });
}
res.redirect('login');
});
}
export default {
loginPage,
setPasswordPage,
setPassword,
login,
logout
};