* chore: add engineering improvements - Configure Prettier with unified code style rules - Add husky + lint-staged for automated pre-commit checks - Add commitlint to enforce conventional commit messages - Add PR check workflow for CI automation - Auto-format all files with Prettier - Fix TypeScript any types in field-crypto.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: enhance development environment - Add .editorconfig for unified editor settings - Add .nvmrc to specify Node.js version (20) - Add useful npm scripts: format, format:check, lint, lint:fix, type-check * chore: add IDE and Git configuration - Add VS Code workspace settings for consistent development experience - Add VS Code extension recommendations (ESLint, Prettier, EditorConfig) - Add .gitattributes to enforce LF line endings * refactor: clean up unused variables and empty blocks - database.ts: Remove unused variables (authManager, format, HTTPS_PORT, etc.) - database.ts: Fix empty catch blocks with descriptive comments - database.ts: Add eslint-disable for required middleware parameter - db/index.ts: Remove unused variables and fix empty catch blocks - Temporarily remove ESLint from pre-commit to allow incremental fixes Reduced total errors from 947 to 913 (34 fixes) * refactor: clean up unused variables and empty blocks in routes Routes updated: - credentials.ts: Remove 12 unused variables/imports - alerts.ts: Remove 1 unused variable - users.ts: Remove 9 unused variables/imports Changes: - Remove unused imports (NextFunction, jwt, UserCrypto, detectKeyType) - Fix empty catch blocks with descriptive comments - Prefix reserved parameters with underscore - Clean up unused error variables in catch blocks Reduced errors from 913 to 886 (27 fixes) * refactor: clean up unused variables in routes/ssh.ts - Remove unused imports (NextFunction, jwt) - Remove 6 unused variables (result, updateResult, name x3) - All 8 no-unused-vars errors fixed * refactor: clean up unused variables and empty blocks in file-manager.ts - Remove 22 unused variables (linkCount, hostId, userId, content, escapedTempFile, index, code) - Fix 1 empty catch block - Simplify multiple route handlers by removing unused destructured parameters Reduced errors from 878 to 855 (23 fixes) * refactor: clean up unused variables and empty blocks in utils database-migration.ts: - Remove 3 unused variables (encryptedSize, totalOriginalRows, totalMemoryRows) lazy-field-encryption.ts: - Fix 6 empty catch blocks with descriptive comments - Keep error variables where they are used in logging tunnel.ts: - Fix multiple empty catch blocks - Remove empty else blocks - Partially fixed (10/21 issues resolved) Reduced errors from 855 to 833 (22 fixes) * fix: restore error variable in catch block for logging Fix TypeScript error where error variable was removed from catch block but still used in logging statements. The error variable is needed for proper error logging and re-throwing. * fix: clean up tunnel.ts empty blocks and unused variables 移除了 tunnel.ts 中的空块和未使用的变量: - 移除 2 个空 else 块 - 修复 2 个空 if 块并添加注释 - 修复空错误处理器并添加注释 - 将未使用的 err 参数重命名为 _err 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty blocks and unused variables in backend utils 修复了后端工具文件中的空块和未使用的变量: - auth-manager.ts: 移除空 else 块 - system-crypto.ts: 修复空 catch 块并添加注释 - starter.ts: 修复空 catch 块并添加注释 - server-stats.ts: 将未使用的 reject 参数重命名为 _reject - credentials.ts: 将 connectionTimeout 从 let 改为 const 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in frontend components 修复了前端组件中的空 catch 块: - Tunnel.tsx: 修复空 catch 块并添加注释 - ServerConfig.tsx: 修复空 catch 块并添加注释 - TerminalKeyboard.tsx: 修复空 catch 块并添加注释 - system-crypto.ts: 修复遗漏的空 catch 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in backend utilities 修复了后端工具文件中的 10 个空 catch 块: - system-crypto.ts: 修复 1 个空 catch 块 - server-stats.ts: 修复 4 个空 catch 块 - auto-ssl-setup.ts: 修复 1 个空 catch 块 - ssh-key-utils.ts: 修复 4 个空 catch 块 所有空块都添加了描述性注释说明为何忽略错误。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in UI hooks and components 修复了 5 个 UI 组件和 hooks 中的空 catch 块: - useDragToSystemDesktop.ts: 修复 2 个空 catch 块 - HomepageAuth.tsx: 修复 1 个空 catch 块 - HostManagerEditor.tsx: 修复 2 个空 catch 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty blocks in file manager and credential editor 修复了 5 个空块: - FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块 - CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up all empty catch blocks in Terminal components 修复了 Terminal 组件中的所有 8 个空 catch 块: - Desktop/Apps/Terminal/Terminal.tsx: 修复 5 个空 catch 块 - Mobile/Apps/Terminal/Terminal.tsx: 修复 3 个空 catch 块 所有空块都添加了描述性注释。这是空块修复的最后一批。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove useless try/catch wrappers 移除了 3 个无用的 try/catch 包装器: - users.ts: 移除只重新抛出错误的外层 try/catch - FileManager.tsx: 移除只重新抛出错误的内层 try/catch - DiffViewer.tsx: 移除只重新抛出错误的内层 try/catch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove unused imports and mark unused parameters 移除了未使用的导入和标记未使用的参数: - auto-ssl-setup.ts: 移除未使用的 crypto 导入 - user-crypto.ts: 移除未使用的 users 导入 - user-data-import.ts: 移除未使用的 nanoid 导入 - simple-db-ops.ts: 标记未使用的 userId 和 tableName 参数 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unnecessary escape characters in regex patterns 移除了正则表达式中不必要的转义字符: - users.ts: 修复 5 个 \/ 不必要的转义 - TabContext.tsx: 修复 1 个 \/ 不必要的转义 在字符串形式的正则表达式中,/ 不需要转义。 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
2124 lines
62 KiB
TypeScript
2124 lines
62 KiB
TypeScript
import express from "express";
|
|
import crypto from "crypto";
|
|
import { db } from "../db/index.js";
|
|
import {
|
|
users,
|
|
sshData,
|
|
fileManagerRecent,
|
|
fileManagerPinned,
|
|
fileManagerShortcuts,
|
|
dismissedAlerts,
|
|
settings,
|
|
} from "../db/schema.js";
|
|
import { eq, and } from "drizzle-orm";
|
|
import bcrypt from "bcryptjs";
|
|
import { nanoid } from "nanoid";
|
|
import speakeasy from "speakeasy";
|
|
import QRCode from "qrcode";
|
|
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 { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
|
|
async function verifyOIDCToken(
|
|
idToken: string,
|
|
issuerUrl: string,
|
|
clientId: string,
|
|
): Promise<any> {
|
|
try {
|
|
const normalizedIssuerUrl = issuerUrl.endsWith("/")
|
|
? issuerUrl.slice(0, -1)
|
|
: issuerUrl;
|
|
const possibleIssuers = [
|
|
issuerUrl,
|
|
normalizedIssuerUrl,
|
|
issuerUrl.replace(/\/application\/o\/[^\/]+$/, ""),
|
|
normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ""),
|
|
];
|
|
|
|
const jwksUrls = [
|
|
`${normalizedIssuerUrl}/.well-known/jwks.json`,
|
|
`${normalizedIssuerUrl}/jwks/`,
|
|
`${normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, "")}/.well-known/jwks.json`,
|
|
];
|
|
|
|
try {
|
|
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
|
const discoveryResponse = await fetch(discoveryUrl);
|
|
if (discoveryResponse.ok) {
|
|
const discovery = (await discoveryResponse.json()) as any;
|
|
if (discovery.jwks_uri) {
|
|
jwksUrls.unshift(discovery.jwks_uri);
|
|
}
|
|
}
|
|
} catch (discoveryError) {
|
|
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
|
}
|
|
|
|
let jwks: any = null;
|
|
let jwksUrl: string | null = null;
|
|
|
|
for (const url of jwksUrls) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const jwksData = (await response.json()) as any;
|
|
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
|
|
jwks = jwksData;
|
|
jwksUrl = url;
|
|
break;
|
|
} else {
|
|
authLogger.error(
|
|
`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`,
|
|
);
|
|
}
|
|
} else {
|
|
}
|
|
} catch (error) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!jwks) {
|
|
throw new Error("Failed to fetch JWKS from any URL");
|
|
}
|
|
|
|
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
|
throw new Error(
|
|
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
|
|
);
|
|
}
|
|
|
|
const header = JSON.parse(
|
|
Buffer.from(idToken.split(".")[0], "base64").toString(),
|
|
);
|
|
const keyId = header.kid;
|
|
|
|
const publicKey = jwks.keys.find((key: any) => key.kid === keyId);
|
|
if (!publicKey) {
|
|
throw new Error(
|
|
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: any) => k.kid).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
const { importJWK, jwtVerify } = await import("jose");
|
|
const key = await importJWK(publicKey);
|
|
|
|
const { payload } = await jwtVerify(idToken, key, {
|
|
issuer: possibleIssuers,
|
|
audience: clientId,
|
|
});
|
|
|
|
return payload;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const router = express.Router();
|
|
|
|
function isNonEmptyString(val: any): val is string {
|
|
return typeof val === "string" && val.trim().length > 0;
|
|
}
|
|
|
|
interface JWTPayload {
|
|
userId: string;
|
|
iat?: number;
|
|
exp?: number;
|
|
}
|
|
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireAdmin = authManager.createAdminMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
|
|
// Route: Create traditional user (username/password)
|
|
// POST /users/create
|
|
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 any).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;
|
|
|
|
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(`Attempt to create duplicate username: ${username}`, {
|
|
operation: "user_create",
|
|
username,
|
|
});
|
|
return res.status(409).json({ error: "Username already exists" });
|
|
}
|
|
|
|
let isFirstUser = false;
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get();
|
|
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
|
|
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
|
const id = nanoid();
|
|
await db.insert(users).values({
|
|
id,
|
|
username,
|
|
password_hash,
|
|
is_admin: isFirstUser,
|
|
is_oidc: false,
|
|
client_id: "",
|
|
client_secret: "",
|
|
issuer_url: "",
|
|
authorization_url: "",
|
|
token_url: "",
|
|
identifier_path: "",
|
|
name_path: "",
|
|
scopes: "openid email profile",
|
|
totp_secret: null,
|
|
totp_enabled: false,
|
|
totp_backup_codes: null,
|
|
});
|
|
|
|
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",
|
|
});
|
|
}
|
|
|
|
authLogger.success(
|
|
`Traditional user created: ${username} (is_admin: ${isFirstUser})`,
|
|
{
|
|
operation: "user_create",
|
|
username,
|
|
isAdmin: isFirstUser,
|
|
userId: id,
|
|
},
|
|
);
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Create OIDC provider configuration (admin only)
|
|
// POST /users/oidc-config
|
|
router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
|
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,
|
|
} = 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",
|
|
};
|
|
|
|
let encryptedConfig;
|
|
try {
|
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (adminDataKey) {
|
|
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
|
encryptedConfig = DataCrypto.encryptRecord(
|
|
"settings",
|
|
configWithId,
|
|
userId,
|
|
adminDataKey,
|
|
);
|
|
authLogger.info("OIDC configuration encrypted with admin data key", {
|
|
operation: "oidc_config_encrypt",
|
|
userId,
|
|
});
|
|
} else {
|
|
encryptedConfig = {
|
|
...config,
|
|
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, // Simple base64 encoding
|
|
};
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Disable OIDC configuration (admin only)
|
|
// DELETE /users/oidc-config
|
|
router.delete("/oidc-config", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Get OIDC configuration (public - needed for login page)
|
|
// GET /users/oidc-config
|
|
router.get("/oidc-config", async (req, res) => {
|
|
try {
|
|
const row = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
|
.get();
|
|
if (!row) {
|
|
return res.json(null);
|
|
}
|
|
|
|
let config = JSON.parse((row as any).value);
|
|
|
|
if (config.client_secret) {
|
|
if (config.client_secret.startsWith("encrypted:")) {
|
|
const authHeader = req.headers["authorization"];
|
|
if (authHeader?.startsWith("Bearer ")) {
|
|
const token = authHeader.split(" ")[1];
|
|
const authManager = AuthManager.getInstance();
|
|
const payload = await authManager.verifyJWTToken(token);
|
|
|
|
if (payload) {
|
|
const userId = payload.userId;
|
|
const user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, userId));
|
|
|
|
if (user && user.length > 0 && user[0].is_admin) {
|
|
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 {
|
|
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
|
|
}
|
|
} else {
|
|
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
|
}
|
|
} else {
|
|
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
|
}
|
|
} 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 {
|
|
config.client_secret = "[ENCODING ERROR]";
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json(config);
|
|
} catch (err) {
|
|
authLogger.error("Failed to get OIDC config", err);
|
|
res.status(500).json({ error: "Failed to get OIDC config" });
|
|
}
|
|
});
|
|
|
|
// Route: Get OIDC authorization URL
|
|
// GET /users/oidc/authorize
|
|
router.get("/oidc/authorize", async (req, res) => {
|
|
try {
|
|
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" });
|
|
}
|
|
|
|
const config = JSON.parse((row as any).value);
|
|
const state = nanoid();
|
|
const nonce = nanoid();
|
|
|
|
let origin =
|
|
req.get("Origin") ||
|
|
req.get("Referer")?.replace(/\/[^/]*$/, "") ||
|
|
"http://localhost:5173";
|
|
|
|
if (origin.includes("localhost")) {
|
|
origin = "http://localhost:30001";
|
|
}
|
|
|
|
const redirectUri = `${origin}/users/oidc/callback`;
|
|
|
|
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_redirect_${state}`, redirectUri);
|
|
|
|
const authUrl = new URL(config.authorization_url);
|
|
authUrl.searchParams.set("client_id", config.client_id);
|
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: OIDC callback - exchange code for token and create/login user
|
|
// GET /users/oidc/callback
|
|
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 storedRedirectRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`oidc_redirect_${state}`);
|
|
if (!storedRedirectRow) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Invalid state parameter - redirect URI not found" });
|
|
}
|
|
const redirectUri = (storedRedirectRow as any).value;
|
|
|
|
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" });
|
|
}
|
|
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`oidc_state_${state}`);
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`oidc_redirect_${state}`);
|
|
|
|
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" });
|
|
}
|
|
|
|
const config = JSON.parse((configRow as any).value);
|
|
|
|
const tokenResponse = await fetch(config.token_url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: config.client_id,
|
|
client_secret: config.client_secret,
|
|
code: code,
|
|
redirect_uri: redirectUri,
|
|
}),
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
authLogger.error(
|
|
"OIDC token exchange failed",
|
|
await tokenResponse.text(),
|
|
);
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Failed to exchange authorization code" });
|
|
}
|
|
|
|
const tokenData = (await tokenResponse.json()) as any;
|
|
|
|
let userInfo: any = 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 any;
|
|
if (discovery.userinfo_endpoint) {
|
|
userInfoUrls.push(discovery.userinfo_endpoint);
|
|
}
|
|
}
|
|
} 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,
|
|
config.issuer_url,
|
|
config.client_id,
|
|
);
|
|
} catch {
|
|
// Fallback to manual decoding
|
|
try {
|
|
const parts = tokenData.id_token.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();
|
|
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: any, path: string): any => {
|
|
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;
|
|
|
|
const name =
|
|
getNestedValue(userInfo, config.name_path) ||
|
|
userInfo[config.name_path] ||
|
|
userInfo.name ||
|
|
userInfo.given_name ||
|
|
identifier;
|
|
|
|
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(", ")}`,
|
|
});
|
|
}
|
|
|
|
let user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(
|
|
and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)),
|
|
);
|
|
|
|
let isFirstUser = false;
|
|
if (!user || user.length === 0) {
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get();
|
|
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
|
|
|
const id = nanoid();
|
|
await db.insert(users).values({
|
|
id,
|
|
username: name,
|
|
password_hash: "",
|
|
is_admin: isFirstUser,
|
|
is_oidc: true,
|
|
oidc_identifier: identifier,
|
|
client_id: config.client_id,
|
|
client_secret: config.client_secret,
|
|
issuer_url: config.issuer_url,
|
|
authorization_url: config.authorization_url,
|
|
token_url: config.token_url,
|
|
identifier_path: config.identifier_path,
|
|
name_path: config.name_path,
|
|
scopes: config.scopes,
|
|
});
|
|
|
|
try {
|
|
await authManager.registerOIDCUser(id);
|
|
} 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",
|
|
});
|
|
}
|
|
|
|
user = await db.select().from(users).where(eq(users.id, id));
|
|
} else {
|
|
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];
|
|
|
|
try {
|
|
await authManager.authenticateOIDCUser(userRecord.id);
|
|
} catch (setupError) {
|
|
authLogger.error("Failed to setup OIDC user encryption", setupError, {
|
|
operation: "oidc_user_encryption_setup_failed",
|
|
userId: userRecord.id,
|
|
});
|
|
}
|
|
|
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
|
expiresIn: "50d",
|
|
});
|
|
|
|
let frontendUrl = redirectUri.replace("/users/oidc/callback", "");
|
|
|
|
if (frontendUrl.includes("localhost")) {
|
|
frontendUrl = "http://localhost:5173";
|
|
}
|
|
|
|
const redirectUrl = new URL(frontendUrl);
|
|
redirectUrl.searchParams.set("success", "true");
|
|
|
|
return res
|
|
.cookie(
|
|
"jwt",
|
|
token,
|
|
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
|
|
)
|
|
.redirect(redirectUrl.toString());
|
|
} catch (err) {
|
|
authLogger.error("OIDC callback failed", err);
|
|
|
|
let frontendUrl = redirectUri.replace("/users/oidc/callback", "");
|
|
|
|
if (frontendUrl.includes("localhost")) {
|
|
frontendUrl = "http://localhost:5173";
|
|
}
|
|
|
|
const redirectUrl = new URL(frontendUrl);
|
|
redirectUrl.searchParams.set("error", "OIDC authentication failed");
|
|
|
|
res.redirect(redirectUrl.toString());
|
|
}
|
|
});
|
|
|
|
// Route: Get user JWT by username and password (traditional login)
|
|
// POST /users/login
|
|
router.post("/login", async (req, res) => {
|
|
const { username, password } = req.body;
|
|
|
|
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" });
|
|
}
|
|
|
|
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) {
|
|
authLogger.warn(`User not found: ${username}`, {
|
|
operation: "user_login",
|
|
username,
|
|
});
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
const userRecord = user[0];
|
|
|
|
if (userRecord.is_oidc) {
|
|
authLogger.warn("OIDC 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.password_hash);
|
|
if (!isMatch) {
|
|
authLogger.warn(`Incorrect password for user: ${username}`, {
|
|
operation: "user_login",
|
|
username,
|
|
userId: userRecord.id,
|
|
});
|
|
return res.status(401).json({ error: "Incorrect 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 (setupError) {}
|
|
|
|
const dataUnlocked = await authManager.authenticateUser(
|
|
userRecord.id,
|
|
password,
|
|
);
|
|
if (!dataUnlocked) {
|
|
return res.status(401).json({ error: "Incorrect password" });
|
|
}
|
|
|
|
if (userRecord.totp_enabled) {
|
|
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
|
pendingTOTP: true,
|
|
expiresIn: "10m",
|
|
});
|
|
return res.json({
|
|
success: true,
|
|
requires_totp: true,
|
|
temp_token: tempToken,
|
|
});
|
|
}
|
|
|
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
|
expiresIn: "24h",
|
|
});
|
|
|
|
authLogger.success(`User logged in successfully: ${username}`, {
|
|
operation: "user_login_success",
|
|
username,
|
|
userId: userRecord.id,
|
|
dataUnlocked: true,
|
|
});
|
|
|
|
const response: any = {
|
|
success: true,
|
|
is_admin: !!userRecord.is_admin,
|
|
username: userRecord.username,
|
|
};
|
|
|
|
const isElectron =
|
|
req.headers["x-electron-app"] === "true" ||
|
|
req.headers["X-Electron-App"] === "true";
|
|
|
|
if (isElectron) {
|
|
response.token = token;
|
|
}
|
|
|
|
return res
|
|
.cookie(
|
|
"jwt",
|
|
token,
|
|
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
|
|
)
|
|
.json(response);
|
|
} catch (err) {
|
|
authLogger.error("Failed to log in user", err);
|
|
return res.status(500).json({ error: "Login failed" });
|
|
}
|
|
});
|
|
|
|
// Route: Logout user
|
|
// POST /users/logout
|
|
router.post("/logout", async (req, res) => {
|
|
try {
|
|
const userId = (req as any).userId;
|
|
|
|
if (userId) {
|
|
authManager.logoutUser(userId);
|
|
authLogger.info("User logged out", {
|
|
operation: "user_logout",
|
|
userId,
|
|
});
|
|
}
|
|
|
|
return res
|
|
.clearCookie("jwt", authManager.getSecureCookieOptions(req))
|
|
.json({ success: true, message: "Logged out successfully" });
|
|
} catch (err) {
|
|
authLogger.error("Logout failed", err);
|
|
return res.status(500).json({ error: "Logout failed" });
|
|
}
|
|
});
|
|
|
|
// Route: Get current user's info using JWT
|
|
// GET /users/me
|
|
router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
|
const userId = (req as any).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 isDataUnlocked = authManager.isUserUnlocked(userId);
|
|
|
|
res.json({
|
|
userId: user[0].id,
|
|
username: user[0].username,
|
|
is_admin: !!user[0].is_admin,
|
|
is_oidc: !!user[0].is_oidc,
|
|
totp_enabled: !!user[0].totp_enabled,
|
|
data_unlocked: isDataUnlocked,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to get username", err);
|
|
res.status(500).json({ error: "Failed to get username" });
|
|
}
|
|
});
|
|
|
|
// Route: Check if system requires initial setup (public - for first-time setup detection)
|
|
// GET /users/setup-required
|
|
router.get("/setup-required", async (req, res) => {
|
|
try {
|
|
const countResult = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users")
|
|
.get();
|
|
const count = (countResult as any)?.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" });
|
|
}
|
|
});
|
|
|
|
// Route: Count users (admin only - for dashboard statistics)
|
|
// GET /users/count
|
|
router.get("/count", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user[0] || !user[0].is_admin) {
|
|
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 any)?.count || 0;
|
|
res.json({ count });
|
|
} catch (err) {
|
|
authLogger.error("Failed to count users", err);
|
|
res.status(500).json({ error: "Failed to count users" });
|
|
}
|
|
});
|
|
|
|
// Route: DB health check (actually queries DB)
|
|
// GET /users/db-health
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Get registration allowed status (public - needed for login page)
|
|
// GET /users/registration-allowed
|
|
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 any).value === "true" : true });
|
|
} catch (err) {
|
|
authLogger.error("Failed to get registration allowed", err);
|
|
res.status(500).json({ error: "Failed to get registration allowed" });
|
|
}
|
|
});
|
|
|
|
// Route: Set registration allowed status (admin only)
|
|
// PATCH /users/registration-allowed
|
|
router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Get password login allowed status (public - needed for login page)
|
|
// GET /users/password-login-allowed
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Set password login allowed status (admin only)
|
|
// PATCH /users/password-login-allowed
|
|
router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
|
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_password_login'")
|
|
.run(allowed ? "true" : "false");
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Route: Delete user account
|
|
// DELETE /users/delete-account
|
|
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).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.is_oidc) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Cannot delete external authentication accounts through this endpoint",
|
|
});
|
|
}
|
|
|
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
|
if (!isMatch) {
|
|
authLogger.warn(
|
|
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
|
);
|
|
return res.status(401).json({ error: "Incorrect password" });
|
|
}
|
|
|
|
if (userRecord.is_admin) {
|
|
const adminCount = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1")
|
|
.get();
|
|
if ((adminCount as any)?.count <= 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" });
|
|
}
|
|
});
|
|
|
|
// Route: Initiate password reset
|
|
// POST /users/initiate-reset
|
|
router.post("/initiate-reset", async (req, res) => {
|
|
const { username } = req.body;
|
|
|
|
if (!isNonEmptyString(username)) {
|
|
return res.status(400).json({ error: "Username is required" });
|
|
}
|
|
|
|
try {
|
|
const user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
|
|
if (!user || user.length === 0) {
|
|
authLogger.warn(
|
|
`Password reset attempted for non-existent user: ${username}`,
|
|
);
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
if (user[0].is_oidc) {
|
|
return res.status(403).json({
|
|
error: "Password reset not available for external authentication users",
|
|
});
|
|
}
|
|
|
|
const resetCode = crypto.randomInt(100000, 1000000).toString();
|
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
|
|
|
db.$client
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(
|
|
`reset_code_${username}`,
|
|
JSON.stringify({ code: resetCode, expiresAt: expiresAt.toISOString() }),
|
|
);
|
|
|
|
authLogger.info(
|
|
`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`,
|
|
);
|
|
|
|
res.json({
|
|
message:
|
|
"Password reset code has been generated and logged. Check docker logs for the code.",
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to initiate password reset", err);
|
|
res.status(500).json({ error: "Failed to initiate password reset" });
|
|
}
|
|
});
|
|
|
|
// Route: Verify reset code
|
|
// POST /users/verify-reset-code
|
|
router.post("/verify-reset-code", async (req, res) => {
|
|
const { username, resetCode } = req.body;
|
|
|
|
if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Username and reset code are required" });
|
|
}
|
|
|
|
try {
|
|
const resetDataRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`reset_code_${username}`);
|
|
if (!resetDataRow) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "No reset code found for this user" });
|
|
}
|
|
|
|
const resetData = JSON.parse((resetDataRow as any).value);
|
|
const now = new Date();
|
|
const expiresAt = new Date(resetData.expiresAt);
|
|
|
|
if (now > expiresAt) {
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`reset_code_${username}`);
|
|
return res.status(400).json({ error: "Reset code has expired" });
|
|
}
|
|
|
|
if (resetData.code !== resetCode) {
|
|
return res.status(400).json({ error: "Invalid reset code" });
|
|
}
|
|
|
|
const tempToken = nanoid();
|
|
const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000);
|
|
|
|
db.$client
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(
|
|
`temp_reset_token_${username}`,
|
|
JSON.stringify({
|
|
token: tempToken,
|
|
expiresAt: tempTokenExpiry.toISOString(),
|
|
}),
|
|
);
|
|
|
|
res.json({ message: "Reset code verified", tempToken });
|
|
} catch (err) {
|
|
authLogger.error("Failed to verify reset code", err);
|
|
res.status(500).json({ error: "Failed to verify reset code" });
|
|
}
|
|
});
|
|
|
|
// Route: Complete password reset
|
|
// POST /users/complete-reset
|
|
router.post("/complete-reset", async (req, res) => {
|
|
const { username, tempToken, newPassword } = req.body;
|
|
|
|
if (
|
|
!isNonEmptyString(username) ||
|
|
!isNonEmptyString(tempToken) ||
|
|
!isNonEmptyString(newPassword)
|
|
) {
|
|
return res.status(400).json({
|
|
error: "Username, temporary token, and new password are required",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const tempTokenRow = db.$client
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(`temp_reset_token_${username}`);
|
|
if (!tempTokenRow) {
|
|
return res.status(400).json({ error: "No temporary token found" });
|
|
}
|
|
|
|
const tempTokenData = JSON.parse((tempTokenRow as any).value);
|
|
const now = new Date();
|
|
const expiresAt = new Date(tempTokenData.expiresAt);
|
|
|
|
if (now > expiresAt) {
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`temp_reset_token_${username}`);
|
|
return res.status(400).json({ error: "Temporary token has expired" });
|
|
}
|
|
|
|
if (tempTokenData.token !== tempToken) {
|
|
return res.status(400).json({ error: "Invalid temporary token" });
|
|
}
|
|
|
|
const user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
if (!user || user.length === 0) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
const userId = user[0].id;
|
|
|
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
|
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ password_hash })
|
|
.where(eq(users.username, username));
|
|
|
|
try {
|
|
const hasActiveSession = authManager.isUserUnlocked(userId);
|
|
|
|
if (hasActiveSession) {
|
|
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
|
userId,
|
|
newPassword,
|
|
);
|
|
|
|
if (!success) {
|
|
authLogger.warn(
|
|
`Failed to preserve DEK during password reset for ${username}. Creating new DEK - data will be lost.`,
|
|
{
|
|
operation: "password_reset_preserve_failed",
|
|
userId,
|
|
username,
|
|
},
|
|
);
|
|
await authManager.registerUser(userId, newPassword);
|
|
authManager.logoutUser(userId);
|
|
} else {
|
|
authLogger.success(
|
|
`Password reset completed for user: ${username}. Data preserved using existing session.`,
|
|
{
|
|
operation: "password_reset_data_preserved",
|
|
userId,
|
|
username,
|
|
},
|
|
);
|
|
}
|
|
} else {
|
|
await authManager.registerUser(userId, newPassword);
|
|
authManager.logoutUser(userId);
|
|
|
|
authLogger.warn(
|
|
`Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`,
|
|
{
|
|
operation: "password_reset_data_inaccessible",
|
|
userId,
|
|
username,
|
|
},
|
|
);
|
|
}
|
|
|
|
await db
|
|
.update(users)
|
|
.set({
|
|
totp_enabled: false,
|
|
totp_secret: null,
|
|
totp_backup_codes: null,
|
|
})
|
|
.where(eq(users.id, userId));
|
|
} catch (encryptionError) {
|
|
authLogger.error(
|
|
"Failed to re-encrypt user data after password reset",
|
|
encryptionError,
|
|
{
|
|
operation: "password_reset_encryption_failed",
|
|
userId,
|
|
username,
|
|
},
|
|
);
|
|
return res.status(500).json({
|
|
error:
|
|
"Password reset completed but user data encryption failed. Please contact administrator.",
|
|
});
|
|
}
|
|
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`reset_code_${username}`);
|
|
db.$client
|
|
.prepare("DELETE FROM settings WHERE key = ?")
|
|
.run(`temp_reset_token_${username}`);
|
|
|
|
res.json({ message: "Password has been successfully reset" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to complete password reset", err);
|
|
res.status(500).json({ error: "Failed to complete password reset" });
|
|
}
|
|
});
|
|
|
|
// Route: List all users (admin only)
|
|
// GET /users/list
|
|
router.get("/list", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
try {
|
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
const allUsers = await db
|
|
.select({
|
|
id: users.id,
|
|
username: users.username,
|
|
is_admin: users.is_admin,
|
|
is_oidc: users.is_oidc,
|
|
})
|
|
.from(users);
|
|
|
|
res.json({ users: allUsers });
|
|
} catch (err) {
|
|
authLogger.error("Failed to list users", err);
|
|
res.status(500).json({ error: "Failed to list users" });
|
|
}
|
|
});
|
|
|
|
// Route: Make user admin (admin only)
|
|
// POST /users/make-admin
|
|
router.post("/make-admin", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).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].is_admin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
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].is_admin) {
|
|
return res.status(400).json({ error: "User is already an admin" });
|
|
}
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ is_admin: true })
|
|
.where(eq(users.username, username));
|
|
|
|
authLogger.success(
|
|
`User ${username} made admin by ${adminUser[0].username}`,
|
|
);
|
|
res.json({ message: `User ${username} is now an admin` });
|
|
} catch (err) {
|
|
authLogger.error("Failed to make user admin", err);
|
|
res.status(500).json({ error: "Failed to make user admin" });
|
|
}
|
|
});
|
|
|
|
// Route: Remove admin status (admin only)
|
|
// POST /users/remove-admin
|
|
router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).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].is_admin) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
if (adminUser[0].username === username) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Cannot remove your own admin status" });
|
|
}
|
|
|
|
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].is_admin) {
|
|
return res.status(400).json({ error: "User is not an admin" });
|
|
}
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ is_admin: false })
|
|
.where(eq(users.username, username));
|
|
|
|
authLogger.success(
|
|
`Admin status removed from ${username} by ${adminUser[0].username}`,
|
|
);
|
|
res.json({ message: `Admin status removed from ${username}` });
|
|
} catch (err) {
|
|
authLogger.error("Failed to remove admin status", err);
|
|
res.status(500).json({ error: "Failed to remove admin status" });
|
|
}
|
|
});
|
|
|
|
// Route: Verify TOTP during login
|
|
// POST /users/totp/verify-login
|
|
router.post("/totp/verify-login", async (req, res) => {
|
|
const { temp_token, totp_code } = req.body;
|
|
|
|
if (!temp_token || !totp_code) {
|
|
return res.status(400).json({ error: "Token and TOTP code are required" });
|
|
}
|
|
|
|
try {
|
|
const decoded = await authManager.verifyJWTToken(temp_token);
|
|
if (!decoded || !decoded.pendingTOTP) {
|
|
return res.status(401).json({ error: "Invalid temporary token" });
|
|
}
|
|
|
|
const user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, decoded.userId));
|
|
if (!user || user.length === 0) {
|
|
return res.status(404).json({ error: "User not found" });
|
|
}
|
|
|
|
const userRecord = user[0];
|
|
|
|
if (!userRecord.totp_enabled || !userRecord.totp_secret) {
|
|
return res.status(400).json({ error: "TOTP not enabled for this user" });
|
|
}
|
|
|
|
const userDataKey = authManager.getUserDataKey(userRecord.id);
|
|
if (!userDataKey) {
|
|
return res.status(401).json({
|
|
error: "Session expired - please log in again",
|
|
code: "SESSION_EXPIRED",
|
|
});
|
|
}
|
|
|
|
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
|
|
userRecord.totp_secret,
|
|
userDataKey,
|
|
userRecord.id,
|
|
"totp_secret",
|
|
);
|
|
|
|
if (!totpSecret) {
|
|
await db
|
|
.update(users)
|
|
.set({
|
|
totp_enabled: false,
|
|
totp_secret: null,
|
|
totp_backup_codes: null,
|
|
})
|
|
.where(eq(users.id, userRecord.id));
|
|
|
|
return res.status(400).json({
|
|
error:
|
|
"TOTP has been disabled due to password reset. Please set up TOTP again.",
|
|
});
|
|
}
|
|
|
|
const verified = speakeasy.totp.verify({
|
|
secret: totpSecret,
|
|
encoding: "base32",
|
|
token: totp_code,
|
|
window: 2,
|
|
});
|
|
|
|
if (!verified) {
|
|
let backupCodes = [];
|
|
try {
|
|
backupCodes = userRecord.totp_backup_codes
|
|
? JSON.parse(userRecord.totp_backup_codes)
|
|
: [];
|
|
} catch {
|
|
backupCodes = [];
|
|
}
|
|
|
|
if (!Array.isArray(backupCodes)) {
|
|
backupCodes = [];
|
|
}
|
|
|
|
const backupIndex = backupCodes.indexOf(totp_code);
|
|
|
|
if (backupIndex === -1) {
|
|
return res.status(401).json({ error: "Invalid TOTP code" });
|
|
}
|
|
|
|
backupCodes.splice(backupIndex, 1);
|
|
await db
|
|
.update(users)
|
|
.set({ totp_backup_codes: JSON.stringify(backupCodes) })
|
|
.where(eq(users.id, userRecord.id));
|
|
}
|
|
|
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
|
expiresIn: "50d",
|
|
});
|
|
|
|
const isElectron =
|
|
req.headers["x-electron-app"] === "true" ||
|
|
req.headers["X-Electron-App"] === "true";
|
|
|
|
const isDataUnlocked = authManager.isUserUnlocked(userRecord.id);
|
|
|
|
if (!isDataUnlocked) {
|
|
return res.status(401).json({
|
|
error: "Session expired - please log in again",
|
|
code: "SESSION_EXPIRED",
|
|
});
|
|
}
|
|
|
|
const response: any = {
|
|
success: true,
|
|
is_admin: !!userRecord.is_admin,
|
|
username: userRecord.username,
|
|
userId: userRecord.id,
|
|
is_oidc: !!userRecord.is_oidc,
|
|
totp_enabled: !!userRecord.totp_enabled,
|
|
data_unlocked: isDataUnlocked,
|
|
};
|
|
|
|
if (isElectron) {
|
|
response.token = token;
|
|
}
|
|
|
|
return res
|
|
.cookie(
|
|
"jwt",
|
|
token,
|
|
authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000),
|
|
)
|
|
.json(response);
|
|
} catch (err) {
|
|
authLogger.error("TOTP verification failed", err);
|
|
return res.status(500).json({ error: "TOTP verification failed" });
|
|
}
|
|
});
|
|
|
|
// Route: Setup TOTP
|
|
// POST /users/totp/setup
|
|
router.post("/totp/setup", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
|
|
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.totp_enabled) {
|
|
return res.status(400).json({ error: "TOTP is already enabled" });
|
|
}
|
|
|
|
const secret = speakeasy.generateSecret({
|
|
name: `Termix (${userRecord.username})`,
|
|
length: 32,
|
|
});
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ totp_secret: secret.base32 })
|
|
.where(eq(users.id, userId));
|
|
|
|
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
|
|
|
|
res.json({
|
|
secret: secret.base32,
|
|
qr_code: qrCodeUrl,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to setup TOTP", err);
|
|
res.status(500).json({ error: "Failed to setup TOTP" });
|
|
}
|
|
});
|
|
|
|
// Route: Enable TOTP
|
|
// POST /users/totp/enable
|
|
router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
const { totp_code } = req.body;
|
|
|
|
if (!totp_code) {
|
|
return res.status(400).json({ error: "TOTP code is required" });
|
|
}
|
|
|
|
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.totp_enabled) {
|
|
return res.status(400).json({ error: "TOTP is already enabled" });
|
|
}
|
|
|
|
if (!userRecord.totp_secret) {
|
|
return res.status(400).json({ error: "TOTP setup not initiated" });
|
|
}
|
|
|
|
const verified = speakeasy.totp.verify({
|
|
secret: userRecord.totp_secret,
|
|
encoding: "base32",
|
|
token: totp_code,
|
|
window: 2,
|
|
});
|
|
|
|
if (!verified) {
|
|
return res.status(401).json({ error: "Invalid TOTP code" });
|
|
}
|
|
|
|
const backupCodes = Array.from({ length: 8 }, () =>
|
|
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
|
);
|
|
|
|
await db
|
|
.update(users)
|
|
.set({
|
|
totp_enabled: true,
|
|
totp_backup_codes: JSON.stringify(backupCodes),
|
|
})
|
|
.where(eq(users.id, userId));
|
|
|
|
res.json({
|
|
message: "TOTP enabled successfully",
|
|
backup_codes: backupCodes,
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to enable TOTP", err);
|
|
res.status(500).json({ error: "Failed to enable TOTP" });
|
|
}
|
|
});
|
|
|
|
// Route: Disable TOTP
|
|
// POST /users/totp/disable
|
|
router.post("/totp/disable", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
const { password, totp_code } = req.body;
|
|
|
|
if (!password && !totp_code) {
|
|
return res.status(400).json({ error: "Password or TOTP code is required" });
|
|
}
|
|
|
|
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.totp_enabled) {
|
|
return res.status(400).json({ error: "TOTP is not enabled" });
|
|
}
|
|
|
|
if (password && !userRecord.is_oidc) {
|
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
|
if (!isMatch) {
|
|
return res.status(401).json({ error: "Incorrect password" });
|
|
}
|
|
} else if (totp_code) {
|
|
const verified = speakeasy.totp.verify({
|
|
secret: userRecord.totp_secret!,
|
|
encoding: "base32",
|
|
token: totp_code,
|
|
window: 2,
|
|
});
|
|
|
|
if (!verified) {
|
|
return res.status(401).json({ error: "Invalid TOTP code" });
|
|
}
|
|
} else {
|
|
return res.status(400).json({ error: "Authentication required" });
|
|
}
|
|
|
|
await db
|
|
.update(users)
|
|
.set({
|
|
totp_enabled: false,
|
|
totp_secret: null,
|
|
totp_backup_codes: null,
|
|
})
|
|
.where(eq(users.id, userId));
|
|
|
|
res.json({ message: "TOTP disabled successfully" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to disable TOTP", err);
|
|
res.status(500).json({ error: "Failed to disable TOTP" });
|
|
}
|
|
});
|
|
|
|
// Route: Generate new backup codes
|
|
// POST /users/totp/backup-codes
|
|
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
const { password, totp_code } = req.body;
|
|
|
|
if (!password && !totp_code) {
|
|
return res.status(400).json({ error: "Password or TOTP code is required" });
|
|
}
|
|
|
|
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.totp_enabled) {
|
|
return res.status(400).json({ error: "TOTP is not enabled" });
|
|
}
|
|
|
|
if (password && !userRecord.is_oidc) {
|
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
|
if (!isMatch) {
|
|
return res.status(401).json({ error: "Incorrect password" });
|
|
}
|
|
} else if (totp_code) {
|
|
const verified = speakeasy.totp.verify({
|
|
secret: userRecord.totp_secret!,
|
|
encoding: "base32",
|
|
token: totp_code,
|
|
window: 2,
|
|
});
|
|
|
|
if (!verified) {
|
|
return res.status(401).json({ error: "Invalid TOTP code" });
|
|
}
|
|
} else {
|
|
return res.status(400).json({ error: "Authentication required" });
|
|
}
|
|
|
|
const backupCodes = Array.from({ length: 8 }, () =>
|
|
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
|
);
|
|
|
|
await db
|
|
.update(users)
|
|
.set({ totp_backup_codes: JSON.stringify(backupCodes) })
|
|
.where(eq(users.id, userId));
|
|
|
|
res.json({ backup_codes: backupCodes });
|
|
} catch (err) {
|
|
authLogger.error("Failed to generate backup codes", err);
|
|
res.status(500).json({ error: "Failed to generate backup codes" });
|
|
}
|
|
});
|
|
|
|
// Route: Delete user (admin only)
|
|
// DELETE /users/delete-user
|
|
router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).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].is_admin) {
|
|
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].is_admin) {
|
|
const adminCount = db.$client
|
|
.prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1")
|
|
.get();
|
|
if ((adminCount as any)?.count <= 1) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Cannot delete the last admin user" });
|
|
}
|
|
}
|
|
|
|
const targetUserId = targetUser[0].id;
|
|
|
|
try {
|
|
await db
|
|
.delete(fileManagerRecent)
|
|
.where(eq(fileManagerRecent.userId, targetUserId));
|
|
await db
|
|
.delete(fileManagerPinned)
|
|
.where(eq(fileManagerPinned.userId, targetUserId));
|
|
await db
|
|
.delete(fileManagerShortcuts)
|
|
.where(eq(fileManagerShortcuts.userId, targetUserId));
|
|
|
|
await db
|
|
.delete(dismissedAlerts)
|
|
.where(eq(dismissedAlerts.userId, targetUserId));
|
|
|
|
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
|
|
} catch (cleanupError) {
|
|
authLogger.error(`Cleanup failed for user ${username}:`, cleanupError);
|
|
throw cleanupError;
|
|
}
|
|
|
|
await db.delete(users).where(eq(users.id, targetUserId));
|
|
|
|
authLogger.success(
|
|
`User ${username} deleted by admin ${adminUser[0].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" });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Route: User data unlock - used when session expires
|
|
// POST /users/unlock-data
|
|
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
const { password } = req.body;
|
|
|
|
if (!password) {
|
|
return res.status(400).json({ error: "Password is required" });
|
|
}
|
|
|
|
try {
|
|
const unlocked = await authManager.authenticateUser(userId, password);
|
|
if (unlocked) {
|
|
res.json({
|
|
success: true,
|
|
message: "Data unlocked successfully",
|
|
});
|
|
} else {
|
|
authLogger.warn("Failed to unlock user data - invalid password", {
|
|
operation: "user_data_unlock_failed",
|
|
userId,
|
|
});
|
|
res.status(401).json({ error: "Invalid password" });
|
|
}
|
|
} catch (err) {
|
|
authLogger.error("Data unlock failed", err, {
|
|
operation: "user_data_unlock_error",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to unlock data" });
|
|
}
|
|
});
|
|
|
|
// Route: Check user data unlock status
|
|
// GET /users/data-status
|
|
router.get("/data-status", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
|
|
try {
|
|
const isUnlocked = authManager.isUserUnlocked(userId);
|
|
res.json({
|
|
unlocked: isUnlocked,
|
|
message: isUnlocked
|
|
? "Data is unlocked"
|
|
: "Data is locked - re-authenticate with password",
|
|
});
|
|
} catch (err) {
|
|
authLogger.error("Failed to check data status", err, {
|
|
operation: "data_status_check_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to check data status" });
|
|
}
|
|
});
|
|
|
|
// Route: Change user password (re-encrypt data keys)
|
|
// POST /users/change-password
|
|
router.post("/change-password", authenticateJWT, async (req, res) => {
|
|
const userId = (req as any).userId;
|
|
const { currentPassword, newPassword } = req.body;
|
|
|
|
if (!currentPassword || !newPassword) {
|
|
return res.status(400).json({
|
|
error: "Current password and new password are required",
|
|
});
|
|
}
|
|
|
|
if (newPassword.length < 8) {
|
|
return res.status(400).json({
|
|
error: "New password must be at least 8 characters long",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const success = await authManager.changeUserPassword(
|
|
userId,
|
|
currentPassword,
|
|
newPassword,
|
|
);
|
|
|
|
if (success) {
|
|
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
|
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
|
await db
|
|
.update(users)
|
|
.set({ password_hash: newPasswordHash })
|
|
.where(eq(users.id, userId));
|
|
|
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
|
await saveMemoryDatabaseToFile();
|
|
|
|
authLogger.success("User password changed successfully", {
|
|
operation: "password_change_success",
|
|
userId,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "Password changed successfully",
|
|
});
|
|
} else {
|
|
authLogger.warn("Password change failed - invalid current password", {
|
|
operation: "password_change_failed",
|
|
userId,
|
|
});
|
|
res.status(401).json({ error: "Current password is incorrect" });
|
|
}
|
|
} catch (err) {
|
|
authLogger.error("Password change failed", err, {
|
|
operation: "password_change_error",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to change password" });
|
|
}
|
|
});
|
|
|
|
export default router;
|