mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
52f4e51ae0
* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh) - Remove passwordHash from /users/list API response - Require both password and TOTP code for MFA-critical operations - Restrict tunnel kill commands to tunnelMarker-only matching - Add session ownership middleware for file manager endpoints * fix: allow navigating away from split-view to non-pane tabs Show the normal view container on top of the split view when the active tab is not assigned to any pane, so users can switch to dashboard or other tabs while split mode is active. Closes #739 * fix: add inline quick-action buttons on host name row Show Terminal, Files, RDP, and VNC shortcut icons on the host name row on hover, so users can launch connections with a single click without expanding the full action tray. Closes #736 * fix: restore SSH keepalive interval to 30s to prevent random disconnects Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel, and server-stats SSH connections. The 60s interval introduced in 2.3.0 causes firewalls and NAT devices to drop idle connections before the next keepalive probe. Closes #733 * fix: apply guacamole-lite protocol patch in Docker builds The Dockerfile uses --ignore-scripts which skips the postinstall hook that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0. Without this patch, the timezone handshake instruction is not sent for protocol versions above 1.1.0, causing VNC connections to fail immediately on connect. Closes #734 * fix: show correct icons for network interface types Detect interface type from name pattern and show appropriate icons: WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for docker/bridge/virtual, generic Network for others. Closes #720 * fix: resolve sudo password for shared host users The password endpoint required hosts.userId to match the requesting user, which fails for shared hosts. Now falls back to decrypting with the owner's key when the requesting user doesn't own the host. Closes #717 * fix: use jump hosts for online status check and metrics collection Status polling now pings the first jump host instead of the unreachable target when jump hosts are configured. The /metrics/start endpoint now tunnels through the jump host chain to reach the target host. Closes #716 * fix: broaden sudo prompt detection for newer distros Add patterns for 'password for <user>:' and bare 'Password:' prompts in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu 26.04 and other distros that use different sudo prompt formats. Closes #718 * fix: recalculate terminal layout after web fonts load xterm.js measures character widths at open() time. If custom fonts haven't loaded yet, measurements use the fallback font and spacing becomes incorrect. Now refresh and re-fit the terminal once document.fonts.ready resolves. Closes #710 * fix: improve terminal cwd detection and initial directory command Remove '&& pwd' from initial directory command — the shell prompt shows the new directory naturally. Fixes PowerShell 5.1 which doesn't support '&&' as a statement separator. Prepend Ctrl+U to get_cwd command to clear any pending input before injecting the cwd probe, reducing interference with foreground programs. Closes #713, #714 * fix: decode base64 file content as UTF-8 in file manager Replace bare atob() with TextDecoder('utf-8') for base64 content decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters like 'é' were decoded as 'é'. Closes #719 * fix: normalize lazy import default exports for iOS compatibility Wrap all lazy() imports with explicit .then(m => ({ default: m.default })) to ensure consistent module resolution across platforms. iOS Safari/WebView may handle bare lazy(() => import(...)) differently, returning the module object instead of extracting the default export. Closes #721 * fix: prevent RDP display from snapping back after container resize Remove immediate rescaleDisplay() from ResizeObserver callback. The display.onresize event already triggers rescaling when the RDP server responds with the new resolution. Calling rescaleDisplay before the server responds uses stale display dimensions, causing the bottom of the screen to be truncated. Closes #725 * fix: add portal Desktop DBus permission for Flatpak URL opening Flatpak sandbox blocks window.open() without the portal permission, causing terminal link clicks to open about:blank. Add talk-name for org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL handling. Closes #704 * chore: remove unused code and fix PR checks (#851) * chore: remove unused frontend code * chore: prune unused theme exports * ci: fix pr check failures * chore: reduce lint warnings * feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828) The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group` to sync the user's admin flag from OIDC group membership on each login. That field is only populated when the OIDC config is stored in the in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments using the env-var config path (declarative IaC: Helm/Compose/Puppet) cannot enable the feature without abandoning env vars and pasting the client_secret into the admin UI. Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config return type and object. Backward compatible: when unset, the existing `if (config.admin_group)` guard at users.ts:1336 keeps the sync block skipped, matching today's behavior. * chore: reduce explicit-any warnings * chore: reduce more explicit-any warnings * chore: reduce lint warnings * chore: silence intentional hook dependency warnings * chore: clean dependency tooling * chore: narrow frontend tsconfig scope * chore: reduce type assertion debt * refactor: split host manager components * refactor: split host editor sections * refactor: split api client modules * refactor: split more api clients * refactor: split user settings api clients * refactor: split tab and history api clients * refactor: split tunnel api clients * refactor: split server stats api client * refactor: split file manager data api * refactor: split ssh file operations api * refactor: split host editor general tab * refactor: split host editor guacamole tabs * refactor: split ssh host management api * refactor: split admin general settings sections * refactor: split admin database section * refactor: split admin management sections * refactor: split admin keys and dialogs * refactor: split system status api clients * refactor: split user route helpers * refactor: split host route helpers * refactor: split file manager ssh helpers * refactor: split file manager session helpers * refactor: split file manager listing routes * refactor: split host opkssh routes * refactor: split file manager content routes * refactor: split user api key routes * refactor: split host folder routes * refactor: split user settings routes * refactor: split user totp routes * refactor: split host file manager bookmark routes * refactor: split file manager operation routes * refactor: split server stats settings routes * refactor: split user session routes * refactor: split host command history routes * refactor: split server stats viewer routes * refactor: split docker container routes * refactor: split user oidc account routes * refactor: split host autostart routes * refactor: split host internal routes * refactor: split host network routes * refactor: split user password reset routes * refactor: split user admin routes * refactor: split user data access routes * refactor: split credential key routes * refactor: split credential deploy routes * refactor: split host bulk routes * refactor: split server stats connection helpers * refactor: split tunnel helpers * refactor: split file manager action routes * refactor: split terminal auth helpers * refactor: split terminal jump host helpers * refactor: split tunnel relay helpers * refactor: split tunnel socks relay helpers * refactor: split tunnel c2s relay handlers * refactor: split server stats session helpers * refactor: split terminal presentation helpers * refactor: split file manager presentation helpers * refactor: split file manager toolbar * fix(guacamole-lite): send name instruction for protocol >= 1.3.0 The Guacamole protocol added the `name` handshake instruction in 1.3.0 (an optional human-readable identifier for the joining user). guacd 1.6.0 began requiring it during the VNC handshake even when negotiating older protocol versions, causing connections to silently drop right after the "User joined" log line with no client-visible error. This patch extends scripts/patch-guacamole-lite.cjs with a third idempotent string-replacement that injects the `name` instruction send when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0. Verified end-to-end: guacd debug logs now show `Processing instruction: name` and `Client is using protocol version "VERSION_1_5_0"` (previously stuck at VERSION_1_1_0). VNC session connects successfully against guacd 1.5.5 / macOS Tahoe target. Related: Termix-SSH/Support#567, #734 * fix: resolve recent support bugs * fix(admin): wire up OIDC-to-password link dialog submit + visibility The admin user-management UI already shipped a link icon and a "Link Account" dialog, but two things blocked the flow: 1. The submit button had no onClick handler and the username input was uncontrolled (no value/onChange). Clicking "Link Accounts" was a no-op — no network request, no console error, no toast. 2. The link icon's visibility condition was `user.isOidc && !user.passwordHash`, which hid the button on OIDC users that had been auto-provisioned with a passwordHash. Termix's OIDC provisioning sets a passwordHash by default, so the button was hidden on virtually every OIDC-provisioned user. This change: - Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`. - Adds two pieces of dialog state: `linkAccountTargetUsername` and `linkAccountSubmitting`. - Makes the dialog's Input field a controlled component. - Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`, emit success/error toasts, refresh the local user list, and close the dialog. - Loosens the visibility condition to `user.isOidc` (the backend handler already enforces all integrity checks). - Adds `linkAccountSuccess`, `linkAccountFailed`, and `linkAccountInProgress` translation keys to `en.json`. Verified locally: full Docker build via docker/Dockerfile passes; `tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces the same warning count as upstream (16 pre-existing `any`-type warnings, 0 errors). * fix: support native oidc callbacks (#856) * docs: add cloudflare tunnel guidance (#857) * fix: sync appearance preferences (#858) * fix: pass through terminal tab completion (#859) * fix: resolve terminal jump hosts server-side (#860) * fix(electron): auto-allow SSL certificates for private network hosts (#861) Add private network IP detection (RFC 1918, link-local, loopback, IPv6 ULA) to the Electron certificate-error handler so that connections to local/private servers like 192.168.x.x bypass SSL validation automatically. Also add an explicit "Allow invalid certificate" toggle in the server config UI for public HTTPS servers with self-signed certs. * fix: restore host password copy actions (#862) * feat: support single-host direct tunnels (ssh -L style) (#863) Add direct tunnel mode that uses a single SSH host for port forwarding, matching the behavior of ssh -L / ssh -R / ssh -D without requiring a second endpoint host in the Termix database. The Termix server creates a local TCP listener and forwards through the SSH channel directly. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * fix: backend build errors (Type) * fix: mobile auth failing to login with webview * fix: mobile app geting incorrectly sent auth token * feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing) * feat: host-to-host file transfer via server relay * feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts * fix: credentials not indexing into host manager until refresh * feat: update credentials lists to match hosts list UI/UX * feat: add rename folder UI * feat: improve transfer to host UI/UX * chore: increment ver * feat: improve transfer to host UI * feat: implement initial auto release system --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com> Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> Co-authored-by: luc <luc_cook@hotmail.co.uk>
2226 lines
67 KiB
TypeScript
2226 lines
67 KiB
TypeScript
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import express from "express";
|
|
import { db } from "../db/index.js";
|
|
import { users, settings, roles, userRoles } from "../db/schema.js";
|
|
import { eq } from "drizzle-orm";
|
|
import bcrypt from "bcryptjs";
|
|
import { nanoid } from "nanoid";
|
|
import type { Request, Response } from "express";
|
|
import { authLogger } from "../../utils/logger.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
|
import {
|
|
parseUserAgent,
|
|
generateDeviceFingerprint,
|
|
} from "../../utils/user-agent-parser.js";
|
|
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
|
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
|
|
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
|
import {
|
|
getOIDCConfigFromEnv,
|
|
isOIDCUserAllowed,
|
|
verifyOIDCToken,
|
|
} from "./user-oidc-utils.js";
|
|
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
|
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
|
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
|
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
|
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
|
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
|
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
|
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
const router = express.Router();
|
|
|
|
function isNonEmptyString(val: unknown): val is string {
|
|
return typeof val === "string" && val.trim().length > 0;
|
|
}
|
|
|
|
function isNativeAppRequest(req: Request): boolean {
|
|
return (
|
|
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
|
req.get("X-Electron-App") === "true"
|
|
);
|
|
}
|
|
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireAdmin = authManager.createAdminMiddleware();
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/create:
|
|
* post:
|
|
* summary: Create a new user
|
|
* description: Creates a new user with a username and password.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* username:
|
|
* type: string
|
|
* password:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: User created successfully.
|
|
* 400:
|
|
* description: Username and password are required.
|
|
* 403:
|
|
* description: Registration is currently disabled.
|
|
* 409:
|
|
* description: Username already exists.
|
|
* 500:
|
|
* description: Failed to create user.
|
|
*/
|
|
router.post("/create", async (req, res) => {
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
|
.get();
|
|
if (row && (row as Record<string, unknown>).value !== "true") {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Registration is currently disabled" });
|
|
}
|
|
} catch (e) {
|
|
authLogger.warn("Failed to check registration status", {
|
|
operation: "registration_check",
|
|
error: e,
|
|
});
|
|
}
|
|
|
|
const { username, password } = req.body;
|
|
authLogger.info("User registration attempt", {
|
|
operation: "user_register_attempt",
|
|
username,
|
|
});
|
|
|
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
|
authLogger.warn(
|
|
"Invalid user creation attempt - missing username or password",
|
|
{
|
|
operation: "user_create",
|
|
hasUsername: !!username,
|
|
hasPassword: !!password,
|
|
},
|
|
);
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Username and password are required" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
if (existing && existing.length > 0) {
|
|
authLogger.warn("Registration failed - username exists", {
|
|
operation: "user_register_failed",
|
|
username,
|
|
reason: "username_exists",
|
|
});
|
|
return res.status(409).json({ error: "Username already exists" });
|
|
}
|
|
|
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
|
const id = nanoid();
|
|
|
|
const isFirstUser = db.$client.transaction(() => {
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get() as { count?: number };
|
|
const first = (countResult?.count || 0) === 0;
|
|
db.$client
|
|
.prepare(
|
|
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(
|
|
id,
|
|
username,
|
|
password_hash,
|
|
first ? 1 : 0,
|
|
0,
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"openid email profile",
|
|
null,
|
|
0,
|
|
null,
|
|
);
|
|
return first;
|
|
})();
|
|
|
|
try {
|
|
const defaultRoleName = isFirstUser ? "admin" : "user";
|
|
const defaultRole = await db
|
|
.select({ id: roles.id })
|
|
.from(roles)
|
|
.where(eq(roles.name, defaultRoleName))
|
|
.limit(1);
|
|
|
|
if (defaultRole.length > 0) {
|
|
await db.insert(userRoles).values({
|
|
userId: id,
|
|
roleId: defaultRole[0].id,
|
|
grantedBy: id,
|
|
});
|
|
} else {
|
|
authLogger.warn("Default role not found during user registration", {
|
|
operation: "assign_default_role",
|
|
userId: id,
|
|
roleName: defaultRoleName,
|
|
});
|
|
}
|
|
} catch (roleError) {
|
|
authLogger.error("Failed to assign default role", roleError, {
|
|
operation: "assign_default_role",
|
|
userId: id,
|
|
});
|
|
}
|
|
|
|
try {
|
|
await authManager.registerUser(id, password);
|
|
} catch (encryptionError) {
|
|
await db.delete(users).where(eq(users.id, id));
|
|
authLogger.error(
|
|
"Failed to setup user encryption, user creation rolled back",
|
|
encryptionError,
|
|
{
|
|
operation: "user_create_encryption_failed",
|
|
userId: id,
|
|
},
|
|
);
|
|
return res.status(500).json({
|
|
error: "Failed to setup user security - user creation cancelled",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
authLogger.error("Failed to persist user to disk", saveError, {
|
|
operation: "user_create_save_failed",
|
|
userId: id,
|
|
});
|
|
}
|
|
|
|
authLogger.success("User registration successful", {
|
|
operation: "user_register_success",
|
|
userId: id,
|
|
username,
|
|
isAdmin: isFirstUser,
|
|
});
|
|
res.json({
|
|
message: "User created",
|
|
is_admin: isFirstUser,
|
|
toast: { type: "success", message: `User created: ${username}` },
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to create user", err);
|
|
res.status(500).json({ error: "Failed to create user" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/oidc-config:
|
|
* post:
|
|
* summary: Configure OIDC provider
|
|
* description: Creates or updates the OIDC provider configuration.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: OIDC configuration updated.
|
|
* 403:
|
|
* description: Not authorized.
|
|
* 500:
|
|
* description: Failed to update OIDC config.
|
|
*/
|
|
router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
const {
|
|
client_id,
|
|
client_secret,
|
|
issuer_url,
|
|
authorization_url,
|
|
token_url,
|
|
userinfo_url,
|
|
identifier_path,
|
|
name_path,
|
|
scopes,
|
|
allowed_users,
|
|
admin_group,
|
|
} = req.body;
|
|
|
|
const isDisableRequest =
|
|
(client_id === "" || client_id === null || client_id === undefined) &&
|
|
(client_secret === "" ||
|
|
client_secret === null ||
|
|
client_secret === undefined) &&
|
|
(issuer_url === "" || issuer_url === null || issuer_url === undefined) &&
|
|
(authorization_url === "" ||
|
|
authorization_url === null ||
|
|
authorization_url === undefined) &&
|
|
(token_url === "" || token_url === null || token_url === undefined);
|
|
|
|
const isEnableRequest =
|
|
isNonEmptyString(client_id) &&
|
|
isNonEmptyString(client_secret) &&
|
|
isNonEmptyString(issuer_url) &&
|
|
isNonEmptyString(authorization_url) &&
|
|
isNonEmptyString(token_url) &&
|
|
isNonEmptyString(identifier_path) &&
|
|
isNonEmptyString(name_path);
|
|
|
|
if (!isDisableRequest && !isEnableRequest) {
|
|
authLogger.warn(
|
|
"OIDC validation failed - neither disable nor enable request",
|
|
{
|
|
operation: "oidc_config_update",
|
|
userId,
|
|
isDisableRequest,
|
|
isEnableRequest,
|
|
},
|
|
);
|
|
return res
|
|
.status(400)
|
|
.json({ error: "All OIDC configuration fields are required" });
|
|
}
|
|
|
|
if (isDisableRequest) {
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = 'oidc_config'")
|
|
.run();
|
|
authLogger.info("OIDC configuration disabled", {
|
|
operation: "oidc_disable",
|
|
userId,
|
|
});
|
|
res.json({ message: "OIDC configuration disabled" });
|
|
} else {
|
|
const config = {
|
|
client_id,
|
|
client_secret,
|
|
issuer_url,
|
|
authorization_url,
|
|
token_url,
|
|
userinfo_url: userinfo_url || "",
|
|
identifier_path,
|
|
name_path,
|
|
scopes: scopes || "openid email profile",
|
|
allowed_users: allowed_users || "",
|
|
admin_group: admin_group || "",
|
|
};
|
|
|
|
let encryptedConfig;
|
|
try {
|
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (adminDataKey) {
|
|
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
|
encryptedConfig = DataCrypto.encryptRecord(
|
|
"settings",
|
|
configWithId,
|
|
userId,
|
|
adminDataKey,
|
|
);
|
|
} else {
|
|
encryptedConfig = {
|
|
...config,
|
|
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`,
|
|
};
|
|
authLogger.warn(
|
|
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
|
{
|
|
operation: "oidc_config_basic_encoding",
|
|
userId,
|
|
},
|
|
);
|
|
}
|
|
} catch (encryptError) {
|
|
authLogger.error(
|
|
"Failed to encrypt OIDC configuration, storing with basic encoding",
|
|
encryptError,
|
|
{
|
|
operation: "oidc_config_encrypt_failed",
|
|
userId,
|
|
},
|
|
);
|
|
encryptedConfig = {
|
|
...config,
|
|
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
|
|
};
|
|
}
|
|
|
|
db.$client
|
|
.prepare(
|
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)",
|
|
)
|
|
.run(JSON.stringify(encryptedConfig));
|
|
authLogger.info("OIDC configuration updated", {
|
|
operation: "oidc_update",
|
|
userId,
|
|
hasUserinfoUrl: !!userinfo_url,
|
|
});
|
|
res.json({ message: "OIDC configuration updated" });
|
|
}
|
|
} catch (err) {
|
|
authLogger.error("Failed to update OIDC config", err);
|
|
res.status(500).json({ error: "Failed to update OIDC config" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/oidc-config:
|
|
* delete:
|
|
* summary: Disable OIDC configuration
|
|
* description: Disables the OIDC provider configuration.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: OIDC configuration disabled.
|
|
* 403:
|
|
* description: Not authorized.
|
|
* 500:
|
|
* description: Failed to disable OIDC config.
|
|
*/
|
|
router.delete("/oidc-config", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
|
|
authLogger.success("OIDC configuration disabled", {
|
|
operation: "oidc_disable",
|
|
userId,
|
|
});
|
|
res.json({ message: "OIDC configuration disabled" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to disable OIDC config", err);
|
|
res.status(500).json({ error: "Failed to disable OIDC config" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/oidc-config:
|
|
* get:
|
|
* summary: Get OIDC configuration
|
|
* description: Returns the public OIDC configuration.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Public OIDC configuration.
|
|
* 500:
|
|
* description: Failed to get OIDC config.
|
|
*/
|
|
router.get("/oidc-config", async (req, res) => {
|
|
try {
|
|
const envConfig = getOIDCConfigFromEnv();
|
|
if (envConfig) {
|
|
return res.json({
|
|
client_id: envConfig.client_id,
|
|
issuer_url: envConfig.issuer_url,
|
|
authorization_url: envConfig.authorization_url,
|
|
scopes: envConfig.scopes,
|
|
});
|
|
}
|
|
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
|
.get();
|
|
if (!row) {
|
|
return res.json(null);
|
|
}
|
|
|
|
const config = JSON.parse((row as Record<string, unknown>).value as string);
|
|
|
|
const publicConfig = {
|
|
client_id: config.client_id,
|
|
issuer_url: config.issuer_url,
|
|
authorization_url: config.authorization_url,
|
|
scopes: config.scopes,
|
|
};
|
|
|
|
return res.json(publicConfig);
|
|
} catch (err) {
|
|
authLogger.error("Failed to get OIDC config", err);
|
|
res.status(500).json({ error: "Failed to get OIDC config" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/oidc-config/admin:
|
|
* get:
|
|
* summary: Get OIDC configuration for admin
|
|
* description: Returns the full OIDC configuration for an admin.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Full OIDC configuration.
|
|
* 500:
|
|
* description: Failed to get OIDC config for admin.
|
|
*/
|
|
router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
|
.get();
|
|
if (!row) {
|
|
const envConfig = getOIDCConfigFromEnv();
|
|
return res.json(envConfig);
|
|
}
|
|
|
|
let config = JSON.parse((row as Record<string, unknown>).value as string);
|
|
|
|
if (config.client_secret?.startsWith("encrypted:")) {
|
|
try {
|
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (adminDataKey) {
|
|
config = DataCrypto.decryptRecord(
|
|
"settings",
|
|
config,
|
|
userId,
|
|
adminDataKey,
|
|
);
|
|
} else {
|
|
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
|
}
|
|
} catch {
|
|
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
|
operation: "oidc_config_decrypt_failed",
|
|
userId,
|
|
});
|
|
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
|
}
|
|
} else if (config.client_secret?.startsWith("encoded:")) {
|
|
try {
|
|
const decoded = Buffer.from(
|
|
config.client_secret.substring(8),
|
|
"base64",
|
|
).toString("utf8");
|
|
config.client_secret = decoded;
|
|
} catch {
|
|
authLogger.warn("Failed to decode OIDC config for admin", {
|
|
operation: "oidc_config_decode_failed",
|
|
userId,
|
|
});
|
|
config.client_secret = "[ENCODING ERROR]";
|
|
}
|
|
}
|
|
|
|
res.json(config);
|
|
} catch (err) {
|
|
authLogger.error("Failed to get OIDC config for admin", err);
|
|
res.status(500).json({ error: "Failed to get OIDC config for admin" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/oidc/authorize:
|
|
* get:
|
|
* summary: Get OIDC authorization URL
|
|
* description: Returns the OIDC authorization URL.
|
|
* tags:
|
|
* - Users
|
|
* parameters:
|
|
* - in: query
|
|
* name: rememberMe
|
|
* schema:
|
|
* type: boolean
|
|
* description: Whether to extend the session to 30 days instead of 2 hours.
|
|
* responses:
|
|
* 200:
|
|
* description: OIDC authorization URL.
|
|
* 404:
|
|
* description: OIDC not configured.
|
|
* 500:
|
|
* description: Failed to generate authorization URL.
|
|
*/
|
|
router.get("/oidc/authorize", async (req, res) => {
|
|
try {
|
|
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
|
|
const origin = getRequestOriginWithForceHTTPS(req);
|
|
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
|
|
|
const envConfig = getOIDCConfigFromEnv();
|
|
let config;
|
|
|
|
if (envConfig) {
|
|
config = envConfig;
|
|
} else {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
|
.get();
|
|
if (!row) {
|
|
return res.status(404).json({ error: "OIDC not configured" });
|
|
}
|
|
config = JSON.parse((row as Record<string, unknown>).value as string);
|
|
}
|
|
const state = nanoid();
|
|
const nonce = nanoid();
|
|
|
|
const referer = req.get("Referer");
|
|
let frontendOrigin;
|
|
if (desktopCallbackPort) {
|
|
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
|
|
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
|
let callbackUrl: URL;
|
|
try {
|
|
callbackUrl = new URL(appCallbackUrl);
|
|
} catch {
|
|
return res.status(400).json({ error: "Invalid app callback URL" });
|
|
}
|
|
if (callbackUrl.protocol !== "termix-mobile:") {
|
|
return res.status(400).json({ error: "Unsupported app callback URL" });
|
|
}
|
|
frontendOrigin = callbackUrl.toString();
|
|
} else if (referer) {
|
|
const refererUrl = new URL(referer);
|
|
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
|
} else {
|
|
frontendOrigin = origin;
|
|
}
|
|
|
|
db.$client
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(`oidc_state_${state}`, nonce);
|
|
|
|
db.$client
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(`oidc_backend_callback_${state}`, backendCallbackUri);
|
|
|
|
db.$client
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(`oidc_frontend_origin_${state}`, frontendOrigin);
|
|
|
|
db.$client
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(
|
|
`oidc_remember_me_${state}`,
|
|
rememberMe === "true" ? "true" : "false",
|
|
);
|
|
|
|
const authUrl = new URL(config.authorization_url);
|
|
authUrl.searchParams.set("client_id", config.client_id);
|
|
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
|
|
authUrl.searchParams.set("response_type", "code");
|
|
authUrl.searchParams.set("scope", config.scopes);
|
|
authUrl.searchParams.set("state", state);
|
|
authUrl.searchParams.set("nonce", nonce);
|
|
|
|
res.json({ auth_url: authUrl.toString(), state, nonce });
|
|
} catch (err) {
|
|
authLogger.error("Failed to generate OIDC auth URL", err);
|
|
res.status(500).json({ error: "Failed to generate authorization URL" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/oidc/callback:
|
|
* get:
|
|
* summary: OIDC callback
|
|
* description: Handles the OIDC callback, exchanges the code for a token, and creates or logs in the user.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 302:
|
|
* description: Redirects to the frontend with a success or error message.
|
|
* 400:
|
|
* description: Code and state are required.
|
|
*/
|
|
router.get("/oidc/callback", async (req, res) => {
|
|
const { code, state } = req.query;
|
|
|
|
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
|
|
return res.status(400).json({ error: "Code and state are required" });
|
|
}
|
|
|
|
const storedBackendCallbackRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`oidc_backend_callback_${state}`);
|
|
const storedFrontendOriginRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`oidc_frontend_origin_${state}`);
|
|
const storedRememberMeRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`oidc_remember_me_${state}`);
|
|
|
|
if (!storedBackendCallbackRow || !storedFrontendOriginRow) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Invalid state parameter - redirect URIs not found" });
|
|
}
|
|
|
|
const backendCallbackUri = (
|
|
storedBackendCallbackRow as Record<string, unknown>
|
|
).value as string;
|
|
const frontendOrigin = (storedFrontendOriginRow as Record<string, unknown>)
|
|
.value as string;
|
|
const storedRememberMe =
|
|
(storedRememberMeRow as Record<string, unknown> | null)?.value === "true";
|
|
|
|
try {
|
|
const storedNonce = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`oidc_state_${state}`);
|
|
if (!storedNonce) {
|
|
return res.status(400).json({ error: "Invalid state parameter" });
|
|
}
|
|
|
|
const envConfig = getOIDCConfigFromEnv();
|
|
let config;
|
|
|
|
if (envConfig) {
|
|
config = envConfig;
|
|
} else {
|
|
const configRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
|
.get();
|
|
if (!configRow) {
|
|
return res.status(500).json({ error: "OIDC not configured" });
|
|
}
|
|
config = JSON.parse(
|
|
(configRow as Record<string, unknown>).value as string,
|
|
);
|
|
|
|
if (config.client_secret?.startsWith("encrypted:")) {
|
|
config.client_secret = Buffer.from(
|
|
config.client_secret.substring(10),
|
|
"base64",
|
|
).toString("utf8");
|
|
} else if (config.client_secret?.startsWith("encoded:")) {
|
|
config.client_secret = Buffer.from(
|
|
config.client_secret.substring(8),
|
|
"base64",
|
|
).toString("utf8");
|
|
}
|
|
}
|
|
|
|
const tokenResponse = await fetch(config.token_url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: config.client_id,
|
|
client_secret: config.client_secret,
|
|
code: code,
|
|
redirect_uri: backendCallbackUri,
|
|
}),
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorText = await tokenResponse.text();
|
|
authLogger.error("OIDC token exchange failed", {
|
|
operation: "oidc_token_exchange_failed",
|
|
status: tokenResponse.status,
|
|
statusText: tokenResponse.statusText,
|
|
backendCallbackUri,
|
|
frontendOrigin,
|
|
errorResponse: errorText,
|
|
});
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Failed to exchange authorization code" });
|
|
}
|
|
|
|
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
|
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`oidc_state_${state}`);
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`oidc_backend_callback_${state}`);
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`oidc_frontend_origin_${state}`);
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`oidc_remember_me_${state}`);
|
|
|
|
let userInfo: Record<string, unknown> = null;
|
|
const userInfoUrls: string[] = [];
|
|
|
|
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
|
|
? config.issuer_url.slice(0, -1)
|
|
: config.issuer_url;
|
|
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "");
|
|
|
|
try {
|
|
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
|
const discoveryResponse = await fetch(discoveryUrl);
|
|
if (discoveryResponse.ok) {
|
|
const discovery = (await discoveryResponse.json()) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
if (discovery.userinfo_endpoint) {
|
|
userInfoUrls.push(discovery.userinfo_endpoint as string);
|
|
}
|
|
}
|
|
} catch (discoveryError) {
|
|
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
|
}
|
|
|
|
if (config.userinfo_url) {
|
|
userInfoUrls.unshift(config.userinfo_url);
|
|
}
|
|
|
|
userInfoUrls.push(
|
|
`${baseUrl}/userinfo/`,
|
|
`${baseUrl}/userinfo`,
|
|
`${normalizedIssuerUrl}/userinfo/`,
|
|
`${normalizedIssuerUrl}/userinfo`,
|
|
`${baseUrl}/oauth2/userinfo/`,
|
|
`${baseUrl}/oauth2/userinfo`,
|
|
`${normalizedIssuerUrl}/oauth2/userinfo/`,
|
|
`${normalizedIssuerUrl}/oauth2/userinfo`,
|
|
);
|
|
|
|
if (tokenData.id_token) {
|
|
try {
|
|
userInfo = await verifyOIDCToken(
|
|
tokenData.id_token as string,
|
|
config.issuer_url,
|
|
config.client_id,
|
|
);
|
|
} catch {
|
|
try {
|
|
const parts = (tokenData.id_token as string).split(".");
|
|
if (parts.length === 3) {
|
|
const payload = JSON.parse(
|
|
Buffer.from(parts[1], "base64").toString(),
|
|
);
|
|
userInfo = payload;
|
|
}
|
|
} catch (decodeError) {
|
|
authLogger.error("Failed to decode ID token payload:", decodeError);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!userInfo && tokenData.access_token) {
|
|
for (const userInfoUrl of userInfoUrls) {
|
|
try {
|
|
const userInfoResponse = await fetch(userInfoUrl, {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenData.access_token}`,
|
|
},
|
|
});
|
|
|
|
if (userInfoResponse.ok) {
|
|
userInfo = (await userInfoResponse.json()) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
break;
|
|
} else {
|
|
authLogger.error(
|
|
`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!userInfo) {
|
|
authLogger.error("Failed to get user information from all sources");
|
|
authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(", ")}`);
|
|
authLogger.error(`Token data keys: ${Object.keys(tokenData).join(", ")}`);
|
|
authLogger.error(`Has id_token: ${!!tokenData.id_token}`);
|
|
authLogger.error(`Has access_token: ${!!tokenData.access_token}`);
|
|
return res.status(400).json({ error: "Failed to get user information" });
|
|
}
|
|
|
|
const getNestedValue = (
|
|
obj: Record<string, unknown>,
|
|
path: string,
|
|
): unknown => {
|
|
if (!path || !obj) return null;
|
|
return path.split(".").reduce((current, key) => current?.[key], obj);
|
|
};
|
|
|
|
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
|
|
userInfo[config.identifier_path] ||
|
|
userInfo.sub ||
|
|
userInfo.email ||
|
|
userInfo.preferred_username) as string;
|
|
|
|
const name = (getNestedValue(userInfo, config.name_path) ||
|
|
userInfo[config.name_path] ||
|
|
userInfo.name ||
|
|
userInfo.given_name ||
|
|
identifier) as string;
|
|
|
|
if (!identifier) {
|
|
authLogger.error(
|
|
`Identifier not found at path: ${config.identifier_path}`,
|
|
);
|
|
authLogger.error(`Available fields: ${Object.keys(userInfo).join(", ")}`);
|
|
return res.status(400).json({
|
|
error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
|
|
});
|
|
}
|
|
|
|
const deviceInfo = parseUserAgent(req);
|
|
let user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.oidcIdentifier, identifier));
|
|
|
|
let isFirstUser = false;
|
|
if (!user || user.length === 0) {
|
|
const preCheckCount = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get();
|
|
isFirstUser = ((preCheckCount as { count?: number })?.count || 0) === 0;
|
|
|
|
if (!isFirstUser && config.allowed_users) {
|
|
const email = userInfo.email as string | undefined;
|
|
if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) {
|
|
authLogger.warn("OIDC user not in allowed list", {
|
|
operation: "oidc_user_not_allowed",
|
|
identifier,
|
|
email,
|
|
});
|
|
const redirectUrl = new URL(frontendOrigin);
|
|
redirectUrl.searchParams.set("error", "user_not_allowed");
|
|
return res.redirect(redirectUrl.toString());
|
|
}
|
|
}
|
|
|
|
let oidcAutoProvision = false;
|
|
try {
|
|
const oidcProvRow = db.$client
|
|
.prepare(
|
|
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
|
|
)
|
|
.get();
|
|
if (oidcProvRow) {
|
|
oidcAutoProvision =
|
|
(oidcProvRow as Record<string, unknown>).value === "true";
|
|
}
|
|
} catch {
|
|
// fall through to env var check
|
|
}
|
|
|
|
if (!oidcAutoProvision) {
|
|
oidcAutoProvision =
|
|
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
|
"true";
|
|
}
|
|
|
|
if (!isFirstUser && !oidcAutoProvision) {
|
|
try {
|
|
const regRow = db.$client
|
|
.prepare(
|
|
"SELECT value FROM settings WHERE key = 'allow_registration'",
|
|
)
|
|
.get();
|
|
if (regRow && (regRow as Record<string, unknown>).value !== "true") {
|
|
authLogger.warn(
|
|
"OIDC user attempted to register when registration is disabled",
|
|
{
|
|
operation: "oidc_registration_disabled",
|
|
identifier,
|
|
name,
|
|
},
|
|
);
|
|
|
|
const redirectUrl = new URL(frontendOrigin);
|
|
redirectUrl.searchParams.set("error", "registration_disabled");
|
|
|
|
return res.redirect(redirectUrl.toString());
|
|
}
|
|
} catch (e) {
|
|
authLogger.warn("Failed to check registration status during OIDC", {
|
|
operation: "oidc_registration_check",
|
|
error: e,
|
|
});
|
|
}
|
|
}
|
|
|
|
const id = nanoid();
|
|
isFirstUser = db.$client.transaction(() => {
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get() as { count?: number };
|
|
const first = (countResult?.count || 0) === 0;
|
|
db.$client
|
|
.prepare(
|
|
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(
|
|
id,
|
|
name,
|
|
"",
|
|
first ? 1 : 0,
|
|
1,
|
|
identifier,
|
|
String(config.client_id),
|
|
String(config.client_secret),
|
|
String(config.issuer_url),
|
|
String(config.authorization_url),
|
|
String(config.token_url),
|
|
String(config.identifier_path),
|
|
String(config.name_path),
|
|
String(config.scopes),
|
|
);
|
|
return first;
|
|
})();
|
|
|
|
try {
|
|
const defaultRoleName = isFirstUser ? "admin" : "user";
|
|
const defaultRole = await db
|
|
.select({ id: roles.id })
|
|
.from(roles)
|
|
.where(eq(roles.name, defaultRoleName))
|
|
.limit(1);
|
|
|
|
if (defaultRole.length > 0) {
|
|
await db.insert(userRoles).values({
|
|
userId: id,
|
|
roleId: defaultRole[0].id,
|
|
grantedBy: id,
|
|
});
|
|
} else {
|
|
authLogger.warn(
|
|
"Default role not found during OIDC user registration",
|
|
{
|
|
operation: "assign_default_role_oidc",
|
|
userId: id,
|
|
roleName: defaultRoleName,
|
|
},
|
|
);
|
|
}
|
|
} catch (roleError) {
|
|
authLogger.error(
|
|
"Failed to assign default role to OIDC user",
|
|
roleError,
|
|
{
|
|
operation: "assign_default_role_oidc",
|
|
userId: id,
|
|
},
|
|
);
|
|
}
|
|
|
|
try {
|
|
const sessionDurationMs =
|
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
|
? 30 * 24 * 60 * 60 * 1000
|
|
: 24 * 60 * 60 * 1000;
|
|
await authManager.registerOIDCUser(id, sessionDurationMs);
|
|
} catch (encryptionError) {
|
|
await db.delete(users).where(eq(users.id, id));
|
|
authLogger.error(
|
|
"Failed to setup OIDC user encryption, user creation rolled back",
|
|
encryptionError,
|
|
{
|
|
operation: "oidc_user_create_encryption_failed",
|
|
userId: id,
|
|
},
|
|
);
|
|
return res.status(500).json({
|
|
error: "Failed to setup user security - user creation cancelled",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
authLogger.error("Failed to persist OIDC user to disk", saveError, {
|
|
operation: "oidc_user_create_save_failed",
|
|
userId: id,
|
|
});
|
|
}
|
|
|
|
user = await db.select().from(users).where(eq(users.id, id));
|
|
} else {
|
|
if (config.allowed_users) {
|
|
const email = userInfo.email as string | undefined;
|
|
if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) {
|
|
authLogger.warn("OIDC user not in allowed list (existing user)", {
|
|
operation: "oidc_user_not_allowed_existing",
|
|
identifier,
|
|
email,
|
|
userId: user[0].id,
|
|
});
|
|
const redirectUrl = new URL(frontendOrigin);
|
|
redirectUrl.searchParams.set("error", "user_not_allowed");
|
|
return res.redirect(redirectUrl.toString());
|
|
}
|
|
}
|
|
|
|
const isDualAuth =
|
|
user[0].passwordHash && user[0].passwordHash.trim() !== "";
|
|
|
|
if (!isDualAuth) {
|
|
await db
|
|
.update(users)
|
|
.set({ username: name })
|
|
.where(eq(users.id, user[0].id));
|
|
}
|
|
|
|
user = await db.select().from(users).where(eq(users.id, user[0].id));
|
|
}
|
|
|
|
const userRecord = user[0];
|
|
|
|
// Sync admin status based on OIDC group membership
|
|
if (config.admin_group) {
|
|
const groups = (userInfo.groups || userInfo.roles || []) as string[];
|
|
const shouldBeAdmin = groups.includes(config.admin_group);
|
|
if (!!userRecord.isAdmin !== shouldBeAdmin) {
|
|
await db
|
|
.update(users)
|
|
.set({ isAdmin: shouldBeAdmin })
|
|
.where(eq(users.id, userRecord.id));
|
|
userRecord.isAdmin = shouldBeAdmin;
|
|
authLogger.info("OIDC admin status synced", {
|
|
operation: "oidc_admin_group_sync",
|
|
userId: userRecord.id,
|
|
group: config.admin_group,
|
|
isAdmin: shouldBeAdmin,
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
|
} catch (setupError) {
|
|
authLogger.error("Failed to setup OIDC user encryption", setupError, {
|
|
operation: "oidc_user_encryption_setup_failed",
|
|
userId: userRecord.id,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const { SharedCredentialManager } =
|
|
await import("../../utils/shared-credential-manager.js");
|
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
|
} catch {
|
|
// expected - re-encryption may fail if no pending credentials
|
|
}
|
|
|
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
|
deviceType: deviceInfo.type,
|
|
deviceInfo: deviceInfo.deviceInfo,
|
|
rememberMe: storedRememberMe,
|
|
});
|
|
|
|
authLogger.success("OIDC login successful", {
|
|
operation: "oidc_login_complete",
|
|
userId: userRecord.id,
|
|
username: userRecord.username,
|
|
});
|
|
|
|
const redirectUrl = new URL(frontendOrigin);
|
|
redirectUrl.searchParams.set("success", "true");
|
|
|
|
const isTokenCallback =
|
|
frontendOrigin.startsWith("http://127.0.0.1:") ||
|
|
frontendOrigin.startsWith("termix-mobile:");
|
|
|
|
const maxAge =
|
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
|
? 30 * 24 * 60 * 60 * 1000
|
|
: storedRememberMe
|
|
? 30 * 24 * 60 * 60 * 1000
|
|
: 24 * 60 * 60 * 1000;
|
|
|
|
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
|
|
|
if (isTokenCallback) {
|
|
redirectUrl.searchParams.set("token", token);
|
|
return res.redirect(redirectUrl.toString());
|
|
}
|
|
|
|
return res
|
|
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
|
.redirect(redirectUrl.toString());
|
|
} catch (err) {
|
|
authLogger.error("OIDC callback failed", err);
|
|
|
|
const redirectUrl = new URL(frontendOrigin);
|
|
redirectUrl.searchParams.set("error", "OIDC authentication failed");
|
|
|
|
res.redirect(redirectUrl.toString());
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/login:
|
|
* post:
|
|
* summary: User login
|
|
* description: Authenticates a user and returns a JWT.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* username:
|
|
* type: string
|
|
* password:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Login successful.
|
|
* 400:
|
|
* description: Invalid username or password.
|
|
* 401:
|
|
* description: Invalid username or password.
|
|
* 403:
|
|
* description: Password authentication is currently disabled.
|
|
* 429:
|
|
* description: Too many login attempts.
|
|
* 500:
|
|
* description: Login failed.
|
|
*/
|
|
router.post("/login", async (req, res) => {
|
|
const { username, password, rememberMe } = req.body;
|
|
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
|
authLogger.info("User login request received", {
|
|
operation: "user_login_request",
|
|
username,
|
|
});
|
|
|
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
|
authLogger.warn("Invalid traditional login attempt", {
|
|
operation: "user_login",
|
|
hasUsername: !!username,
|
|
hasPassword: !!password,
|
|
});
|
|
return res.status(400).json({ error: "Invalid username or password" });
|
|
}
|
|
|
|
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
|
|
if (lockStatus.locked) {
|
|
authLogger.warn("Login attempt blocked due to rate limiting", {
|
|
operation: "user_login_blocked",
|
|
username,
|
|
ip: clientIp,
|
|
remainingTime: lockStatus.remainingTime,
|
|
});
|
|
return res.status(429).json({
|
|
error: "Too many login attempts. Please try again later.",
|
|
remainingTime: lockStatus.remainingTime,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
|
.get();
|
|
if (row && (row as { value: string }).value !== "true") {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Password authentication is currently disabled" });
|
|
}
|
|
} catch (e) {
|
|
authLogger.error("Failed to check password login status", {
|
|
operation: "login_check",
|
|
error: e,
|
|
});
|
|
return res.status(500).json({ error: "Failed to check login status" });
|
|
}
|
|
|
|
try {
|
|
const user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
|
|
if (!user || user.length === 0) {
|
|
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
|
authLogger.warn(`Login failed: user not found`, {
|
|
operation: "user_login",
|
|
username,
|
|
ip: clientIp,
|
|
remainingAttempts: loginRateLimiter.getRemainingAttempts(
|
|
clientIp,
|
|
username,
|
|
),
|
|
});
|
|
return res.status(401).json({ error: "Invalid username or password" });
|
|
}
|
|
|
|
const userRecord = user[0];
|
|
|
|
if (
|
|
userRecord.isOidc &&
|
|
(!userRecord.passwordHash || userRecord.passwordHash.trim() === "")
|
|
) {
|
|
authLogger.warn("OIDC-only user attempted traditional login", {
|
|
operation: "user_login",
|
|
username,
|
|
userId: userRecord.id,
|
|
});
|
|
return res
|
|
.status(403)
|
|
.json({ error: "This user uses external authentication" });
|
|
}
|
|
|
|
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
|
if (!isMatch) {
|
|
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
|
authLogger.warn(`Login failed: incorrect password`, {
|
|
operation: "user_login",
|
|
username,
|
|
userId: userRecord.id,
|
|
ip: clientIp,
|
|
remainingAttempts: loginRateLimiter.getRemainingAttempts(
|
|
clientIp,
|
|
username,
|
|
),
|
|
});
|
|
return res.status(401).json({ error: "Invalid username or password" });
|
|
}
|
|
|
|
try {
|
|
const kekSalt = await db
|
|
.select()
|
|
.from(settings)
|
|
.where(eq(settings.key, `user_kek_salt_${userRecord.id}`));
|
|
|
|
if (kekSalt.length === 0) {
|
|
await authManager.registerUser(userRecord.id, password);
|
|
}
|
|
} catch {
|
|
// expected - KEK salt registration may fail for existing users
|
|
}
|
|
|
|
const deviceInfo = parseUserAgent(req);
|
|
|
|
let dataUnlocked = false;
|
|
if (userRecord.isOidc) {
|
|
dataUnlocked = await authManager.authenticateOIDCUser(
|
|
userRecord.id,
|
|
deviceInfo.type,
|
|
);
|
|
} else {
|
|
dataUnlocked = await authManager.authenticateUser(
|
|
userRecord.id,
|
|
password,
|
|
deviceInfo.type,
|
|
);
|
|
}
|
|
|
|
if (!dataUnlocked) {
|
|
return res.status(401).json({ error: "Incorrect password" });
|
|
}
|
|
|
|
try {
|
|
const { SharedCredentialManager } =
|
|
await import("../../utils/shared-credential-manager.js");
|
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
|
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
|
} catch (error) {
|
|
authLogger.warn("Failed to re-encrypt pending shared credentials", {
|
|
operation: "reencrypt_pending_credentials",
|
|
userId: userRecord.id,
|
|
error,
|
|
});
|
|
}
|
|
|
|
if (userRecord.totpEnabled) {
|
|
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
|
|
|
const isTrusted = await authManager.isTrustedDevice(
|
|
userRecord.id,
|
|
deviceFingerprint,
|
|
);
|
|
|
|
if (isTrusted) {
|
|
authLogger.info("TOTP bypassed for trusted device", {
|
|
operation: "totp_bypass",
|
|
userId: userRecord.id,
|
|
deviceFingerprint,
|
|
});
|
|
} else {
|
|
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
|
pendingTOTP: true,
|
|
expiresIn: "10m",
|
|
});
|
|
return res.json({
|
|
success: true,
|
|
requires_totp: true,
|
|
temp_token: tempToken,
|
|
rememberMe: !!rememberMe,
|
|
});
|
|
}
|
|
}
|
|
|
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
|
rememberMe: !!rememberMe,
|
|
deviceType: deviceInfo.type,
|
|
deviceInfo: deviceInfo.deviceInfo,
|
|
});
|
|
|
|
loginRateLimiter.resetAttempts(clientIp, username);
|
|
|
|
const payload = await authManager.verifyJWTToken(token);
|
|
authLogger.success("User login successful", {
|
|
operation: "user_login_complete",
|
|
userId: userRecord.id,
|
|
username,
|
|
sessionId: payload?.sessionId,
|
|
});
|
|
|
|
const response: Record<string, unknown> = {
|
|
success: true,
|
|
is_admin: !!userRecord.isAdmin,
|
|
username: userRecord.username,
|
|
...(isNativeAppRequest(req) ? { token } : {}),
|
|
};
|
|
|
|
const timeoutRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
|
.get() as { value: string } | undefined;
|
|
const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24;
|
|
const maxAge = rememberMe
|
|
? 30 * 24 * 60 * 60 * 1000
|
|
: timeoutHours * 60 * 60 * 1000;
|
|
|
|
return res
|
|
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
|
.json(response);
|
|
} catch (err) {
|
|
authLogger.error("Failed to log in user", err);
|
|
return res.status(500).json({ error: "Login failed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/logout:
|
|
* post:
|
|
* summary: User logout
|
|
* description: Logs out the user and clears the JWT cookie.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Logged out successfully.
|
|
* 500:
|
|
* description: Logout failed.
|
|
*/
|
|
router.post("/logout", authenticateJWT, async (req, res) => {
|
|
try {
|
|
const authReq = req as AuthenticatedRequest;
|
|
const userId = authReq.userId;
|
|
|
|
if (userId) {
|
|
const sessionId = authReq.sessionId;
|
|
|
|
await authManager.logoutUser(userId, sessionId);
|
|
authLogger.info("User logged out", {
|
|
operation: "user_logout",
|
|
userId,
|
|
sessionId,
|
|
});
|
|
}
|
|
|
|
return res
|
|
.clearCookie("jwt", authManager.getClearCookieOptions(req))
|
|
.json({ success: true, message: "Logged out successfully" });
|
|
} catch (err) {
|
|
authLogger.error("Logout failed", err);
|
|
return res.status(500).json({ error: "Logout failed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/me:
|
|
* get:
|
|
* summary: Get current user's info
|
|
* description: Retrieves information about the currently authenticated user.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: User information.
|
|
* 401:
|
|
* description: Invalid userId or user not found.
|
|
* 500:
|
|
* description: Failed to get username.
|
|
*/
|
|
router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
authLogger.warn("Invalid userId in JWT for /users/me");
|
|
return res.status(401).json({ error: "Invalid userId" });
|
|
}
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0) {
|
|
authLogger.warn(`User not found for /users/me: ${userId}`);
|
|
return res.status(401).json({ error: "User not found" });
|
|
}
|
|
|
|
const hasPassword =
|
|
user[0].passwordHash && user[0].passwordHash.trim() !== "";
|
|
const hasOidc = user[0].isOidc && user[0].oidcIdentifier;
|
|
const isDualAuth = hasPassword && hasOidc;
|
|
|
|
res.json({
|
|
userId: user[0].id,
|
|
username: user[0].username,
|
|
is_admin: !!user[0].isAdmin,
|
|
is_oidc: !!user[0].isOidc,
|
|
is_dual_auth: isDualAuth,
|
|
totp_enabled: !!user[0].totpEnabled,
|
|
data_unlocked: authManager.isUserUnlocked(userId),
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get username", err);
|
|
res.status(500).json({ error: "Failed to get username" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/me/token:
|
|
* get:
|
|
* summary: Get current session token
|
|
* description: Returns the JWT for the currently authenticated session. Intended for mobile WebView clients that cannot read HTTP-only cookies.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Current session token.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* token:
|
|
* type: string
|
|
* 401:
|
|
* description: Not authenticated.
|
|
*/
|
|
router.get("/me/token", authenticateJWT, (req: Request, res: Response) => {
|
|
const token = (req as Request & { cookies: Record<string, string> }).cookies
|
|
?.jwt;
|
|
res.json({ token: token || null });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/setup-required:
|
|
* get:
|
|
* summary: Check if setup is required
|
|
* description: Checks if the system requires initial setup (i.e., no users exist).
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Setup status.
|
|
* 500:
|
|
* description: Failed to check setup status.
|
|
*/
|
|
router.get("/setup-required", async (req, res) => {
|
|
try {
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get();
|
|
const count = (countResult as { count?: number })?.count || 0;
|
|
|
|
res.json({
|
|
setup_required: count === 0,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to check setup status", err);
|
|
res.status(500).json({ error: "Failed to check setup status" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/count:
|
|
* get:
|
|
* summary: Count users
|
|
* description: Returns the total number of users in the system.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: User count.
|
|
* 403:
|
|
* description: Admin access required.
|
|
* 500:
|
|
* description: Failed to count users.
|
|
*/
|
|
router.get("/count", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user[0] || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Admin access required" });
|
|
}
|
|
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get();
|
|
const count = (countResult as { count?: number })?.count || 0;
|
|
res.json({ count });
|
|
} catch (err) {
|
|
authLogger.error("Failed to count users", err);
|
|
res.status(500).json({ error: "Failed to count users" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/db-health:
|
|
* get:
|
|
* summary: Database health check
|
|
* description: Checks if the database is accessible.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Database is accessible.
|
|
* 500:
|
|
* description: Database not accessible.
|
|
*/
|
|
router.get("/db-health", requireAdmin, async (req, res) => {
|
|
try {
|
|
db.$client.prepare("SELECT 1").get();
|
|
res.json({ status: "ok" });
|
|
} catch (err) {
|
|
authLogger.error("DB health check failed", err);
|
|
res.status(500).json({ error: "Database not accessible" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/registration-allowed:
|
|
* get:
|
|
* summary: Get registration status
|
|
* description: Checks if user registration is currently allowed.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Registration status.
|
|
* 500:
|
|
* description: Failed to get registration allowed status.
|
|
*/
|
|
router.get("/registration-allowed", async (req, res) => {
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
|
.get();
|
|
res.json({
|
|
allowed: row ? (row as Record<string, unknown>).value === "true" : true,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get registration allowed", err);
|
|
res.status(500).json({ error: "Failed to get registration allowed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/registration-allowed:
|
|
* patch:
|
|
* summary: Set registration status
|
|
* description: Enables or disables user registration.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* allowed:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Registration status updated.
|
|
* 400:
|
|
* description: Invalid value for allowed.
|
|
* 403:
|
|
* description: Not authorized.
|
|
* 500:
|
|
* description: Failed to set registration allowed status.
|
|
*/
|
|
router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { allowed } = req.body;
|
|
if (typeof allowed !== "boolean") {
|
|
return res.status(400).json({ error: "Invalid value for allowed" });
|
|
}
|
|
db.$client
|
|
.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'")
|
|
.run(allowed ? "true" : "false");
|
|
res.json({ allowed });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set registration allowed", err);
|
|
res.status(500).json({ error: "Failed to set registration allowed" });
|
|
}
|
|
});
|
|
|
|
router.get("/oidc-auto-provision", async (_req, res) => {
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'")
|
|
.get();
|
|
res.json({
|
|
enabled: row ? (row as Record<string, unknown>).value === "true" : false,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get OIDC auto-provision setting", err);
|
|
res
|
|
.status(500)
|
|
.json({ error: "Failed to get OIDC auto-provision setting" });
|
|
}
|
|
});
|
|
|
|
router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { enabled } = req.body;
|
|
if (typeof enabled !== "boolean") {
|
|
return res.status(400).json({ error: "Invalid value for enabled" });
|
|
}
|
|
const existing = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'")
|
|
.get();
|
|
if (existing) {
|
|
db.$client
|
|
.prepare(
|
|
"UPDATE settings SET value = ? WHERE key = 'oidc_auto_provision'",
|
|
)
|
|
.run(enabled ? "true" : "false");
|
|
} else {
|
|
db.$client
|
|
.prepare(
|
|
"INSERT INTO settings (key, value) VALUES ('oidc_auto_provision', ?)",
|
|
)
|
|
.run(enabled ? "true" : "false");
|
|
}
|
|
res.json({ enabled });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set OIDC auto-provision", err);
|
|
res.status(500).json({ error: "Failed to set OIDC auto-provision" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/password-login-allowed:
|
|
* get:
|
|
* summary: Get password login status
|
|
* description: Checks if password-based login is currently allowed.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Password login status.
|
|
* 500:
|
|
* description: Failed to get password login allowed status.
|
|
*/
|
|
router.get("/password-login-allowed", async (req, res) => {
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
|
.get();
|
|
res.json({
|
|
allowed: row ? (row as { value: string }).value === "true" : true,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get password login allowed", err);
|
|
res.status(500).json({ error: "Failed to get password login allowed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/password-login-allowed:
|
|
* patch:
|
|
* summary: Set password login status
|
|
* description: Enables or disables password-based login.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* allowed:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Password login status updated.
|
|
* 400:
|
|
* description: Invalid value for allowed.
|
|
* 403:
|
|
* description: Not authorized.
|
|
* 500:
|
|
* description: Failed to set password login allowed status.
|
|
*/
|
|
router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { allowed } = req.body;
|
|
if (typeof allowed !== "boolean") {
|
|
return res.status(400).json({ error: "Invalid value for allowed" });
|
|
}
|
|
db.$client
|
|
.prepare(
|
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
|
|
)
|
|
.run(allowed ? "true" : "false");
|
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
|
await saveMemoryDatabaseToFile();
|
|
res.json({ allowed });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set password login allowed", err);
|
|
res.status(500).json({ error: "Failed to set password login allowed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/password-reset-allowed:
|
|
* get:
|
|
* summary: Get password reset status
|
|
* description: Checks if password reset is currently allowed.
|
|
* tags:
|
|
* - Users
|
|
* responses:
|
|
* 200:
|
|
* description: Password reset status.
|
|
* 500:
|
|
* description: Failed to get password reset allowed status.
|
|
*/
|
|
router.get("/password-reset-allowed", async (req, res) => {
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'")
|
|
.get();
|
|
res.json({
|
|
allowed: row ? (row as { value: string }).value === "true" : true,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get password reset allowed", err);
|
|
res.status(500).json({ error: "Failed to get password reset allowed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/password-reset-allowed:
|
|
* patch:
|
|
* summary: Set password reset status
|
|
* description: Enables or disables password reset.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* allowed:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Password reset status updated.
|
|
* 400:
|
|
* description: Invalid value for allowed.
|
|
* 403:
|
|
* description: Not authorized.
|
|
* 500:
|
|
* description: Failed to set password reset allowed status.
|
|
*/
|
|
router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
const { allowed } = req.body;
|
|
if (typeof allowed !== "boolean") {
|
|
return res.status(400).json({ error: "Invalid value for allowed" });
|
|
}
|
|
db.$client
|
|
.prepare(
|
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_reset', ?)",
|
|
)
|
|
.run(allowed ? "true" : "false");
|
|
res.json({ allowed });
|
|
} catch (err) {
|
|
authLogger.error("Failed to set password reset allowed", err);
|
|
res.status(500).json({ error: "Failed to set password reset allowed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/delete-account:
|
|
* delete:
|
|
* summary: Delete user account
|
|
* description: Deletes the authenticated user's account.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* password:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Account deleted successfully.
|
|
* 400:
|
|
* description: Password is required.
|
|
* 401:
|
|
* description: Incorrect password.
|
|
* 403:
|
|
* description: Cannot delete external authentication accounts or the last admin user.
|
|
* 404:
|
|
* description: User not found.
|
|
* 500:
|
|
* description: Failed to delete account.
|
|
*/
|
|
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { password } = req.body;
|
|
|
|
if (!isNonEmptyString(password)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Password is required to delete account" });
|
|
}
|
|
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
const userRecord = user[0];
|
|
|
|
if (userRecord.isOidc) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Cannot delete external authentication accounts through this endpoint",
|
|
});
|
|
}
|
|
|
|
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
|
if (!isMatch) {
|
|
authLogger.warn(
|
|
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
|
);
|
|
return res.status(401).json({ error: "Incorrect password" });
|
|
}
|
|
|
|
if (userRecord.isAdmin) {
|
|
const adminCount = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1")
|
|
.get();
|
|
if (((adminCount as { count?: number })?.count || 0) <= 1) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Cannot delete the last admin user" });
|
|
}
|
|
}
|
|
|
|
await db.delete(users).where(eq(users.id, userId));
|
|
|
|
authLogger.success(`User account deleted: ${userRecord.username}`);
|
|
res.json({ message: "Account deleted successfully" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to delete user account", err);
|
|
res.status(500).json({ error: "Failed to delete account" });
|
|
}
|
|
});
|
|
|
|
registerUserPasswordResetRoutes(router, { authManager });
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/change-password:
|
|
* post:
|
|
* summary: Change user password
|
|
* description: Changes the authenticated user's password.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* oldPassword:
|
|
* type: string
|
|
* newPassword:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Password changed successfully.
|
|
* 400:
|
|
* description: Old and new passwords are required.
|
|
* 401:
|
|
* description: Incorrect current password.
|
|
* 500:
|
|
* description: Failed to update password and re-encrypt data.
|
|
*/
|
|
router.post("/change-password", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { oldPassword, newPassword } = req.body;
|
|
authLogger.info("Password change request", {
|
|
operation: "password_change_request",
|
|
userId,
|
|
});
|
|
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "User not authenticated" });
|
|
}
|
|
|
|
if (!oldPassword || !newPassword) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Old and new passwords are required." });
|
|
}
|
|
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
const isMatch = await bcrypt.compare(oldPassword, user[0].passwordHash);
|
|
if (!isMatch) {
|
|
authLogger.warn("Password change failed - old password incorrect", {
|
|
operation: "password_change_failed",
|
|
userId,
|
|
reason: "old_password_wrong",
|
|
});
|
|
return res.status(401).json({ error: "Incorrect current password" });
|
|
}
|
|
|
|
const success = await authManager.changeUserPassword(
|
|
userId,
|
|
oldPassword,
|
|
newPassword,
|
|
);
|
|
if (!success) {
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to update password and re-encrypt data." });
|
|
}
|
|
|
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
|
await db
|
|
.update(users)
|
|
.set({ passwordHash: password_hash })
|
|
.where(eq(users.id, userId));
|
|
|
|
authManager.logoutUser(userId);
|
|
authLogger.success("Password changed successfully", {
|
|
operation: "password_change_complete",
|
|
userId,
|
|
});
|
|
|
|
res.json({ message: "Password changed successfully. Please log in again." });
|
|
});
|
|
|
|
registerUserAdminRoutes(router, authenticateJWT);
|
|
|
|
registerUserTotpRoutes(router, {
|
|
authenticateJWT,
|
|
authManager,
|
|
isNativeAppRequest,
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/delete-user:
|
|
* delete:
|
|
* summary: Delete user (admin only)
|
|
* description: Allows an admin to delete another user and all related data.
|
|
* tags:
|
|
* - Users
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* username:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: User deleted successfully.
|
|
* 400:
|
|
* description: Username is required or cannot delete yourself.
|
|
* 403:
|
|
* description: Not authorized or cannot delete last admin.
|
|
* 404:
|
|
* description: User not found.
|
|
* 500:
|
|
* description: Failed to delete user.
|
|
*/
|
|
router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { username } = req.body;
|
|
|
|
if (!isNonEmptyString(username)) {
|
|
return res.status(400).json({ error: "Username is required" });
|
|
}
|
|
|
|
try {
|
|
const adminUser = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
if (adminUser[0].username === username) {
|
|
return res.status(400).json({ error: "Cannot delete your own account" });
|
|
}
|
|
|
|
const targetUser = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
if (!targetUser || targetUser.length === 0) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
if (targetUser[0].isAdmin) {
|
|
const adminCount = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1")
|
|
.get();
|
|
if (((adminCount as { count?: number })?.count || 0) <= 1) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Cannot delete the last admin user" });
|
|
}
|
|
}
|
|
|
|
const targetUserId = targetUser[0].id;
|
|
|
|
await deleteUserAndRelatedData(targetUserId);
|
|
|
|
authLogger.warn("User account deleted by admin", {
|
|
operation: "admin_delete_user",
|
|
adminId: userId,
|
|
targetUserId,
|
|
targetUsername: username,
|
|
});
|
|
res.json({ message: `User ${username} deleted successfully` });
|
|
} catch (err) {
|
|
authLogger.error("Failed to delete user", err);
|
|
|
|
if (err && typeof err === "object" && "code" in err) {
|
|
if (err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
|
|
res.status(400).json({
|
|
error:
|
|
"Cannot delete user: User has associated data that cannot be removed",
|
|
});
|
|
} else {
|
|
res.status(500).json({ error: `Database error: ${err.code}` });
|
|
}
|
|
} else {
|
|
res.status(500).json({ error: "Failed to delete account" });
|
|
}
|
|
}
|
|
});
|
|
|
|
registerUserDataAccessRoutes(router, {
|
|
authenticateJWT,
|
|
authManager,
|
|
});
|
|
|
|
registerUserSessionRoutes(router, {
|
|
authenticateJWT,
|
|
authManager,
|
|
});
|
|
|
|
registerUserOidcAccountRoutes(router, {
|
|
authenticateJWT,
|
|
authManager,
|
|
});
|
|
|
|
registerUserSettingsRoutes(router, authenticateJWT);
|
|
|
|
registerUserApiKeyRoutes(router, requireAdmin);
|
|
|
|
export default router;
|