Skip to content
Open
45 changes: 40 additions & 5 deletions apps/client/src/setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import "jquery";
import utils from "./services/utils.js";

import ko from "knockout";

import utils from "./services/utils.js";

// TriliumNextTODO: properly make use of below types
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
Expand All @@ -16,6 +18,8 @@ class SetupModel {
syncServerHost: ko.Observable<string | undefined>;
syncProxy: ko.Observable<string | undefined>;
password: ko.Observable<string | undefined>;
totpToken: ko.Observable<string | undefined>;
totpEnabled: ko.Observable<boolean>;

constructor(syncInProgress: boolean) {
this.syncInProgress = syncInProgress;
Expand All @@ -27,6 +31,8 @@ class SetupModel {
this.syncServerHost = ko.observable();
this.syncProxy = ko.observable();
this.password = ko.observable();
this.totpToken = ko.observable();
this.totpEnabled = ko.observable(false);

if (this.syncInProgress) {
setInterval(checkOutstandingSyncs, 1000);
Expand All @@ -40,7 +46,7 @@ class SetupModel {
return !!this.setupType();
}

selectSetupType() {
async selectSetupType() {
if (this.setupType() === "new-document") {
this.step("new-document-in-progress");

Expand All @@ -52,6 +58,24 @@ class SetupModel {
}
}

async checkTotpStatus() {
const syncServerHost = this.syncServerHost();
if (!syncServerHost) {
this.totpEnabled(false);
return;
}

try {
const resp = await $.post("api/setup/check-server-totp", {
syncServerHost
});
this.totpEnabled(!!resp.totpEnabled);
} catch {
// If we can't reach the server, don't show TOTP field yet
this.totpEnabled(false);
}
}

back() {
this.step("setup-type");
this.setupType("");
Expand All @@ -72,11 +96,22 @@ class SetupModel {
return;
}

// Check TOTP status before submitting (in case it wasn't checked yet)
await this.checkTotpStatus();

const totpToken = this.totpToken();

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: syncServerHost,
syncProxy: syncProxy,
password: password
syncServerHost,
syncProxy,
password,
totpToken
});

if (resp.result === "success") {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/assets/translations/cn/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
"password": "密码",
"password-placeholder": "密码",
"totp-token": "TOTP 验证码",
"totp-token-placeholder": "请输入 TOTP 验证码",
"back": "返回",
"finish-setup": "完成设置"
},
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/assets/translations/en/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/assets/translations/tw/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
"password": "密碼",
"password-placeholder": "密碼",
"totp-token": "TOTP 驗證碼",
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
"back": "返回",
"finish-setup": "完成設定"
},
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/assets/views/setup.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@

<div class="form-group">
<label for="sync-server-host"><%= t("setup_sync-from-server.server-host") %></label>
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost, event: { blur: checkTotpStatus }" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
</div>
<div class="form-group">
<label for="sync-proxy"><%= t("setup_sync-from-server.proxy-server") %></label>
Expand All @@ -141,6 +141,10 @@
<label for="password"><%= t("setup_sync-from-server.password") %></label>
<input type="password" id="password" class="form-control" data-bind="value: password" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
</div>
<div class="form-group" style="margin-bottom: 8px;" data-bind="visible: totpEnabled">
<label for="totpToken"><%= t("setup_sync-from-server.totp-token") %></label>
<input type="text" id="totpToken" class="form-control" data-bind="value: totpToken" placeholder="<%= t("setup_sync-from-server.totp-token-placeholder") %>" autocomplete="one-time-code">
</div>

<button type="button" data-bind="click: back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 28 additions & 9 deletions apps/server/src/routes/api/setup.ts
Original file line number Diff line number Diff line change
@@ -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()
};
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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
};
1 change: 1 addition & 0 deletions apps/server/src/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/services/api-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { OptionRow } from "@triliumnext/commons";
export interface SetupStatusResponse {
syncVersion: number;
schemaExists: boolean;
totpEnabled: boolean;
}

/**
Expand Down
40 changes: 30 additions & 10 deletions apps/server/src/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -161,9 +163,27 @@ 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 totpToken = req.headers["trilium-totp"] || "";
if (typeof totpToken !== "string" || !totpToken) {
res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required");
log.info(`WARNING: Missing 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 {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/services/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ async function exec<T>(opts: ExecOpts): Promise<T> {

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({
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/services/request_interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ExecOpts {
cookieJar?: CookieJar;
auth?: {
password?: string;
totpToken?: string;
};
timeout: number;
body?: string | {};
Expand Down
35 changes: 25 additions & 10 deletions apps/server/src/services/setup.ts
Original file line number Diff line number Diff line change
@@ -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<SetupStatusResponse>("GET", "/api/setup/status");
Expand Down Expand Up @@ -55,13 +55,13 @@ async function requestToSyncServer<T>(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",
Expand All @@ -76,7 +76,7 @@ async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string
const resp = await request.exec<SetupSyncSeedResponse>({
method: "get",
url: `${syncServerHost}/api/setup/sync-seed`,
auth: { password },
auth: { password, totpToken },
proxy: syncProxy,
timeout: 30000 // seed request should not take long
});
Expand Down Expand Up @@ -111,10 +111,25 @@ function getSyncSeedOptions() {
return [becca.getOption("documentId"), becca.getOption("documentSecret")];
}

async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> {
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
};