Merge pull request #401 from TriliumNext/feature/MFA

Feature addition: Multi-Factor Authentication
This commit is contained in:
Elian Doran 2025-03-29 13:06:00 +02:00 committed by GitHub
commit 7be71fc6fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1638 additions and 1332 deletions

2
.gitignore vendored
View File

@ -42,4 +42,4 @@ data-docs/backup
data-docs/log
data-docs/session
data-docs/session_secret.txt
data-docs/document.*
data-docs/document.*

View File

@ -37,6 +37,7 @@ Feel free to join our official conversations. We would love to hear what feature
* Fast and easy [navigation between notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text search and [note hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
* Direct OpenID and TOTP integration for more secure login
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet

View File

@ -43,4 +43,17 @@ cookieMaxAge=1814400
[Sync]
#syncServerHost=
#syncServerTimeout=
#syncServerProxy=
#syncServerProxy=
[MultiFactorAuthentication]
# Set the base URL for OAuth/OpenID authentication
# This is the URL of the service that will be used to verify the user's identity
oauthBaseUrl=
# Set the client ID for OAuth/OpenID authentication
# This is the ID of the client that will be used to verify the user's identity
oauthClientId=
# Set the client secret for OAuth/OpenID authentication
# This is the secret of the client that will be used to verify the user's identity
oauthClientSecret=

View File

@ -0,0 +1,14 @@
-- Add the oauth user data table
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);

View File

@ -126,6 +126,19 @@ CREATE TABLE IF NOT EXISTS "attachments"
utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null,
deleteId TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);
CREATE INDEX IDX_attachments_ownerId_role
on attachments (ownerId, role);

7
images/google-logo.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285f4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34a853"/>
<path d="M3.964 10.71c-.18-.54-.282-1.117-.282-1.71s.102-1.17.282-1.71V4.958H.957C.347 6.173 0 7.548 0 9s.348 2.827.957 4.042l3.007-2.332z" fill="#fbbc05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#ea4335"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

1490
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,7 @@
"escape-html": "1.0.3",
"eslint-linter-browserify": "9.23.0",
"express": "4.21.2",
"express-openid-connect": "^2.17.1",
"express-rate-limit": "7.5.0",
"express-session": "1.18.1",
"force-graph": "1.49.5",
@ -143,6 +144,7 @@
"strip-bom": "5.0.0",
"striptags": "3.2.0",
"swagger-ui-express": "5.0.1",
"time2fa": "^1.3.0",
"tmp": "0.2.3",
"turndown": "7.2.0",
"unescape": "1.0.1",

View File

@ -14,6 +14,8 @@ import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import { startScheduledCleanup } from "./services/erase.js";
import sql_init from "./services/sql_init.js";
import { auth } from "express-openid-connect";
import openID from "./services/open_id.js";
import { t } from "i18next";
await import("./services/handlers.js");
@ -59,6 +61,9 @@ app.use(`/icon.png`, express.static(path.join(scriptDir, "public/icon.png")));
app.use(sessionParser);
app.use(favicon(`${scriptDir}/../images/app-icons/icon.ico`));
if (openID.isOpenIDEnabled())
app.use(auth(openID.generateOAuthConfig()));
await assets.register(app);
routes.register(app);
custom.register(app);

View File

@ -0,0 +1,9 @@
class OpenIdError {
message: string;
constructor(message: string) {
this.message = message;
}
}
export default OpenIdError;

4
src/express.d.ts vendored
View File

