diff --git a/apps/client/src/setup.ts b/apps/client/src/setup.ts index 30232b85a33..933737f5dcb 100644 --- a/apps/client/src/setup.ts +++ b/apps/client/src/setup.ts @@ -17,6 +17,9 @@ class SetupController { private syncServerHostInput: HTMLInputElement; private syncProxyInput: HTMLInputElement; private passwordInput: HTMLInputElement; + private totpTokenInput: HTMLInputElement; + private totpSection: HTMLElement; + private totpEnabled = false; private sections: Record; constructor(rootNode: HTMLElement, syncInProgress: boolean) { @@ -29,6 +32,8 @@ class SetupController { this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement); this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement); this.passwordInput = mustGetElement("password", HTMLInputElement); + this.totpTokenInput = mustGetElement("totp-token", HTMLInputElement); + this.totpSection = mustGetElement("totp-section", HTMLElement); this.sections = { "setup-type": mustGetElement("setup-type-section", HTMLElement), "new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement), @@ -56,6 +61,10 @@ class SetupController { }); } + this.syncServerHostInput.addEventListener("blur", () => { + void this.checkTotpStatus(); + }); + for (const backButton of document.querySelectorAll("[data-action='back']")) { backButton.addEventListener("click", () => { this.back(); @@ -87,9 +96,40 @@ class SetupController { } } + private async checkTotpStatus() { + const syncServerHost = this.syncServerHostInput.value.trim(); + + if (!syncServerHost) { + this.setTotpEnabled(false); + return; + } + + try { + const resp = await $.post("api/setup/check-server-totp", { + syncServerHost + }); + + this.setTotpEnabled(!!resp.totpEnabled); + } catch { + // If we can't reach the server, don't show the TOTP field yet. + this.setTotpEnabled(false); + } + } + + private setTotpEnabled(enabled: boolean) { + this.totpEnabled = enabled; + + if (!enabled) { + this.totpTokenInput.value = ""; + } + + this.render(); + } + private back() { this.setStep("setup-type"); this.setupType = ""; + this.setTotpEnabled(false); for (const input of this.setupTypeInputs) { input.checked = false; @@ -113,11 +153,21 @@ class SetupController { return; } + await this.checkTotpStatus(); + + const totpToken = this.totpTokenInput.value.trim(); + + if (this.totpEnabled && !totpToken) { + showAlert("TOTP token can't be empty when two-factor authentication is enabled"); + return; + } + // not using server.js because it loads too many dependencies const resp = await $.post("api/setup/sync-from-server", { syncServerHost, syncProxy, - password + password, + totpToken }); if (resp.result === "success") { @@ -139,13 +189,10 @@ class SetupController { section.style.display = step === this.step ? "" : "none"; } + this.totpSection.style.display = this.totpEnabled ? "" : "none"; this.setupTypeNextButton.disabled = !this.setupType; } - private getSelectedSetupType(): SetupType { - return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType; - } - private startSyncPolling() { if (this.syncPollIntervalId !== null) { return; @@ -196,7 +243,7 @@ function mustGetElement(id: string, ctor: T): Inst return element as InstanceType; } -addEventListener("DOMContentLoaded", (event) => { +addEventListener("DOMContentLoaded", () => { const rootNode = document.getElementById("setup-dialog"); if (!rootNode || !(rootNode instanceof HTMLElement)) return; diff --git a/apps/server/src/assets/translations/cn/server.json b/apps/server/src/assets/translations/cn/server.json index ec5f2efd56a..35f6ba03835 100644 --- a/apps/server/src/assets/translations/cn/server.json +++ b/apps/server/src/assets/translations/cn/server.json @@ -155,6 +155,8 @@ "proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)", "password": "密码", "password-placeholder": "密码", + "totp-token": "TOTP 验证码", + "totp-token-placeholder": "请输入 TOTP 验证码", "back": "返回", "finish-setup": "完成设置" }, diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 56aa2697bfd..4be4f27df25 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -252,6 +252,8 @@ "proxy-instruction": "If you leave proxy setting blank, system proxy will be used (applies to the desktop application only)", "password": "Password", "password-placeholder": "Password", + "totp-token": "TOTP Token", + "totp-token-placeholder": "Enter your TOTP code", "back": "Back", "finish-setup": "Finish setup" }, diff --git a/apps/server/src/assets/translations/tw/server.json b/apps/server/src/assets/translations/tw/server.json index d40cb8eaff0..874efb3d87d 100644 --- a/apps/server/src/assets/translations/tw/server.json +++ b/apps/server/src/assets/translations/tw/server.json @@ -155,6 +155,8 @@ "proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)", "password": "密碼", "password-placeholder": "密碼", + "totp-token": "TOTP 驗證碼", + "totp-token-placeholder": "請輸入 TOTP 驗證碼", "back": "返回", "finish-setup": "完成設定" }, diff --git a/apps/server/src/assets/views/setup.ejs b/apps/server/src/assets/views/setup.ejs index 79e41b4672c..8a0f4cbc0d8 100644 --- a/apps/server/src/assets/views/setup.ejs +++ b/apps/server/src/assets/views/setup.ejs @@ -141,6 +141,10 @@ "> + diff --git a/apps/server/src/express.d.ts b/apps/server/src/express.d.ts index 5b9673d693c..9bbcb294d1a 100644 --- a/apps/server/src/express.d.ts +++ b/apps/server/src/express.d.ts @@ -8,6 +8,7 @@ export declare module "express-serve-static-core" { authorization?: string; "trilium-cred"?: string; + "trilium-totp"?: string; "x-csrf-token"?: string; "trilium-component-id"?: string; diff --git a/apps/server/src/routes/api/setup.ts b/apps/server/src/routes/api/setup.ts index 718bc37a75c..7716d9a8ffa 100644 --- a/apps/server/src/routes/api/setup.ts +++ b/apps/server/src/routes/api/setup.ts @@ -1,16 +1,19 @@ -"use strict"; -import sqlInit from "../../services/sql_init.js"; -import setupService from "../../services/setup.js"; -import log from "../../services/log.js"; -import appInfo from "../../services/app_info.js"; + import type { Request } from "express"; +import appInfo from "../../services/app_info.js"; +import log from "../../services/log.js"; +import setupService from "../../services/setup.js"; +import sqlInit from "../../services/sql_init.js"; +import totp from "../../services/totp.js"; + function getStatus() { return { isInitialized: sqlInit.isDbInitialized(), schemaExists: sqlInit.schemaExists(), - syncVersion: appInfo.syncVersion + syncVersion: appInfo.syncVersion, + totpEnabled: totp.isTotpEnabled() }; } @@ -19,9 +22,9 @@ async function setupNewDocument() { } function setupSyncFromServer(req: Request) { - const { syncServerHost, syncProxy, password } = req.body; + const { syncServerHost, syncProxy, password, totpToken } = req.body; - return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password); + return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken); } function saveSyncSeed(req: Request) { @@ -82,10 +85,26 @@ function getSyncSeed() { }; } +async function checkServerTotpStatus(req: Request) { + const { syncServerHost } = req.body; + + if (!syncServerHost) { + return { totpEnabled: false }; + } + + try { + const resp = await setupService.checkRemoteTotpStatus(syncServerHost); + return { totpEnabled: !!resp.totpEnabled }; + } catch { + return { totpEnabled: false }; + } +} + export default { getStatus, setupNewDocument, setupSyncFromServer, getSyncSeed, - saveSyncSeed + saveSyncSeed, + checkServerTotpStatus }; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index ce9b84f0a9b..f83ffc01b4f 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -244,6 +244,7 @@ function register(app: express.Application) { asyncRoute(PST, "/api/setup/sync-from-server", [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler); route(GET, "/api/setup/sync-seed", [loginRateLimiter, auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler); asyncRoute(PST, "/api/setup/sync-seed", [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler); + asyncRoute(PST, "/api/setup/check-server-totp", [auth.checkAppNotInitialized], setupApiRoute.checkServerTotpStatus, apiResultHandler); apiRoute(GET, "/api/autocomplete", autocompleteApiRoute.getAutocomplete); apiRoute(GET, "/api/autocomplete/notesCount", autocompleteApiRoute.getNotesCount); diff --git a/apps/server/src/services/api-interface.ts b/apps/server/src/services/api-interface.ts index 8d837c3b50a..7f2c6b04944 100644 --- a/apps/server/src/services/api-interface.ts +++ b/apps/server/src/services/api-interface.ts @@ -6,6 +6,7 @@ import type { OptionRow } from "@triliumnext/commons"; export interface SetupStatusResponse { syncVersion: number; schemaExists: boolean; + totpEnabled: boolean; } /** diff --git a/apps/server/src/services/auth.spec.ts b/apps/server/src/services/auth.spec.ts index f0446dfe149..c46688c58de 100644 --- a/apps/server/src/services/auth.spec.ts +++ b/apps/server/src/services/auth.spec.ts @@ -9,6 +9,10 @@ import options from "./options"; let app: Application; +function encodeCred(password: string): string { + return Buffer.from(`dummy:${password}`).toString("base64"); +} + describe("Auth", () => { beforeAll(async () => { const buildApp = (await (import("../../src/app.js"))).default; @@ -72,4 +76,49 @@ describe("Auth", () => { .expect(200); }); }); + + describe("Setup status endpoint", () => { + it("returns totpEnabled: true when TOTP is enabled", async () => { + cls.init(() => { + options.setOption("mfaEnabled", "true"); + options.setOption("mfaMethod", "totp"); + options.setOption("totpVerificationHash", "hi"); + }); + const response = await supertest(app) + .get("/api/setup/status") + .expect(200); + expect(response.body.totpEnabled).toBe(true); + }); + + it("returns totpEnabled: false when TOTP is disabled", async () => { + cls.init(() => { + options.setOption("mfaEnabled", "false"); + }); + const response = await supertest(app) + .get("/api/setup/status") + .expect(200); + expect(response.body.totpEnabled).toBe(false); + }); + }); + + describe("checkCredentials TOTP enforcement", () => { + beforeAll(() => { + config.General.noAuthentication = false; + refreshAuth(); + }); + + it("does not require TOTP token when TOTP is disabled", async () => { + cls.init(() => { + options.setOption("mfaEnabled", "false"); + }); + // Will still fail with 401 due to wrong password, but NOT because of missing TOTP + const response = await supertest(app) + .get("/api/setup/sync-seed") + .set("trilium-cred", encodeCred("wrongpassword")) + .expect(401); + // The error should be about password, not TOTP + expect(response.text).toContain("Incorrect password"); + }); + }); }, 60_000); + diff --git a/apps/server/src/services/auth.ts b/apps/server/src/services/auth.ts index b10ef80977b..9b9a6e17d33 100644 --- a/apps/server/src/services/auth.ts +++ b/apps/server/src/services/auth.ts @@ -1,15 +1,17 @@ -import etapiTokenService from "./etapi_tokens.js"; -import log from "./log.js"; -import sqlInit from "./sql_init.js"; -import { isElectron } from "./utils.js"; -import passwordEncryptionService from "./encryption/password_encryption.js"; +import type { NextFunction, Request, Response } from "express"; + +import attributes from "./attributes.js"; import config from "./config.js"; import passwordService from "./encryption/password.js"; -import totp from "./totp.js"; +import passwordEncryptionService from "./encryption/password_encryption.js"; +import recoveryCodeService from "./encryption/recovery_codes.js"; +import etapiTokenService from "./etapi_tokens.js"; +import log from "./log.js"; import openID from "./open_id.js"; import options from "./options.js"; -import attributes from "./attributes.js"; -import type { NextFunction, Request, Response } from "express"; +import sqlInit from "./sql_init.js"; +import totp from "./totp.js"; +import { isElectron } from "./utils.js"; let noAuthentication = false; refreshAuth(); @@ -161,9 +163,28 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) { if (!passwordEncryptionService.verifyPassword(password)) { res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect password"); log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`); - } else { - next(); + return; + } + + // Verify TOTP if enabled + if (totp.isTotpEnabled()) { + const totpHeader = req.headers["trilium-totp"]; + const totpToken = Array.isArray(totpHeader) ? totpHeader[0] : totpHeader; + if (typeof totpToken !== "string" || !totpToken) { + res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required"); + log.info(`WARNING: Missing or invalid TOTP token from ${req.ip}, rejecting.`); + return; + } + + // Accept TOTP code or recovery code + if (!totp.validateTOTP(totpToken) && !recoveryCodeService.verifyRecoveryCode(totpToken)) { + res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect TOTP token"); + log.info(`WARNING: Wrong TOTP token from ${req.ip}, rejecting.`); + return; + } } + + next(); } export default { diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts index 87f4cfef188..4121e3a04f8 100644 --- a/apps/server/src/services/encryption/totp_encryption.ts +++ b/apps/server/src/services/encryption/totp_encryption.ts @@ -1,8 +1,9 @@ +import type { OptionNames } from "@triliumnext/commons"; + import optionService from "../options.js"; -import myScryptService from "./my_scrypt.js"; -import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js"; +import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js"; import dataEncryptionService from "./data_encryption.js"; -import type { OptionNames } from "@triliumnext/commons"; +import myScryptService from "./my_scrypt.js"; const TOTP_OPTIONS: Record = { SALT: "totpEncryptionSalt", diff --git a/apps/server/src/services/request.ts b/apps/server/src/services/request.ts index 688c617531a..2ff5f2616ff 100644 --- a/apps/server/src/services/request.ts +++ b/apps/server/src/services/request.ts @@ -62,6 +62,9 @@ async function exec(opts: ExecOpts): Promise { if (opts.auth) { headers["trilium-cred"] = Buffer.from(`dummy:${opts.auth.password}`).toString("base64"); + if (opts.auth.totpToken) { + headers["trilium-totp"] = opts.auth.totpToken; + } } const request = (await client).request({ diff --git a/apps/server/src/services/request_interface.ts b/apps/server/src/services/request_interface.ts index 8ecd46bdb17..67eb37d8afa 100644 --- a/apps/server/src/services/request_interface.ts +++ b/apps/server/src/services/request_interface.ts @@ -14,6 +14,7 @@ export interface ExecOpts { cookieJar?: CookieJar; auth?: { password?: string; + totpToken?: string; }; timeout: number; body?: string | {}; diff --git a/apps/server/src/services/setup.ts b/apps/server/src/services/setup.ts index a20fb670f93..d176744ff68 100644 --- a/apps/server/src/services/setup.ts +++ b/apps/server/src/services/setup.ts @@ -1,13 +1,13 @@ -import syncService from "./sync.js"; +import becca from "../becca/becca.js"; +import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js"; +import appInfo from "./app_info.js"; import log from "./log.js"; -import sqlInit from "./sql_init.js"; import optionService from "./options.js"; -import syncOptions from "./sync_options.js"; import request from "./request.js"; -import appInfo from "./app_info.js"; +import sqlInit from "./sql_init.js"; +import syncService from "./sync.js"; +import syncOptions from "./sync_options.js"; import { timeLimit } from "./utils.js"; -import becca from "../becca/becca.js"; -import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js"; async function hasSyncServerSchemaAndSeed() { const response = await requestToSyncServer("GET", "/api/setup/status"); @@ -55,13 +55,13 @@ async function requestToSyncServer(method: string, path: string, body?: strin url: syncOptions.getSyncServerHost() + path, body, proxy: syncOptions.getSyncProxy(), - timeout: timeout + timeout }), timeout )) as T; } -async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) { +async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string, totpToken?: string) { if (sqlInit.isDbInitialized()) { return { result: "failure", @@ -76,7 +76,7 @@ async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string const resp = await request.exec({ method: "get", url: `${syncServerHost}/api/setup/sync-seed`, - auth: { password }, + auth: { password, totpToken }, proxy: syncProxy, timeout: 30000 // seed request should not take long }); @@ -111,10 +111,30 @@ function getSyncSeedOptions() { return [becca.getOption("documentId"), becca.getOption("documentSecret")]; } +async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> { + // Validate URL scheme to mitigate SSRF + if (!syncServerHost.startsWith("http://") && !syncServerHost.startsWith("https://")) { + return { totpEnabled: false }; + } + + try { + const resp = await request.exec<{ totpEnabled?: boolean }>({ + method: "get", + url: `${syncServerHost}/api/setup/status`, + proxy: null, + timeout: 10000 + }); + return { totpEnabled: !!resp?.totpEnabled }; + } catch { + return { totpEnabled: false }; + } +} + export default { hasSyncServerSchemaAndSeed, triggerSync, sendSeedToSyncServer, setupSyncFromSyncServer, - getSyncSeedOptions + getSyncSeedOptions, + checkRemoteTotpStatus }; diff --git a/apps/server/src/services/totp.ts b/apps/server/src/services/totp.ts index cabeaae1576..49feddcc797 100644 --- a/apps/server/src/services/totp.ts +++ b/apps/server/src/services/totp.ts @@ -1,6 +1,7 @@ -import { Totp, generateSecret } from 'time2fa'; -import options from './options.js'; +import { generateSecret,Totp } from 'time2fa'; + import totpEncryptionService from './encryption/totp_encryption.js'; +import options from './options.js'; function isTotpEnabled(): boolean { return options.getOptionOrNull('mfaEnabled') === "true" && @@ -10,7 +11,7 @@ function isTotpEnabled(): boolean { function createSecret(): { success: boolean; message?: string } { try { - const secret = generateSecret(); + const secret = generateSecret(20); totpEncryptionService.setTotpSecret(secret); @@ -43,6 +44,8 @@ function validateTOTP(submittedPasscode: string): boolean { return Totp.validate({ passcode: submittedPasscode, secret: secret.trim() + }, { + secretSize: secret.trim().length === 32 ? 20 : 10 }); } catch (e) { console.error('Failed to validate TOTP:', e);