@ -4,6 +4,10 @@ export declare module "express-serve-static-core" {
interface Request {
session: Session & {
loggedIn: boolean;
lastAuthState: {
totpEnabled: boolean;
ssoEnabled: boolean;
};
};
headers: {
"x-local-date"?: string;

View File

@ -36,7 +36,7 @@ export default class BackendLogWidget extends AbstractCodeTypeWidget {
await this.load();
}
getExtraOpts() {
getExtraOpts(): Partial<CodeMirrorOpts> {
return {
lineWrapping: false,
readOnly: true

View File

@ -32,6 +32,7 @@ import DatabaseAnonymizationOptions from "./options/advanced/database_anonymizat
import BackendLogWidget from "./content/backend_log.js";
import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js";
import RibbonOptions from "./options/appearance/ribbon.js";
import MultiFactorAuthenticationOptions from './options/multi_factor_authentication.js';
import LocalizationOptions from "./options/i18n/i18n.js";
import CodeBlockOptions from "./options/appearance/code_block.js";
import EditorOptions from "./options/text_notes/editor.js";
@ -94,6 +95,7 @@ const CONTENT_WIDGETS: Record<string, (typeof NoteContextAwareWidget)[]> = {
PasswordOptions,
ProtectedSessionTimeoutOptions
],
_optionsMFA: [MultiFactorAuthenticationOptions],
_optionsEtapi: [
EtapiOptions
],

View File

@ -0,0 +1,342 @@
import server from "../../../services/server.js";
import toastService from "../../../services/toast.js";
import OptionsWidget from "./options_widget.js";
import type { OptionMap } from "../../../../../services/options_interface.js";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import dialogService from "../../../services/dialog.js";
const TPL = `
<div class="options-section">
<h4>${t("multi_factor_authentication.title")}</h4>
<p class="form-text">${t("multi_factor_authentication.description")}</p>
<div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox">
<input type="checkbox" class="mfa-enabled-checkbox form-check-input" />
${t("multi_factor_authentication.mfa_enabled")}
</label>
</div>
<hr />
<div class="mfa-options" style="display: none;">
<label class="form-label"><b>${t("multi_factor_authentication.mfa_method")}</b></label>
<div role="group">
<label class="tn-radio">
<input class="mfa-method-radio" type="radio" name="mfaMethod" value="totp" />
<b>${t("multi_factor_authentication.totp_title")}</b>
</label>
<label class="tn-radio">
<input class="mfa-method-radio" type="radio" name="mfaMethod" value="oauth" />
<b>${t("multi_factor_authentication.oauth_title")}</b>
</label>
</div>
<div class="totp-options" style="display: none;">
<p class="form-text">${t("multi_factor_authentication.totp_description")}</p>
<hr />
<h5>${t("multi_factor_authentication.totp_secret_title")}</h5>
<div class="admonition note no-totp-secret" role="alert">
${t("multi_factor_authentication.no_totp_secret_warning")}
</div>
<div class="admonition warning" role="alert">
${t("multi_factor_authentication.totp_secret_description_warning")}
</div>
<button class="generate-totp btn btn-primary">
${t("multi_factor_authentication.totp_secret_generate")}
</button>
<hr />
<h5>${t("multi_factor_authentication.recovery_keys_title")}</h5>
<p class="form-text">${t("multi_factor_authentication.recovery_keys_description")}</p>
<div class="admonition caution">
${t("multi_factor_authentication.recovery_keys_description_warning")}
</div>
<table style="border: 0px solid white">
<tbody>
<tr>
<td class="key_0"></td>
<td style="width: 20px" />
<td class="key_1"></td>
</tr>
<tr>
<td class="key_2"></td>
<td />
<td class="key_3"></td>
</tr>
<tr>
<td class="key_4"></td>
<td />
<td class="key_5"></td>
</tr>
<tr>
<td class="key_6"></td>
<td />
<td class="key_7"></td>
</tr>
</tbody>
</table>
<br>
<button class="generate-recovery-code btn btn-primary"> ${t("multi_factor_authentication.recovery_keys_generate")} </button>
</div>
<div class="oauth-options" style="display: none;">
<p class="form-text">${t("multi_factor_authentication.oauth_description")}</p>
<div class="admonition note oauth-warning" role="alert">
${t("multi_factor_authentication.oauth_description_warning")}
</div>
<div class="admonition caution missing-vars" role="alert" style="display: none;"></div>
<hr />
<div class="col-md-6">
<span><b>${t("multi_factor_authentication.oauth_user_account")}</b></span><span class="user-account-name">${t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
<br>
<span><b>${t("multi_factor_authentication.oauth_user_email")}</b></span><span class="user-account-email">${t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
</div>
</div>
</div>
</div>
`;
const TPL_ELECTRON = `
<div class="options-section">
<h4>${t("multi_factor_authentication.title")}</h4>
<p class="form-text">${t("multi_factor_authentication.electron_disabled")}</p>
</div>
`;
interface OAuthStatus {
enabled: boolean;
name?: string;
email?: string;
missingVars?: string[];
}
interface TOTPStatus {
set: boolean;
}
interface RecoveryKeysResponse {
success: boolean;
recoveryCodes?: string[];
keysExist?: boolean;
usedRecoveryCodes?: string[];
}
export default class MultiFactorAuthenticationOptions extends OptionsWidget {
private $mfaEnabledCheckbox!: JQuery<HTMLElement>;
private $mfaOptions!: JQuery<HTMLElement>;
private $mfaMethodRadios!: JQuery<HTMLElement>;
private $totpOptions!: JQuery<HTMLElement>;
private $noTotpSecretWarning!: JQuery<HTMLElement>;
private $generateTotpButton!: JQuery<HTMLElement>;
private $generateRecoveryCodeButton!: JQuery<HTMLElement>;
private $recoveryKeys: JQuery<HTMLElement>[] = [];
private $oauthOptions!: JQuery<HTMLElement>;
private $UserAccountName!: JQuery<HTMLElement>;
private $UserAccountEmail!: JQuery<HTMLElement>;
private $oauthWarning!: JQuery<HTMLElement>;
private $missingVars!: JQuery<HTMLElement>;
doRender() {
const template = utils.isElectron() ? TPL_ELECTRON : TPL;
this.$widget = $(template);
if (!utils.isElectron()) {
this.$mfaEnabledCheckbox = this.$widget.find(".mfa-enabled-checkbox");
this.$mfaOptions = this.$widget.find(".mfa-options");
this.$mfaMethodRadios = this.$widget.find(".mfa-method-radio");
this.$totpOptions = this.$widget.find(".totp-options");
this.$noTotpSecretWarning = this.$widget.find(".no-totp-secret");
this.$generateTotpButton = this.$widget.find(".generate-totp");
this.$generateRecoveryCodeButton = this.$widget.find(".generate-recovery-code");
this.$oauthOptions = this.$widget.find(".oauth-options");
this.$UserAccountName = this.$widget.find(".user-account-name");
this.$UserAccountEmail = this.$widget.find(".user-account-email");
this.$oauthWarning = this.$widget.find(".oauth-warning");
this.$missingVars = this.$widget.find(".missing-vars");
this.$recoveryKeys = [];
for (let i = 0; i < 8; i++) {
this.$recoveryKeys.push(this.$widget.find(".key_" + i));
}
this.$generateRecoveryCodeButton.on("click", async () => {
await this.setRecoveryKeys();
});
this.$generateTotpButton.on("click", async () => {
await this.generateKey();
});
this.displayRecoveryKeys();
this.$mfaEnabledCheckbox.on("change", () => {
const isChecked = this.$mfaEnabledCheckbox.is(":checked");
this.$mfaOptions.toggle(isChecked);
if (!isChecked) {
this.$totpOptions.hide();
this.$oauthOptions.hide();
} else {
this.$mfaMethodRadios.filter('[value="totp"]').prop("checked", true);
this.$totpOptions.show();
this.$oauthOptions.hide();
}
this.updateCheckboxOption("mfaEnabled", this.$mfaEnabledCheckbox);
});
this.$mfaMethodRadios.on("change", () => {
const selectedMethod = this.$mfaMethodRadios.filter(":checked").val();
this.$totpOptions.toggle(selectedMethod === "totp");
this.$oauthOptions.toggle(selectedMethod === "oauth");
this.updateOption("mfaMethod", selectedMethod);
});
}
}
async setRecoveryKeys() {
const result = await server.get<RecoveryKeysResponse>("totp_recovery/generate");
if (!result.success) {
toastService.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (result.recoveryCodes) {
this.keyFiller(result.recoveryCodes);
await server.post("totp_recovery/set", {
recoveryCodes: result.recoveryCodes,
});
}
}
async displayRecoveryKeys() {
const result = await server.get<RecoveryKeysResponse>("totp_recovery/enabled");
if (!result.success) {
this.fillKeys(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (!result.keysExist) {
this.fillKeys(t("multi_factor_authentication.recovery_keys_no_key_set"));
this.$generateRecoveryCodeButton.text(t("multi_factor_authentication.recovery_keys_generate"));
return;
}
const usedResult = await server.get<RecoveryKeysResponse>("totp_recovery/used");
if (usedResult.usedRecoveryCodes) {
this.keyFiller(usedResult.usedRecoveryCodes);
this.$generateRecoveryCodeButton.text(t("multi_factor_authentication.recovery_keys_regenerate"));
} else {
this.fillKeys(t("multi_factor_authentication.recovery_keys_no_key_set"));
}
}
private keyFiller(values: string[]) {
this.fillKeys("");
values.forEach((key, index) => {
if (typeof key === 'string') {
const date = new Date(key.replace(/\//g, '-'));
if (isNaN(date.getTime())) {
this.$recoveryKeys[index].text(key);
} else {
this.$recoveryKeys[index].text(t("multi_factor_authentication.recovery_keys_used", { date: key.replace(/\//g, '-') }));
}
} else {
this.$recoveryKeys[index].text(t("multi_factor_authentication.recovery_keys_unused", { index: key }));
}
});
}
private fillKeys(message: string) {
for (let i = 0; i < 8; i++) {
this.$recoveryKeys[i].text(message);
}
}
async generateKey() {
const totpStatus = await server.get<TOTPStatus>("totp/status");
if (totpStatus.set) {
const confirmed = await dialogService.confirm(t("multi_factor_authentication.totp_secret_regenerate_confirm"));
if (!confirmed) {
return;
}
}
const result = await server.get<{ success: boolean; message: string }>("totp/generate");
if (result.success) {
await dialogService.prompt({
title: t("multi_factor_authentication.totp_secret_generated"),
message: t("multi_factor_authentication.totp_secret_warning"),
defaultValue: result.message,
shown: ({ $answer }) => {
if ($answer) {
$answer.prop('readonly', true);
}
}
});
this.$generateTotpButton.text(t("multi_factor_authentication.totp_secret_regenerate"));
await this.setRecoveryKeys();
} else {
toastService.showError(result.message);
}
}
optionsLoaded(options: OptionMap) {
if (!utils.isElectron()) {
this.$mfaEnabledCheckbox.prop("checked", options.mfaEnabled === "true");
this.$mfaOptions.toggle(options.mfaEnabled === "true");
if (options.mfaEnabled === "true") {
const savedMethod = options.mfaMethod || "totp";
this.$mfaMethodRadios.filter(`[value="${savedMethod}"]`).prop("checked", true);
this.$totpOptions.toggle(savedMethod === "totp");
this.$oauthOptions.toggle(savedMethod === "oauth");
} else {
this.$totpOptions.hide();
this.$oauthOptions.hide();
}
server.get<OAuthStatus>("oauth/status").then((result) => {
if (result.enabled) {
if (result.name) this.$UserAccountName.text(result.name);
if (result.email) this.$UserAccountEmail.text(result.email);
this.$oauthWarning.hide();
this.$missingVars.hide();
} else {
this.$UserAccountName.text(t("multi_factor_authentication.oauth_user_not_logged_in"));
this.$UserAccountEmail.text(t("multi_factor_authentication.oauth_user_not_logged_in"));
this.$oauthWarning.show();
if (result.missingVars && result.missingVars.length > 0) {
this.$missingVars.show();
const missingVarsList = result.missingVars.map(v => `"${v}"`);
this.$missingVars.html(t("multi_factor_authentication.oauth_missing_vars", { variables: missingVarsList.join(", ") }));
}
}
});
server.get<TOTPStatus>("totp/status").then((result) => {
if (result.set) {
this.$generateTotpButton.text(t("multi_factor_authentication.totp_secret_regenerate"));
this.$noTotpSecretWarning.hide();
} else {
this.$noTotpSecretWarning.show();
}
});
}
}
}

View File

@ -43,7 +43,7 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget {
this.show();
}
getExtraOpts() {
getExtraOpts(): Partial<CodeMirrorOpts> {
return {
readOnly: true
};
@ -100,7 +100,7 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget {
return ret;
});
for (i = pre.length; i--; ) {
for (i = pre.length; i--;) {
html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>"));
}

View File

@ -32,6 +32,31 @@
color: var(--dropdown-item-icon-destructive-color) !important;
}
.google-login-btn {
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
color: #757575;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-bottom: 20px;
text-decoration: none;
transition: background-color 0.3s;
}
.google-login-btn:hover {
background-color: #f5f5f5;
}
.google-login-btn img {
margin-right: 10px;
width: 18px;
height: 18px;
}
/*
* SEARCH PAGE
*/

View File

@ -1299,6 +1299,39 @@
"password_mismatch": "新密码不一致。",
"password_changed_success": "密码已更改。按 OK 后 Trilium 将重载。"
},
"multi_factor_authentication": {
"title": "多因素认证MFA",
"description": "多因素认证MFA为您的账户添加了额外的安全层。除了输入密码登录外MFA还要求您提供一个或多个额外的验证信息来验证您的身份。这样即使有人获得了您的密码没有第二个验证信息他们也无法访问您的账户。这就像给您的门添加了一把额外的锁让他人更难闯入。<br><br>请按照以下说明启用 MFA。如果您配置不正确登录将仅使用密码。",
"mfa_enabled": "启用多因素认证",
"mfa_method": "MFA 方法",
"electron_disabled": "当前桌面版本不支持多因素认证。",
"totp_title": "基于时间的一次性密码TOTP",
"totp_description": "TOTP基于时间的一次性密码是一种安全功能它会生成一个每30秒变化的唯一临时代码。您需要使用这个代码和您的密码一起登录账户这使得他人更难访问您的账户。",
"totp_secret_title": "生成 TOTP 密钥",
"totp_secret_generate": "生成 TOTP 密钥",
"totp_secret_regenerate": "重新生成 TOTP 密钥",
"no_totp_secret_warning": "要启用 TOTP您需要先生成一个 TOTP 密钥。",
"totp_secret_description_warning": "生成新的 TOTP 密钥后,您需要使用新的 TOTP 密钥重新登录。",
"totp_secret_generated": "TOTP 密钥已生成",
"totp_secret_warning": "请将生成的密钥保存在安全的地方。它将不会再次显示。",
"totp_secret_regenerate_confirm": "您确定要重新生成 TOTP 密钥吗?这将使之前的 TOTP 密钥失效,并使所有现有的恢复代码失效。请将生成的密钥保存在安全的地方。它将不会再次显示。",
"recovery_keys_title": "单点登录恢复密钥",
"recovery_keys_description": "单点登录恢复密钥用于在您无法访问您的认证器代码时登录。离开页面后,恢复密钥将不会再次显示。请将它们保存在安全的地方。",
"recovery_keys_description_warning": "离开页面后,恢复密钥将不会再次显示。请将它们保存在安全的地方。<br>一旦恢复密钥被使用,它将无法再次使用。",
"recovery_keys_error": "生成恢复代码时出错",
"recovery_keys_no_key_set": "未设置恢复代码",
"recovery_keys_generate": "生成恢复代码",
"recovery_keys_regenerate": "重新生成恢复代码",
"recovery_keys_used": "已使用: {{date}}",
"recovery_keys_unused": "恢复代码 {{index}} 未使用",
"oauth_title": "OAuth/OpenID 认证",
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google的账户登录网站以验证您的身份。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
"oauth_description_warning": "要启用 OAuth/OpenID您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。",
"oauth_missing_vars": "缺少以下设置项: {{missingVars}}",
"oauth_user_account": "用户账号:",
"oauth_user_email": "用户邮箱:",
"oauth_user_not_logged_in": "未登录!"
},
"shortcuts": {
"keyboard_shortcuts": "快捷键",
"multiple_shortcuts": "同一操作的多个快捷键可以用逗号分隔。",

View File

@ -1310,6 +1310,39 @@
"password_mismatch": "New passwords are not the same.",
"password_changed_success": "Password has been changed. Trilium will be reloaded after you press OK."
},
"multi_factor_authentication": {
"title": "Multi-Factor Authentication",
"description": "Multi-Factor Authentication (MFA) adds an extra layer of security to your account. Instead of just entering a password to log in, MFA requires you to provide one or more additional pieces of evidence to verify your identity. This way, even if someone gets hold of your password, they still can't access your account without the second piece of information. It's like adding an extra lock to your door, making it much harder for anyone else to break in.<br><br>Please follow the instructions below to enable MFA. If you don't config correctly, login will fall back to password only.",
"mfa_enabled": "Enable Multi-Factor Authentication",
"mfa_method": "MFA Method",
"electron_disabled": "Multi-Factor Authentication is not supported in the desktop build currently.",
"totp_title": "Time-based One-Time Password (TOTP)",
"totp_description": "TOTP (Time-Based One-Time Password) is a security feature that generates a unique, temporary code which changes every 30 seconds. You use this code, along with your password to log into your account, making it much harder for anyone else to access it.",
"totp_secret_title": "Generate TOTP Secret",
"totp_secret_generate": "Generate TOTP Secret",
"totp_secret_regenerate": "Regenerate TOTP Secret",
"no_totp_secret_warning": "To enable TOTP, you need to generate a TOTP secret first.",
"totp_secret_description_warning": "After generating a new TOTP secret, you will be required to login again with the new TOTP secret.",
"totp_secret_generated": "TOTP Secret Generated",
"totp_secret_warning": "Please save the generated secret in a secure location. It will not be shown again.",
"totp_secret_regenerate_confirm": "Are you sure you want to regenerate the TOTP secret? This will invalidate previous TOTP secret and all existing recovery codes.",
"recovery_keys_title": "Single Sign-on Recovery Keys",
"recovery_keys_description": "Single sign-on recovery keys are used to login in the even you cannot access your Authenticator codes.",
"recovery_keys_description_warning": "Recovery keys won't be shown again after leaving the page, keep them somewhere safe and secure.<br>After a recovery key is used it cannot be used again.",
"recovery_keys_error": "Error generating recovery codes",
"recovery_keys_no_key_set": "No recovery codes set",
"recovery_keys_generate": "Generate Recovery Codes",
"recovery_keys_regenerate": "Regenerate Recovery Codes",
"recovery_keys_used": "Used: {{date}}",
"recovery_keys_unused": "Recovery code {{index}} is unused",
"oauth_title": "OAuth/OpenID",
"oauth_description": "OpenID is a standardized way to let you log into websites using an account from another service, like Google, to verify your identity. Follow these <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">instructions</a> to setup an OpenID service through Google.",
"oauth_description_warning": "To enable OAuth/OpenID, you need to set the OAuth/OpenID base URL, client ID and client secret in the config.ini file and restart the application. If you want to set from environment variables, please set TRILIUM_OAUTH_BASE_URL, TRILIUM_OAUTH_CLIENT_ID and TRILIUM_OAUTH_CLIENT_SECRET.",
"oauth_missing_vars": "Missing settings: {{variables}}",
"oauth_user_account": "User Account: ",
"oauth_user_email": "User Email: ",
"oauth_user_not_logged_in": "Not logged in!"
},
"shortcuts": {
"keyboard_shortcuts": "Keyboard Shortcuts",
"multiple_shortcuts": "Multiple shortcuts for the same action can be separated by comma.",
@ -1433,7 +1466,8 @@
"widget": "Widget",
"confirm-change": "It is not recommended to change note type when note content is not empty. Do you want to continue anyway?",
"geo-map": "Geo Map",
"beta-feature": "Beta"
"beta-feature": "Beta",
"task-list": "Task List"
},
"protect_note": {
"toggle-on": "Protect the note",

View File

@ -1,5 +1,3 @@
"use strict";
import appInfo from "../../services/app_info.js";
/**

View File

@ -80,7 +80,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"allowedHtmlTags",
"redirectBareDomain",
"showLoginInShareTheme",
"splitEditorOrientation"
"splitEditorOrientation",
"mfaEnabled",
"mfaMethod"
]);
function getOptions() {

View File

@ -0,0 +1,65 @@
import recovery_codes from '../../services/encryption/recovery_codes.js';
import type { Request } from 'express';
import { randomBytes } from 'crypto';
function setRecoveryCodes(req: Request) {
const success = recovery_codes.setRecoveryCodes(req.body.recoveryCodes.join(','));
return { success: success, message: 'Recovery codes set!' };
}
function veryifyRecoveryCode(req: Request) {
const success = recovery_codes.verifyRecoveryCode(req.body.recovery_code_guess);
return { success: success };
}
function checkForRecoveryKeys() {
return {
success: true, keysExist: recovery_codes.isRecoveryCodeSet()
};
}
function generateRecoveryCodes() {
const recoveryKeys = [
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64'),
randomBytes(16).toString('base64')
];
recovery_codes.setRecoveryCodes(recoveryKeys.join(','));
return { success: true, recoveryCodes: recoveryKeys };
}
function getUsedRecoveryCodes() {
if (!recovery_codes.isRecoveryCodeSet()) {
return []
}
const dateRegex = RegExp(/^\d{4}\/\d{2}\/\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/gm);
const recoveryCodes = recovery_codes.getRecoveryCodes();
const usedStatus: string[] = [];
recoveryCodes.forEach((recoveryKey: string) => {
if (dateRegex.test(recoveryKey)) usedStatus.push(recoveryKey);
else usedStatus.push(recoveryCodes.indexOf(recoveryKey));
});
return {
success: true,
usedRecoveryCodes: usedStatus
};
}
export default {
setRecoveryCodes,
generateRecoveryCodes,
veryifyRecoveryCode,
checkForRecoveryKeys,
getUsedRecoveryCodes
};

19
src/routes/api/totp.ts Normal file
View File

@ -0,0 +1,19 @@
import totpService from '../../services/totp.js';
function generateTOTPSecret() {
return totpService.createSecret();
}
function getTOTPStatus() {
return { success: true, message: totpService.isTotpEnabled(), set: totpService.checkForTotpSecret() };
}
function getSecret() {
return totpService.getTotpSecret();
}
export default {
generateSecret: generateTOTPSecret,
getTOTPStatus,
getSecret
};

View File

@ -1,5 +1,3 @@
"use strict";
import utils from "../services/utils.js";
import optionService from "../services/options.js";
import myScryptService from "../services/encryption/my_scrypt.js";
@ -8,13 +6,20 @@ import passwordService from "../services/encryption/password.js";
import assetPath 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 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';
function loginPage(req: Request, res: Response) {
res.render("login", {
failedAuth: false,
assetPath,
appPath
res.render('login', {
wrongPassword: false,
wrongTotp: false,
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled(),
assetPath: assetPath,
appPath: appPath,
});
}
@ -58,43 +63,95 @@ function setPassword(req: Request, res: Response) {
}
function login(req: Request, res: Response) {
const { password, rememberMe } = req.body;
if (!verifyPassword(password)) {
// note that logged IP address is usually meaningless since the traffic should come from a reverse proxy
log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`);
return res.status(401).render("login", {
failedAuth: true,
assetPath,
appPath
if (openID.isOpenIDEnabled()) {
res.oidc.login({
returnTo: '/',
authorizationParams: {
prompt: 'consent',
access_type: 'offline'
}
});
return;
}
const submittedPassword = req.body.password;
const submittedTotpToken = req.body.totpToken;
if (!verifyPassword(submittedPassword)) {
sendLoginError(req, res, 'password');
return;
}
if (totp.isTotpEnabled()) {
if (!verifyTOTP(submittedTotpToken)) {
sendLoginError(req, res, 'totp');
return;
}
}
const rememberMe = req.body.rememberMe;
req.session.regenerate(() => {
if (!rememberMe) {
if (rememberMe) {
req.session.cookie.maxAge = 21 * 24 * 3600000; // 3 weeks
} else {
// 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.loggedIn = true;
req.session.lastAuthState = {
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled()
};
res.redirect(".");
req.session.loggedIn = true;
res.redirect('.');
});
}
function verifyPassword(guessedPassword: string) {
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(guessedPassword);
const guess_hashed = myScryptService.getVerificationHash(submittedPassword);
return guess_hashed.equals(hashed_password);
}
function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' = '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.render('login', {
wrongPassword: errorType === 'password',
wrongTotp: errorType === 'totp',
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled(),
assetPath: assetPath,
appPath: appPath,
});
}
function logout(req: Request, res: Response) {
req.session.regenerate(() => {
req.session.loggedIn = false;
if (openID.isOpenIDEnabled() && openIDEncryption.isSubjectIdentifierSaved()) {
res.oidc.logout({ returnTo: '/' });
} else res.redirect('login');
res.sendStatus(200);
});
}

View File

@ -6,6 +6,9 @@ import log from "../services/log.js";
import express from "express";
const router = express.Router();
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import totp from './api/totp.js';
import recoveryCodes from './api/recovery_codes.js';
import cls from "../services/cls.js";
import sql from "../services/sql.js";
import entityChangesService from "../services/entity_changes.js";
@ -70,9 +73,9 @@ import etapiNoteRoutes from "../etapi/notes.js";
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import etapiSpecRoute from "../etapi/spec.js";
import etapiBackupRoute from "../etapi/backup.js";
import apiDocsRoute from "./api_docs.js";
const MAX_ALLOWED_FILE_SIZE_MB = 250;
const GET = "get",
PST = "post",
@ -114,8 +117,22 @@ function register(app: express.Application) {
route(PST, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPassword);
route(GET, "/setup", [], setupRoute.setupPage);
apiRoute(GET, "/api/tree", treeApiRoute.getTree);
apiRoute(PST, "/api/tree/load", treeApiRoute.load);
apiRoute(GET, '/api/totp/generate', totp.generateSecret);
apiRoute(GET, '/api/totp/status', totp.getTOTPStatus);
apiRoute(GET, '/api/totp/get', totp.getSecret);
apiRoute(GET, '/api/oauth/status', openID.getOAuthStatus);
apiRoute(GET, '/api/oauth/validate', openID.isTokenValid);
apiRoute(PST, '/api/totp_recovery/set', recoveryCodes.setRecoveryCodes);
apiRoute(PST, '/api/totp_recovery/verify', recoveryCodes.veryifyRecoveryCode);
apiRoute(GET, '/api/totp_recovery/generate', recoveryCodes.generateRecoveryCodes);
apiRoute(GET, '/api/totp_recovery/enabled', recoveryCodes.checkForRecoveryKeys);
apiRoute(GET, '/api/totp_recovery/used', recoveryCodes.getUsedRecoveryCodes);
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
apiRoute(GET, "/api/notes/:noteId", notesApiRoute.getNote);
apiRoute(GET, "/api/notes/:noteId/blob", notesApiRoute.getNoteBlob);
@ -492,7 +509,7 @@ function handleException(e: unknown | Error, method: HttpMethod, path: string, r
log.error(`${method} ${path} threw exception: '${errMessage}', stack: ${errStack}`);
const resStatusCode = (e instanceof ValidationError || e instanceof NotFoundError) ? e.statusCode : 500;
const resStatusCode = (e instanceof ValidationError || e instanceof NotFoundError) ? e.statusCode : 500;
res.status(resStatusCode).json({
message: errMessage

View File

@ -3,6 +3,7 @@ import sessionFileStore from "session-file-store";
import sessionSecret from "../services/session_secret.js";
import dataDir from "../services/data_dir.js";
import config from "../services/config.js";
const FileStore = sessionFileStore(session);
const sessionParser = session({

View File

@ -1,11 +1,9 @@
"use strict";
import path from "path";
import build from "./build.js";
import packageJson from "../../package.json" with { type: "json" };
import dataDir from "./data_dir.js";
const APP_DB_VERSION = 228;
const APP_DB_VERSION = 229;
const SYNC_VERSION = 34;
const CLIPPER_PROTOCOL_VERSION = "1.0";

View File

@ -1,5 +1,3 @@
"use strict";
import etapiTokenService from "./etapi_tokens.js";
import log from "./log.js";
import sqlInit from "./sql_init.js";
@ -7,6 +5,8 @@ import { isElectron } from "./utils.js";
import passwordEncryptionService from "./encryption/password_encryption.js";
import config from "./config.js";
import passwordService from "./encryption/password.js";
import totp from "./totp.js";
import openID from "./open_id.js";
import options from "./options.js";
import attributes from "./attributes.js";
import type { NextFunction, Request, Response } from "express";
@ -15,8 +15,30 @@ const noAuthentication = config.General && config.General.noAuthentication === t
function checkAuth(req: Request, res: Response, next: NextFunction) {
if (!sqlInit.isDbInitialized()) {
res.redirect("setup");
} else if (!req.session.loggedIn && !isElectron && !noAuthentication) {
res.redirect('setup');
}
const currentTotpStatus = totp.isTotpEnabled();
const currentSsoStatus = openID.isOpenIDEnabled();
const lastAuthState = req.session.lastAuthState || { totpEnabled: false, ssoEnabled: false };
if (isElectron) {
next();
return;
} else if (currentTotpStatus !== lastAuthState.totpEnabled || currentSsoStatus !== lastAuthState.ssoEnabled) {
req.session.destroy((err) => {
if (err) console.error('Error destroying session:', err);
res.redirect('/login');
});
return;
} else if (currentSsoStatus) {
if (req.oidc?.isAuthenticated() && req.session.loggedIn) {
next();
return;
}
res.redirect('/login');
return;
} else if (!req.session.loggedIn && !noAuthentication) {
const redirectToShare = options.getOptionBool("redirectBareDomain");
if (redirectToShare) {
// Check if any note has the #shareRoot label

View File

@ -1,5 +1,3 @@
"use strict";
import ini from "ini";
import fs from "fs";
import dataDir from "./data_dir.js";
@ -41,6 +39,11 @@ export interface TriliumConfig {
syncServerTimeout: string;
syncProxy: string;
};
MultiFactorAuthentication: {
oauthBaseUrl: string;
oauthClientId: string;
oauthClientSecret: string;
};
}
//prettier-ignore
@ -50,13 +53,13 @@ const config: TriliumConfig = {
instanceName:
process.env.TRILIUM_GENERAL_INSTANCENAME || iniConfig.General.instanceName || "",
noAuthentication:
noAuthentication:
envToBoolean(process.env.TRILIUM_GENERAL_NOAUTHENTICATION) || iniConfig.General.noAuthentication || false,
noBackup:
noBackup:
envToBoolean(process.env.TRILIUM_GENERAL_NOBACKUP) || iniConfig.General.noBackup || false,
noDesktopIcon:
noDesktopIcon:
envToBoolean(process.env.TRILIUM_GENERAL_NODESKTOPICON) || iniConfig.General.noDesktopIcon || false
},
@ -67,14 +70,14 @@ const config: TriliumConfig = {
port:
process.env.TRILIUM_NETWORK_PORT || iniConfig.Network.port || "3000",
https:
https:
envToBoolean(process.env.TRILIUM_NETWORK_HTTPS) || iniConfig.Network.https || false,
certPath:
certPath:
process.env.TRILIUM_NETWORK_CERTPATH || iniConfig.Network.certPath || "",
keyPath:
process.env.TRILIUM_NETWORK_KEYPATH || iniConfig.Network.keyPath || "",
keyPath:
process.env.TRILIUM_NETWORK_KEYPATH || iniConfig.Network.keyPath || "",
trustedReverseProxy:
process.env.TRILIUM_NETWORK_TRUSTEDREVERSEPROXY || iniConfig.Network.trustedReverseProxy || false
@ -98,8 +101,18 @@ const config: TriliumConfig = {
syncProxy:
// additionally checking in iniConfig for inconsistently named syncProxy for backwards compatibility
process.env.TRILIUM_SYNC_SERVER_PROXY || iniConfig?.Sync?.syncProxy || iniConfig?.Sync?.syncServerProxy || ""
}
},
MultiFactorAuthentication: {
oauthBaseUrl:
process.env.TRILIUM_OAUTH_BASE_URL || iniConfig?.MultiFactorAuthentication?.oauthBaseUrl || "",
oauthClientId:
process.env.TRILIUM_OAUTH_CLIENT_ID || iniConfig?.MultiFactorAuthentication?.oauthClientId || "",
oauthClientSecret:
process.env.TRILIUM_OAUTH_CLIENT_SECRET || iniConfig?.MultiFactorAuthentication?.oauthClientSecret || ""
}
};
export default config;

View File

@ -1,5 +1,3 @@
"use strict";
import crypto from "crypto";
import log from "../log.js";

View File

@ -1,7 +1,6 @@
"use strict";
import optionService from "../options.js";
import crypto from "crypto";
import sql from "../sql.js";
function getVerificationHash(password: crypto.BinaryLike) {
const salt = optionService.getOption("passwordVerificationSalt");
@ -21,7 +20,45 @@ function getScryptHash(password: crypto.BinaryLike, salt: crypto.BinaryLike) {
return hashed;
}
function getSubjectIdentifierVerificationHash(
guessedUserId: string | crypto.BinaryLike,
salt?: string
) {
if (salt != null) return getScryptHash(guessedUserId, salt);
const savedSalt = sql.getValue("SELECT salt FROM user_data;");
if (!savedSalt) {
console.error("User salt undefined!");
return undefined;
}
return getScryptHash(guessedUserId, savedSalt.toString());
}
function getSubjectIdentifierDerivedKey(
subjectIdentifer: crypto.BinaryLike,
givenSalt?: string
) {
if (givenSalt !== undefined) {
return getScryptHash(subjectIdentifer, givenSalt.toString());
}
const salt = sql.getValue("SELECT salt FROM user_data;");
if (!salt) return undefined;
return getScryptHash(subjectIdentifer, salt.toString());
}
function createSubjectIdentifierDerivedKey(
subjectIdentifer: string | crypto.BinaryLike,
salt: string | crypto.BinaryLike
) {
return getScryptHash(subjectIdentifer, salt);
}
export default {
getVerificationHash,
getPasswordDerivedKey
getPasswordDerivedKey,
getSubjectIdentifierVerificationHash,
getSubjectIdentifierDerivedKey,
createSubjectIdentifierDerivedKey
};

View File

@ -0,0 +1,145 @@
import myScryptService from "./my_scrypt.js";
import utils from "../utils.js";
import dataEncryptionService from "./data_encryption.js";
import sql from "../sql.js";
import sqlInit from "../sql_init.js";
import OpenIdError from "../../errors/open_id_error.js";
function saveUser(subjectIdentifier: string, name: string, email: string) {
if (isUserSaved()) return false;
const verificationSalt = utils.randomSecureToken(32);
const derivedKeySalt = utils.randomSecureToken(32);
const verificationHash = myScryptService.getSubjectIdentifierVerificationHash(
subjectIdentifier,
verificationSalt
);
if (!verificationHash) {
throw new OpenIdError("Verification hash undefined!")
}
const userIDEncryptedDataKey = setDataKey(
subjectIdentifier,
utils.randomSecureToken(16),
verificationSalt
);
if (!userIDEncryptedDataKey) {
console.error("UserID encrypted data key null");
return undefined;
}
const data = {
tmpID: 0,
userIDVerificationHash: utils.toBase64(verificationHash),
salt: verificationSalt,
derivedKey: derivedKeySalt,
userIDEncryptedDataKey: userIDEncryptedDataKey,
isSetup: "true",
username: name,
email: email
};
sql.upsert("user_data", "tmpID", data);
return true;
}
function isSubjectIdentifierSaved() {
const value = sql.getValue("SELECT userIDEncryptedDataKey FROM user_data;");
if (value === undefined || value === null || value === "") return false;
return true;
}
function isUserSaved() {
const isSaved = sql.getValue<string>("SELECT isSetup FROM user_data;");
return isSaved === "true" ? true : false;
}
function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) {
if (!sqlInit.isDbInitialized()) {
throw new OpenIdError("Database not initialized!");
}
if (isUserSaved()) {
return false;
}
const salt = sql.getValue("SELECT salt FROM user_data;");
if (salt == undefined) {
console.log("Salt undefined");
return undefined;
}
const givenHash = myScryptService
.getSubjectIdentifierVerificationHash(subjectIdentifier)
?.toString("base64");
if (givenHash === undefined) {
console.log("Sub id hash undefined!");
return undefined;
}
const savedHash = sql.getValue(
"SELECT userIDVerificationHash FROM user_data"
);
if (savedHash === undefined) {
console.log("verification hash undefined");
return undefined;
}
console.log("Matches: " + givenHash === savedHash);
return givenHash === savedHash;
}
function setDataKey(
subjectIdentifier: string,
plainTextDataKey: string | Buffer,
salt: string
) {
const subjectIdentifierDerivedKey =
myScryptService.getSubjectIdentifierDerivedKey(subjectIdentifier, salt);
if (subjectIdentifierDerivedKey === undefined) {
console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY");
return undefined;
}
const newEncryptedDataKey = dataEncryptionService.encrypt(
subjectIdentifierDerivedKey,
plainTextDataKey
);
return newEncryptedDataKey;
}
function getDataKey(subjectIdentifier: string) {
const subjectIdentifierDerivedKey =
myScryptService.getSubjectIdentifierDerivedKey(subjectIdentifier);
const encryptedDataKey = sql.getValue(
"SELECT userIDEncryptedDataKey FROM user_data"
);
if (!encryptedDataKey) {
console.error("Encrypted data key empty!");
return undefined;
}
if (!subjectIdentifierDerivedKey) {
console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY");
return undefined;
}
const decryptedDataKey = dataEncryptionService.decrypt(
subjectIdentifierDerivedKey,
encryptedDataKey.toString()
);
return decryptedDataKey;
}
export default {
verifyOpenIDSubjectIdentifier,
getDataKey,
setDataKey,
saveUser,
isSubjectIdentifierSaved,
};

View File

@ -1,5 +1,3 @@
"use strict";
import sql from "../sql.js";
import optionService from "../options.js";
import myScryptService from "./my_scrypt.js";

View File

@ -0,0 +1,73 @@
import sql from '../sql.js';
import optionService from '../options.js';
import crypto from 'crypto';
function isRecoveryCodeSet() {
return optionService.getOptionBool('encryptedRecoveryCodes');
}
function setRecoveryCodes(recoveryCodes: string) {
const iv = crypto.randomBytes(16);
const securityKey = crypto.randomBytes(32);
const cipher = crypto.createCipheriv('aes-256-cbc', securityKey, iv);
let encryptedRecoveryCodes = cipher.update(recoveryCodes, 'utf-8', 'hex');
sql.transactional(() => {
optionService.setOption('recoveryCodeInitialVector', iv.toString('hex'));
optionService.setOption('recoveryCodeSecurityKey', securityKey.toString('hex'));
optionService.setOption('recoveryCodesEncrypted', encryptedRecoveryCodes + cipher.final('hex'));
optionService.setOption('encryptedRecoveryCodes', 'true');
return true;
});
return false;
}
function getRecoveryCodes() {
if (!isRecoveryCodeSet()) {
return []
}
return sql.transactional(() => {
const iv = Buffer.from(optionService.getOption('recoveryCodeInitialVector'), 'hex');
const securityKey = Buffer.from(optionService.getOption('recoveryCodeSecurityKey'), 'hex');
const encryptedRecoveryCodes = optionService.getOption('recoveryCodesEncrypted');
const decipher = crypto.createDecipheriv('aes-256-cbc', securityKey, iv);
const decryptedData = decipher.update(encryptedRecoveryCodes, 'hex', 'utf-8');
const decryptedString = decryptedData + decipher.final('utf-8');
return decryptedString.split(',');
});
}
function removeRecoveryCode(usedCode: string) {
const oldCodes: string[] = getRecoveryCodes();
const today = new Date();
oldCodes[oldCodes.indexOf(usedCode)] = today.toJSON().replace(/-/g, '/');
setRecoveryCodes(oldCodes.toString());
}
function verifyRecoveryCode(recoveryCodeGuess: string) {
const recoveryCodeRegex = RegExp(/^.{22}==$/gm);
if (!recoveryCodeRegex.test(recoveryCodeGuess)) {
return false;
}
const recoveryCodes = getRecoveryCodes();
var loginSuccess = false;
recoveryCodes.forEach((recoveryCode: string) => {
if (recoveryCodeGuess === recoveryCode) {
removeRecoveryCode(recoveryCode);
loginSuccess = true;
return;
}
});
return loginSuccess;
}
export default {
setRecoveryCodes,
getRecoveryCodes,
verifyRecoveryCode,
isRecoveryCodeSet
};

View File

@ -0,0 +1,83 @@
import optionService from "../options.js";
import myScryptService from "./my_scrypt.js";
import { randomSecureToken, toBase64 } from "../utils.js";
import dataEncryptionService from "./data_encryption.js";
import type { OptionNames } from "../options_interface.js";
const TOTP_OPTIONS: Record<string, OptionNames> = {
SALT: "totpEncryptionSalt",
ENCRYPTED_SECRET: "totpEncryptedSecret",
VERIFICATION_HASH: "totpVerificationHash"
};
function verifyTotpSecret(secret: string): boolean {
const givenSecretHash = toBase64(myScryptService.getVerificationHash(secret));
const dbSecretHash = optionService.getOptionOrNull(TOTP_OPTIONS.VERIFICATION_HASH);
if (!dbSecretHash) {
return false;
}
return givenSecretHash === dbSecretHash;
}
function setTotpSecret(secret: string) {
if (!secret) {
throw new Error("TOTP secret cannot be empty");
}
const encryptionSalt = randomSecureToken(32);
optionService.setOption(TOTP_OPTIONS.SALT, encryptionSalt);
const verificationHash = toBase64(myScryptService.getVerificationHash(secret));
optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, verificationHash);
const encryptedSecret = dataEncryptionService.encrypt(
Buffer.from(encryptionSalt),
secret
);
optionService.setOption(TOTP_OPTIONS.ENCRYPTED_SECRET, encryptedSecret);
}
function getTotpSecret(): string | null {
const encryptionSalt = optionService.getOptionOrNull(TOTP_OPTIONS.SALT);
const encryptedSecret = optionService.getOptionOrNull(TOTP_OPTIONS.ENCRYPTED_SECRET);
if (!encryptionSalt || !encryptedSecret) {
return null;
}
try {
const decryptedSecret = dataEncryptionService.decrypt(
Buffer.from(encryptionSalt),
encryptedSecret
);
if (!decryptedSecret) {
return null;
}
return decryptedSecret.toString();
} catch (e) {
console.error("Failed to decrypt TOTP secret:", e);
return null;
}
}
function resetTotpSecret() {
optionService.setOption(TOTP_OPTIONS.SALT, "");
optionService.setOption(TOTP_OPTIONS.ENCRYPTED_SECRET, "");
optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, "");
}
function isTotpSecretSet(): boolean {
return !!optionService.getOptionOrNull(TOTP_OPTIONS.VERIFICATION_HASH);
}
export default {
verifyTotpSecret,
setTotpSecret,
getTotpSecret,
resetTotpSecret,
isTotpSecretSet
};

View File

@ -271,6 +271,7 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
{ id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" },
{ id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" },
{ id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" },
{ id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' },
{ id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
{ id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
{ id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },

154
src/services/open_id.ts Normal file
View File

@ -0,0 +1,154 @@
import type { NextFunction, Request, Response } from "express";
import openIDEncryption from "./encryption/open_id_encryption.js";
import sqlInit from "./sql_init.js";
import options from "./options.js";
import type { Session } from "express-openid-connect";
import sql from "./sql.js";
import config from "./config.js";
function checkOpenIDConfig() {
let missingVars: string[] = []
if (config.MultiFactorAuthentication.oauthBaseUrl === "") {
missingVars.push("oauthBaseUrl");
}
if (config.MultiFactorAuthentication.oauthClientId === "") {
missingVars.push("oauthClientId");
}
if (config.MultiFactorAuthentication.oauthClientSecret === "") {
missingVars.push("oauthClientSecret");
}
return missingVars;
}
function isOpenIDEnabled() {
return !(checkOpenIDConfig().length > 0) && options.getOptionOrNull('mfaMethod') === 'oauth';
}
function isUserSaved() {
const data = sql.getValue<string>("SELECT isSetup FROM user_data;");
return data === "true" ? true : false;
}
function getUsername() {
const username = sql.getValue<string>("SELECT username FROM user_data;");
return username;
}
function getUserEmail() {
const email = sql.getValue<string>("SELECT email FROM user_data;");
return email;
}
function clearSavedUser() {
sql.execute("DELETE FROM user_data");
options.setOption("userSubjectIdentifierSaved", false);
return {
success: true,
message: "Account data removed."
};
}
function getOAuthStatus() {
return {
success: true,
name: getUsername(),
email: getUserEmail(),
enabled: isOpenIDEnabled(),
missingVars: checkOpenIDConfig()
};
}
function isTokenValid(req: Request, res: Response, next: NextFunction) {
const userStatus = openIDEncryption.isSubjectIdentifierSaved();
if (req.oidc !== undefined) {
const result = req.oidc
.fetchUserInfo()
.then((result) => {
return {
success: true,
message: "Token is valid",
user: userStatus,
};
})
.catch((result) => {
return {
success: false,
message: "Token is not valid",
user: userStatus,
};
});
return result;
} else {
return {
success: false,
message: "Token not set up",
user: userStatus,
};
}
}
function generateOAuthConfig() {
const authRoutes = {
callback: "/callback",
login: "/authenticate",
postLogoutRedirect: "/login",
logout: "/logout",
};
const logoutParams = {
};
const authConfig = {
authRequired: false,
auth0Logout: false,
baseURL: config.MultiFactorAuthentication.oauthBaseUrl,
clientID: config.MultiFactorAuthentication.oauthClientId,
issuerBaseURL: "https://accounts.google.com",
secret: config.MultiFactorAuthentication.oauthClientSecret,
clientSecret: config.MultiFactorAuthentication.oauthClientSecret,
authorizationParams: {
response_type: "code",
scope: "openid profile email",
access_type: "offline",
prompt: "consent",
state: "random_state_" + Math.random().toString(36).substring(2)
},
routes: authRoutes,
idpLogout: true,
logoutParams: logoutParams,
afterCallback: async (req: Request, res: Response, session: Session) => {
if (!sqlInit.isDbInitialized()) return session;
if (!req.oidc.user) {
console.log("user invalid!");
return session;
}
openIDEncryption.saveUser(
req.oidc.user.sub.toString(),
req.oidc.user.name.toString(),
req.oidc.user.email.toString()
);
req.session.loggedIn = true;
req.session.lastAuthState = {
totpEnabled: false,
ssoEnabled: true
};
return session;
},
};
return authConfig;
}
export default {
generateOAuthConfig,
getOAuthStatus,
isOpenIDEnabled,
clearSavedUser,
isTokenValid,
isUserSaved,
};

View File

@ -131,6 +131,10 @@ const defaultOptions: DefaultOption[] = [
{ name: "customSearchEngineUrl", value: "https://duckduckgo.com/?q={keyword}", isSynced: true },
{ name: "promotedAttributesOpenInRibbon", value: "true", isSynced: true },
{ name: "editedNotesOpenInRibbon", value: "true", isSynced: true },
{ name: "mfaEnabled", value: "false", isSynced: false },
{ name: "mfaMethod", value: "totp", isSynced: false },
{ name: "encryptedRecoveryCodes", value: "false", isSynced: false },
{ name: "userSubjectIdentifierSaved", value: "false", isSynced: false },
// Appearance
{ name: "splitEditorOrientation", value: "horizontal", isSynced: true },

View File

@ -48,6 +48,18 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
encryptedDataKey: string;
hoistedNoteId: string;
// Multi-Factor Authentication
mfaEnabled: boolean;
mfaMethod: string;
totpEncryptionSalt: string;
totpEncryptedSecret: string;
totpVerificationHash: string;
encryptedRecoveryCodes: boolean;
userSubjectIdentifierSaved: boolean;
recoveryCodeInitialVector: string;
recoveryCodeSecurityKey: string;
recoveryCodesEncrypted: string;
lastSyncedPull: number;
lastSyncedPush: number;
revisionSnapshotTimeInterval: number;

View File

@ -46,6 +46,21 @@ async function initDbConnection() {
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);`)
dbReady.resolve();
}

66
src/services/totp.ts Normal file
View File

@ -0,0 +1,66 @@
import { Totp, generateSecret } from 'time2fa';
import options from './options.js';
import totpEncryptionService from './encryption/totp_encryption.js';
function isTotpEnabled(): boolean {
return options.getOption('mfaEnabled') === "true" &&
options.getOption('mfaMethod') === "totp" &&
totpEncryptionService.isTotpSecretSet();
}
function createSecret(): { success: boolean; message?: string } {
try {
const secret = generateSecret();
totpEncryptionService.setTotpSecret(secret);
return {
success: true,
message: secret
};
} catch (e) {
console.error('Failed to create TOTP secret:', e);
return {
success: false,
message: e instanceof Error ? e.message : 'Unknown error occurred'
};
}
}
function getTotpSecret(): string | null {
return totpEncryptionService.getTotpSecret();
}
function checkForTotpSecret(): boolean {
return totpEncryptionService.isTotpSecretSet();
}
function validateTOTP(submittedPasscode: string): boolean {
const secret = getTotpSecret();
if (!secret) return false;
try {
return Totp.validate({
passcode: submittedPasscode,
secret: secret.trim()
});
} catch (e) {
console.error('Failed to validate TOTP:', e);
return false;
}
}
function resetTotp(): void {
totpEncryptionService.resetTotpSecret();
options.setOption('mfaEnabled', 'false');
options.setOption('mfaMethod', '');
}
export default {
isTotpEnabled,
createSecret,
getTotpSecret,
checkForTotpSecret,
validateTOTP,
resetTotp
};

View File

@ -24,32 +24,55 @@
<img class="img-fluid d-block mx-auto" style="height: 8rem;" src="<%= assetPath %>/images/icon-color.svg" aria-hidden="true" draggable="false" >
<h1 class="text-center"><%= t("login.heading") %></h1>
<form action="login" method="POST">
<div class="form-group">
<label for="password"><%= t("login.password") %></label>
<div class="controls">
<input id="password" name="password" placeholder="" class="form-control" type="password" autofocus>
<% if (ssoEnabled) { %>
<a href="/authenticate" class="google-login-btn">
<img src="<%= assetPath %>/images/google-logo.svg" alt="Google logo">
<%= t("login.sign_in_with_google") %>
</a>
<% } else { %>
<form action="login" method="POST">
<div class="form-group">
<label for="password"><%= t("login.password") %></label>
<div class="controls">
<input id="password" name="password" placeholder="" class="form-control" type="password" autofocus>
</div>
</div>
</div>
<% if( totpEnabled ) { %>
<div class="form-group">
<label for="totpToken">TOTP Token</label>
<div class="controls">
<input id="totpToken" name="totpToken" placeholder="" class="form-control" type="text" required />
</div>
</div>
<% } %>
<% if (failedAuth) { %>
<div class="alert alert-warning">
<%= t("login.incorrect-password") %>
</div>
<% } %>
<% if ( wrongPassword ) { %>
<div class="alert alert-warning">
<%= t("login.incorrect-password") %>
</div>
<% } %>
<% if ( totpEnabled ) { %>
<% if( wrongTotp ) { %>
<div class="alert alert-warning">
<%= t("login.incorrect-totp") %>
</div>
<% } %>
<% } %>
<div class="form-group">
<div class="checkbox">
<label class="tn-checkbox">
<input id="remember-me" name="rememberMe" value="1" type="checkbox">
<%= t("login.remember-me") %>
</label>
<div class="form-group">
<div class="checkbox">
<label class="tn-checkbox">
<input id="remember-me" name="rememberMe" value="1" type="checkbox">
<%= t("login.remember-me") %>
</label>
</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-success"><%= t("login.button") %></button>
</div>
</form>
<div class="form-group">
<button class="btn btn-success"><%= t("login.button") %></button>
</div>
</form>
<% } %>
</div>
</div>
<script src="<%= appPath %>/login.js" crossorigin type="module"></script>

View File

@ -98,10 +98,12 @@
"login": {
"title": "登录",
"heading": "Trilium 登录",
"incorrect-totp": "TOTP 不正确,请重试。",
"incorrect-password": "密码不正确,请重试。",
"password": "密码",
"remember-me": "记住我",
"button": "登录"
"button": "登录",
"sign_in_with_google": "使用 Google 登录"
},
"set_password": {
"title": "设置密码",
@ -238,13 +240,15 @@
"images-title": "图片",
"spellcheck-title": "拼写检查",
"password-title": "密码",
"multi-factor-authentication-title": "多因素认证",
"etapi-title": "ETAPI",
"backup-title": "备份",
"sync-title": "同步",
"other": "其他",
"advanced-title": "高级",
"visible-launchers-title": "可见启动器",
"user-guide": "用户指南"
"user-guide": "用户指南",
"localization": "语言和区域"
},
"notes": {
"new-note": "新建笔记",

View File

@ -98,10 +98,12 @@
"login": {
"title": "Login",
"heading": "Trilium Login",
"incorrect-totp": "TOTP is incorrect. Please try again.",
"incorrect-password": "Password is incorrect. Please try again.",
"password": "Password",
"remember-me": "Remember me",
"button": "Login"
"button": "Login",
"sign_in_with_google": "Sign in with Google"
},
"set_password": {
"title": "Set Password",
@ -238,6 +240,7 @@
"images-title": "Images",
"spellcheck-title": "Spellcheck",
"password-title": "Password",
"multi-factor-authentication-title": "MFA",
"etapi-title": "ETAPI",
"backup-title": "Backup",
"sync-title": "Sync",