mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -759,10 +759,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT
|
||||
scopes TEXT DEFAULT 'openid email profile'
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
@@ -900,8 +897,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
|
||||
const userRecord = user;
|
||||
const insertUser = exportDb.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, totp_secret, totp_enabled, totp_backup_codes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertUser.run(
|
||||
userRecord.id,
|
||||
@@ -918,9 +915,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
userRecord.identifierPath || null,
|
||||
userRecord.namePath || null,
|
||||
userRecord.scopes || null,
|
||||
userRecord.totpSecret || null,
|
||||
userRecord.totpEnabled ? 1 : 0,
|
||||
userRecord.totpBackupCodes || null,
|
||||
);
|
||||
|
||||
const sshHosts =
|
||||
|
||||
@@ -232,9 +232,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -291,22 +288,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -884,10 +865,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
const hadRegisteredAtColumn = (() => {
|
||||
try {
|
||||
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
|
||||
@@ -940,24 +917,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
|
||||
@@ -38,12 +38,6 @@ export const users = mysqlTable("users", {
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: boolean("totp_enabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
totpBackupCodes: text("totp_backup_codes"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
@@ -173,25 +167,6 @@ export const userSecondFactors = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const webauthnCredentials = mysqlTable("webauthn_credentials", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
credentialId: varchar("credential_id", { length: 255 }).notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
counter: int("counter").notNull().default(0),
|
||||
deviceType: text("device_type"),
|
||||
backedUp: boolean("backed_up").notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
});
|
||||
|
||||
export const hosts = mysqlTable(
|
||||
"ssh_data",
|
||||
{
|
||||
|
||||
@@ -39,12 +39,6 @@ export const users = pgTable("users", {
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: boolean("totp_enabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
totpBackupCodes: text("totp_backup_codes"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
@@ -174,25 +168,6 @@ export const userSecondFactors = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const webauthnCredentials = pgTable("webauthn_credentials", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
credentialId: varchar("credential_id", { length: 255 }).notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
counter: integer("counter").notNull().default(0),
|
||||
deviceType: text("device_type"),
|
||||
backedUp: boolean("backed_up").notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
});
|
||||
|
||||
export const hosts = pgTable(
|
||||
"ssh_data",
|
||||
{
|
||||
|
||||
@@ -27,12 +27,6 @@ export const users = sqliteTable("users", {
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: integer("totp_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
totpBackupCodes: text("totp_backup_codes"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: integer("donation_modal_dismissed", {
|
||||
mode: "boolean",
|
||||
@@ -164,25 +158,6 @@ export const userSecondFactors = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const webauthnCredentials = sqliteTable("webauthn_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
credentialId: text("credential_id").notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
counter: integer("counter").notNull().default(0),
|
||||
deviceType: text("device_type"),
|
||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
});
|
||||
|
||||
export const hosts = sqliteTable(
|
||||
"ssh_data",
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getDb, getSqlite } from "../db/index.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { NotificationChannelRepository } from "./notification-channel-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
@@ -128,13 +127,6 @@ export function getCurrentSettingValue(key: string): string | null {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialRepository {
|
||||
return new WebauthnCredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("webauthn_credential_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
|
||||
return new NotificationChannelRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -113,6 +113,27 @@ export class UserAuthRepository {
|
||||
return this.context.drizzle.select().from(userSecondFactors);
|
||||
}
|
||||
|
||||
/** Users enrolled in at least one second factor, whichever plugin owns it. */
|
||||
async listUserIdsWithSecondFactors(): Promise<Set<string>> {
|
||||
const rows = await this.context.drizzle
|
||||
.selectDistinct({ userId: userSecondFactors.userId })
|
||||
.from(userSecondFactors);
|
||||
return new Set(rows.map((row) => row.userId));
|
||||
}
|
||||
|
||||
async countUsersWithSecondFactors(): Promise<number> {
|
||||
return (await this.listUserIdsWithSecondFactors()).size;
|
||||
}
|
||||
|
||||
async hasSecondFactor(userId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userSecondFactors.userId })
|
||||
.from(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async recordSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
|
||||
@@ -228,15 +228,6 @@ export class UserRepository {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async countTotpEnabled(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.totpEnabled, true));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { webauthnCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
|
||||
export type NewWebauthnCredentialRecord =
|
||||
typeof webauthnCredentials.$inferInsert;
|
||||
|
||||
export interface WebauthnCredentialAuthState {
|
||||
counter: number;
|
||||
backedUp: boolean;
|
||||
deviceType: string | null;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
export class WebauthnCredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<WebauthnCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
}
|
||||
|
||||
async findByCredentialId(
|
||||
credentialId: string,
|
||||
): Promise<WebauthnCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.credentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
record: NewWebauthnCredentialRecord,
|
||||
): Promise<WebauthnCredentialRecord> {
|
||||
const rows = await insertReturning(
|
||||
this.context,
|
||||
webauthnCredentials,
|
||||
record,
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateAuthState(
|
||||
id: string,
|
||||
state: WebauthnCredentialAuthState,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(webauthnCredentials)
|
||||
.set(state)
|
||||
.where(eq(webauthnCredentials.id, id));
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,50 @@ export function registerAuthRoutes(router: Router): void {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/verify-login:
|
||||
* post:
|
||||
* summary: Finish a login with a second factor (2.8 route)
|
||||
* description: Kept for clients built against 2.8, such as Termix-Mobile. Verifies the pending login against the factor named in `factor`, or the user's first enrolled factor, the same way as /users/auth/second-factor/{factorId}/verify.
|
||||
* tags:
|
||||
* - Auth
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* temp_token:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* factor:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login finished.
|
||||
* 400:
|
||||
* description: Token and code are required.
|
||||
* 401:
|
||||
* description: Invalid temporary token or code.
|
||||
*/
|
||||
router.post("/totp/verify-login", async (req, res) => {
|
||||
if (!req.body?.temp_token || !req.body?.totp_code) {
|
||||
return res.status(400).json({ error: "Token and code are required" });
|
||||
}
|
||||
try {
|
||||
await verifySecondFactorAndRespond(
|
||||
req,
|
||||
res,
|
||||
typeof req.body.factor === "string" ? req.body.factor : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/start:
|
||||
|
||||
@@ -112,6 +112,10 @@ export function registerUserAdminRoutes(
|
||||
total = pageUsers.length;
|
||||
}
|
||||
|
||||
const secondFactorUsers = requester?.isAdmin
|
||||
? await createCurrentUserAuthRepository().listUserIdsWithSecondFactors()
|
||||
: new Set<string>();
|
||||
|
||||
res.json({
|
||||
users: pageUsers.map((u) => ({
|
||||
userId: u.id,
|
||||
@@ -124,7 +128,7 @@ export function registerUserAdminRoutes(
|
||||
...(requester?.isAdmin
|
||||
? {
|
||||
data_unlocked: DataCrypto.canUserAccessData(u.id),
|
||||
totp_enabled: !!u.totpEnabled,
|
||||
second_factor_enabled: secondFactorUsers.has(u.id),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
@@ -473,9 +477,6 @@ export function registerUserAdminRoutes(
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -676,100 +677,6 @@ export function registerUserAdminRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/admin/totp/disable:
|
||||
* post:
|
||||
* summary: Disable a user's TOTP (admin only)
|
||||
* description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP disabled for the user.
|
||||
* 400:
|
||||
* description: User ID is required or TOTP is not enabled.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to disable TOTP.
|
||||
*/
|
||||
router.post("/admin/totp/disable", authenticateJWT, async (req, res) => {
|
||||
const adminId = (req as AuthenticatedRequest).userId;
|
||||
const { userId: targetUserId } = req.body;
|
||||
|
||||
if (!isNonEmptyString(targetUserId)) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const adminUser = await userRepository.findById(adminId);
|
||||
if (!adminUser?.isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(targetUserId.trim());
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
if (!targetUser.totpEnabled) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "TOTP is not enabled for this user" });
|
||||
}
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
targetUser.id,
|
||||
"core",
|
||||
"totp",
|
||||
);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("admin_disable_totp");
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist TOTP disable to disk", saveError, {
|
||||
operation: "admin_disable_totp_save_failed",
|
||||
userId: targetUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: adminId,
|
||||
username: adminUser.username ?? adminId,
|
||||
action: "admin_disable_totp",
|
||||
resourceType: "user",
|
||||
resourceId: targetUser.id,
|
||||
resourceName: targetUser.username,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: "TOTP disabled" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to disable TOTP for user", err);
|
||||
res.status(500).json({ error: "Failed to disable TOTP" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/admin/{userId}/second-factors:
|
||||
|
||||
@@ -84,11 +84,11 @@ export async function resetUserPassword(
|
||||
await deleteLegacyWraps(userId);
|
||||
await authManager.logoutUser(userId);
|
||||
|
||||
await userRepository.update(userId, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
// Same as 2.8, where the wipe took the TOTP secret with it: the user sets
|
||||
// up their second factors again after this.
|
||||
const { resetUserSecondFactors } =
|
||||
await import("../../auth/second-factor-admin.js");
|
||||
await resetUserSecondFactors(userId);
|
||||
|
||||
authLogger.warn(
|
||||
`Password reset completed for user: ${username}. All encrypted data has been deleted because the old key was unrecoverable.`,
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
import { isPasswordLoginSettingOn } from "../../auth/core-auth.js";
|
||||
import { verifySecondFactorAndRespond } from "../../auth/login-pipeline.js";
|
||||
import { TOTP_FACTOR_ID } from "../../auth/legacy/totp-factor.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
|
||||
type NativeAppRequestChecker = (req: Request) => boolean;
|
||||
|
||||
interface UserTotpRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
isNativeAppRequest: NativeAppRequestChecker;
|
||||
}
|
||||
|
||||
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */
|
||||
export function generateBackupCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function verifyTotpReauth(
|
||||
userRecord: UserRecord,
|
||||
credential: string,
|
||||
userDataKey?: Buffer | null,
|
||||
): Promise<boolean> {
|
||||
if (userRecord.totpSecret) {
|
||||
const totpSecret = userDataKey
|
||||
? LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpSecret,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totpSecret",
|
||||
)
|
||||
: userRecord.totpSecret;
|
||||
|
||||
if (totpSecret) {
|
||||
const totpMatch = speakeasy.totp.verify({
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
token: credential,
|
||||
window: 2,
|
||||
});
|
||||
if (totpMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawBackupCodes =
|
||||
userDataKey && userRecord.totpBackupCodes
|
||||
? LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpBackupCodes,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totpBackupCodes",
|
||||
)
|
||||
: userRecord.totpBackupCodes;
|
||||
|
||||
let backupCodes: unknown = [];
|
||||
try {
|
||||
backupCodes = rawBackupCodes ? JSON.parse(rawBackupCodes) : [];
|
||||
} catch {
|
||||
backupCodes = [];
|
||||
}
|
||||
if (Array.isArray(backupCodes)) {
|
||||
const backupIndex = backupCodes.indexOf(credential);
|
||||
if (backupIndex !== -1) {
|
||||
backupCodes.splice(backupIndex, 1);
|
||||
const updatedJson = JSON.stringify(backupCodes);
|
||||
const storedValue = userDataKey
|
||||
? FieldCrypto.encryptField(
|
||||
updatedJson,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totpBackupCodes",
|
||||
)
|
||||
: updatedJson;
|
||||
await createCurrentUserRepository().update(userRecord.id, {
|
||||
totpBackupCodes: storedValue,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerUserTotpRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager, isNativeAppRequest }: UserTotpRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/setup:
|
||||
* post:
|
||||
* summary: Setup TOTP
|
||||
* description: Initiates TOTP setup by generating a secret and QR code.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP setup initiated with secret and QR code.
|
||||
* 400:
|
||||
* description: TOTP is already enabled.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to setup TOTP.
|
||||
*/
|
||||
router.post("/totp/setup", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const userRecord = await createCurrentUserRepository().findById(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
const credential = req.body?.credential;
|
||||
if (!credential) {
|
||||
return res.status(400).json({
|
||||
error: "A TOTP code or password is required",
|
||||
});
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
let verified = await verifyTotpReauth(
|
||||
userRecord,
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
if (!verified && !userRecord.isOidc && userRecord.passwordHash) {
|
||||
verified = await bcrypt.compare(credential, userRecord.passwordHash);
|
||||
}
|
||||
if (!verified) {
|
||||
return res.status(401).json({
|
||||
error: "Incorrect password or invalid TOTP code",
|
||||
});
|
||||
}
|
||||
|
||||
const existingSecret = userDataKey
|
||||
? LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpSecret,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpSecret",
|
||||
)
|
||||
: userRecord.totpSecret;
|
||||
if (!existingSecret) {
|
||||
return res.status(409).json({ error: "TOTP secret is unavailable" });
|
||||
}
|
||||
|
||||
const otpauthUrl = speakeasy.otpauthURL({
|
||||
secret: existingSecret,
|
||||
label: `Termix (${userRecord.username})`,
|
||||
encoding: "base32",
|
||||
});
|
||||
authLogger.info("Additional TOTP authenticator enrollment started", {
|
||||
operation: "totp_add_authenticator",
|
||||
userId,
|
||||
});
|
||||
return res.json({
|
||||
secret: existingSecret,
|
||||
qr_code: await QRCode.toDataURL(otpauthUrl),
|
||||
additional: true,
|
||||
});
|
||||
}
|
||||
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `Termix (${userRecord.username})`,
|
||||
length: 32,
|
||||
});
|
||||
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpSecret: secret.base32,
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/enable:
|
||||
* post:
|
||||
* summary: Enable TOTP
|
||||
* description: Enables TOTP after verifying the initial code.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP enabled successfully with backup codes.
|
||||
* 400:
|
||||
* description: TOTP code is required or TOTP already enabled.
|
||||
* 401:
|
||||
* description: Invalid TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to enable TOTP.
|
||||
*/
|
||||
router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
return res.status(409).json({
|
||||
error: "TOTP is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
}
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
const { totp_code } = req.body;
|
||||
|
||||
if (!totp_code) {
|
||||
return res.status(400).json({ error: "TOTP code is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordLoginAllowed = isPasswordLoginSettingOn();
|
||||
if (!passwordLoginAllowed) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"Cannot enable 2FA while password login is disabled. Enable password login first.",
|
||||
});
|
||||
}
|
||||
|
||||
const userRecord = await createCurrentUserRepository().findById(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is already enabled" });
|
||||
}
|
||||
|
||||
if (!userRecord.totpSecret) {
|
||||
return res.status(400).json({ error: "TOTP setup not initiated" });
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
const totpSecret = userDataKey
|
||||
? LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpSecret,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpSecret",
|
||||
)
|
||||
: userRecord.totpSecret;
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () => generateBackupCode());
|
||||
|
||||
const backupCodesJson = JSON.stringify(backupCodes);
|
||||
const storedBackupCodes = userDataKey
|
||||
? FieldCrypto.encryptField(
|
||||
backupCodesJson,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
)
|
||||
: backupCodesJson;
|
||||
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpEnabled: true,
|
||||
totpBackupCodes: storedBackupCodes,
|
||||
});
|
||||
await createCurrentUserAuthRepository().recordSecondFactor(
|
||||
userId,
|
||||
"core",
|
||||
TOTP_FACTOR_ID,
|
||||
);
|
||||
|
||||
await createCurrentSessionRepository().revokeAllForUser(
|
||||
userId,
|
||||
sessionId,
|
||||
);
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("totp_enable_explicit_save");
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist TOTP enablement to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "totp_enable_db_save_failed",
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/disable:
|
||||
* post:
|
||||
* summary: Disable TOTP
|
||||
* description: Disables TOTP for a user.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP disabled successfully.
|
||||
* 400:
|
||||
* description: Password or TOTP code is required.
|
||||
* 401:
|
||||
* description: Incorrect password or invalid TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to disable TOTP.
|
||||
*/
|
||||
router.post("/totp/disable", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password, totp_code } = req.body;
|
||||
try {
|
||||
const userRecord = await createCurrentUserRepository().findById(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
// One re-authentication value, whichever kind it is. The dialog offers a
|
||||
// single field -- "Enter TOTP code or password" -- so it arrives in
|
||||
// whichever of the two body fields the caller happened to use.
|
||||
const credential = totp_code || password;
|
||||
if (!credential) {
|
||||
return res.status(400).json({
|
||||
error: userRecord.isOidc
|
||||
? "A TOTP code is required"
|
||||
: "A TOTP code or password is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (!userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
// TOTP code or backup code first; verifyTotpReauth deliberately refuses
|
||||
// the account password, so that stays a separate comparison here.
|
||||
let verified = await verifyTotpReauth(
|
||||
userRecord,
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!verified && !userRecord.isOidc && userRecord.passwordHash) {
|
||||
verified = await bcrypt.compare(credential, userRecord.passwordHash);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Incorrect password or invalid TOTP code" });
|
||||
}
|
||||
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
userId,
|
||||
"core",
|
||||
TOTP_FACTOR_ID,
|
||||
);
|
||||
authLogger.info("Two-factor authentication disabled", {
|
||||
operation: "totp_disable",
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/backup-codes:
|
||||
* post:
|
||||
* summary: Generate new backup codes
|
||||
* description: Generates new TOTP backup codes.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New backup codes generated.
|
||||
* 400:
|
||||
* description: Password or TOTP code is required.
|
||||
* 401:
|
||||
* description: Incorrect password or invalid TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to generate backup codes.
|
||||
*/
|
||||
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password, totp_code } = req.body;
|
||||
try {
|
||||
const userRecord = await createCurrentUserRepository().findById(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (!totp_code || (!userRecord.isOidc && !password)) {
|
||||
return res.status(400).json({
|
||||
error: userRecord.isOidc
|
||||
? "A TOTP code is required"
|
||||
: "Both password and TOTP code are required",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!userRecord.isOidc &&
|
||||
(!userRecord.passwordHash ||
|
||||
!(await bcrypt.compare(password, userRecord.passwordHash)))
|
||||
) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
const verified = await verifyTotpReauth(
|
||||
userRecord,
|
||||
totp_code,
|
||||
userDataKey,
|
||||
);
|
||||
if (!verified) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Incorrect password or invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () => generateBackupCode());
|
||||
|
||||
const backupCodesJson = JSON.stringify(backupCodes);
|
||||
const storedBackupCodes = userDataKey
|
||||
? FieldCrypto.encryptField(
|
||||
backupCodesJson,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
)
|
||||
: backupCodesJson;
|
||||
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpBackupCodes: storedBackupCodes,
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/verify-login:
|
||||
* post:
|
||||
* summary: Verify TOTP during login
|
||||
* description: Verifies the TOTP code during login.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* temp_token:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP verification successful.
|
||||
* 400:
|
||||
* description: Token and TOTP code are required.
|
||||
* 401:
|
||||
* description: Invalid temporary token or TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: TOTP verification failed.
|
||||
*/
|
||||
router.post("/totp/verify-login", async (req, res) => {
|
||||
if (!req.body?.temp_token || !req.body?.totp_code) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Token and TOTP code are required" });
|
||||
}
|
||||
try {
|
||||
await verifySecondFactorAndRespond(req, res, TOTP_FACTOR_ID);
|
||||
} catch (err) {
|
||||
authLogger.error("TOTP verification failed", err);
|
||||
return res.status(500).json({ error: "TOTP verification failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
RegistrationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
Base64URLString,
|
||||
WebAuthnCredential,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
import {
|
||||
createCurrentUserRepository,
|
||||
createCurrentWebauthnCredentialRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
|
||||
type UserVerification = "discouraged" | "preferred" | "required";
|
||||
type NativeAppRequestChecker = (req: Request) => boolean;
|
||||
|
||||
interface WebAuthnRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
isNativeAppRequest: NativeAppRequestChecker;
|
||||
}
|
||||
|
||||
interface ChallengeRecord {
|
||||
challenge: string;
|
||||
userId?: string;
|
||||
rpID: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const challengeTtlMs = 5 * 60 * 1000;
|
||||
const registrationChallenges = new Map<string, ChallengeRecord>();
|
||||
const authenticationChallenges = new Map<string, ChallengeRecord>();
|
||||
|
||||
function normalizeUserVerification(value: unknown): UserVerification {
|
||||
return value === "discouraged" || value === "required" ? value : "preferred";
|
||||
}
|
||||
|
||||
function getRequestOrigin(req: Request): string {
|
||||
const origin = req.get("origin");
|
||||
if (origin) return origin;
|
||||
|
||||
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
|
||||
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
|
||||
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
|
||||
}
|
||||
|
||||
function getRpID(origin: string): string {
|
||||
return new URL(origin).hostname;
|
||||
}
|
||||
|
||||
function pruneChallenges(map: Map<string, ChallengeRecord>): void {
|
||||
const now = Date.now();
|
||||
for (const [id, record] of map) {
|
||||
if (now - record.createdAt > challengeTtlMs) {
|
||||
map.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function putChallenge(
|
||||
map: Map<string, ChallengeRecord>,
|
||||
record: Omit<ChallengeRecord, "createdAt">,
|
||||
): string {
|
||||
pruneChallenges(map);
|
||||
const challengeId = nanoid();
|
||||
map.set(challengeId, { ...record, createdAt: Date.now() });
|
||||
return challengeId;
|
||||
}
|
||||
|
||||
function takeChallenge(
|
||||
map: Map<string, ChallengeRecord>,
|
||||
challengeId: unknown,
|
||||
): ChallengeRecord | null {
|
||||
if (typeof challengeId !== "string") return null;
|
||||
pruneChallenges(map);
|
||||
const record = map.get(challengeId);
|
||||
if (!record) return null;
|
||||
map.delete(challengeId);
|
||||
return record;
|
||||
}
|
||||
|
||||
function toBase64Url(value: Uint8Array): string {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(value, "base64url"));
|
||||
}
|
||||
|
||||
function parseTransports(value: string | null): AuthenticatorTransportFuture[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getCredentialForVerification(
|
||||
credential: WebauthnCredentialRecord,
|
||||
): WebAuthnCredential {
|
||||
return {
|
||||
id: credential.credentialId as Base64URLString,
|
||||
publicKey: fromBase64Url(
|
||||
credential.publicKey,
|
||||
) as WebAuthnCredential["publicKey"],
|
||||
counter: credential.counter,
|
||||
transports: parseTransports(credential.transports),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a passkey assertion and says who signed in. A passkey that verified
|
||||
* the user (PIN or biometric) counts as the second factor too.
|
||||
*/
|
||||
export async function verifyPasskeyLogin(
|
||||
req: Request,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const challenge = takeChallenge(
|
||||
authenticationChallenges,
|
||||
req.body?.challengeId,
|
||||
);
|
||||
if (!challenge) {
|
||||
throw new LoginMethodError("Authentication challenge expired", 400);
|
||||
}
|
||||
|
||||
const response = req.body?.response as AuthenticationResponseJSON | undefined;
|
||||
if (!response?.id) {
|
||||
throw new LoginMethodError("Invalid passkey response", 400);
|
||||
}
|
||||
|
||||
const credential =
|
||||
await createCurrentWebauthnCredentialRepository().findByCredentialId(
|
||||
response.id,
|
||||
);
|
||||
if (!credential) {
|
||||
throw new LoginMethodError("Passkey not recognized", 401);
|
||||
}
|
||||
if (challenge.userId && challenge.userId !== credential.userId) {
|
||||
throw new LoginMethodError("Passkey not recognized", 401);
|
||||
}
|
||||
|
||||
let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
credential: getCredentialForVerification(credential),
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
advancedFIDOConfig: { userVerification: challenge.userVerification },
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.warn("WebAuthn authentication failed", {
|
||||
operation: "webauthn_auth_verify",
|
||||
credentialId: credential.id,
|
||||
userId: credential.userId,
|
||||
error: getErrorMessage(error, "Unknown"),
|
||||
});
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
|
||||
await createCurrentWebauthnCredentialRepository().updateAuthState(
|
||||
credential.id,
|
||||
{
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
backedUp: verification.authenticationInfo.credentialBackedUp,
|
||||
deviceType: verification.authenticationInfo.credentialDeviceType,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: credential.userId,
|
||||
mfaSatisfied: verification.authenticationInfo.userVerified === true,
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
unlockError:
|
||||
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
|
||||
};
|
||||
}
|
||||
|
||||
export function registerUserWebAuthnRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/webauthn/credentials:
|
||||
* get:
|
||||
* summary: List passkeys
|
||||
* description: Lists the authenticated user's registered passkeys.
|
||||
* tags:
|
||||
* - WebAuthn
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of passkeys.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
*/
|
||||
router.get("/webauthn/credentials", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const credentials =
|
||||
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
|
||||
|
||||
res.json({
|
||||
credentials: credentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
deviceType: credential.deviceType,
|
||||
backedUp: credential.backedUp,
|
||||
transports: parseTransports(credential.transports),
|
||||
userVerification: credential.userVerification,
|
||||
createdAt: credential.createdAt,
|
||||
lastUsedAt: credential.lastUsedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/webauthn/register/options:
|
||||
* post:
|
||||
* summary: Start passkey registration
|
||||
* description: Generates WebAuthn registration options for the authenticated user.
|
||||
* tags:
|
||||
* - WebAuthn
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Registration options and challenge id.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
*/
|
||||
router.post(
|
||||
"/webauthn/register/options",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!authManager.getUserDataKey(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "User data is locked. Log in again before adding a passkey.",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const existing =
|
||||
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
|
||||
|
||||
const origin = getRequestOrigin(req);
|
||||
const rpID = getRpID(origin);
|
||||
const userVerification = normalizeUserVerification(
|
||||
req.body?.userVerification,
|
||||
);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: "Termix",
|
||||
rpID,
|
||||
userID: Buffer.from(userId, "utf8"),
|
||||
userName: user.username,
|
||||
userDisplayName: user.username,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
userVerification,
|
||||
},
|
||||
});
|
||||
|
||||
const challengeId = putChallenge(registrationChallenges, {
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpID,
|
||||
origin,
|
||||
userVerification,
|
||||
});
|
||||
|
||||
res.json({ options, challengeId });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/webauthn/register/verify:
|
||||
* post:
|
||||
* summary: Finish passkey registration
|
||||
* description: Verifies the WebAuthn registration response and stores the passkey.
|
||||
* tags:
|
||||
* - WebAuthn
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Passkey registered.
|
||||
* 400:
|
||||
* description: Registration failed or challenge expired.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
*/
|
||||
router.post(
|
||||
"/webauthn/register/verify",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const challenge = takeChallenge(
|
||||
registrationChallenges,
|
||||
req.body?.challengeId,
|
||||
);
|
||||
if (!challenge || challenge.userId !== userId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Registration challenge expired" });
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: req.body?.response as RegistrationResponseJSON,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
const transports =
|
||||
(req.body?.response as RegistrationResponseJSON | undefined)?.response
|
||||
?.transports ?? [];
|
||||
|
||||
const name =
|
||||
typeof req.body?.name === "string" && req.body.name.trim()
|
||||
? req.body.name.trim().slice(0, 80)
|
||||
: "Passkey";
|
||||
|
||||
await createCurrentWebauthnCredentialRepository().create({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
name,
|
||||
credentialId: credential.id,
|
||||
publicKey: toBase64Url(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: JSON.stringify(transports),
|
||||
userVerification: challenge.userVerification,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.warn("WebAuthn registration failed", {
|
||||
operation: "webauthn_register_verify",
|
||||
userId,
|
||||
error: getErrorMessage(error, "Unknown"),
|
||||
});
|
||||
res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/webauthn/authenticate/options:
|
||||
* post:
|
||||
* summary: Start passkey login
|
||||
* description: Generates WebAuthn authentication options, optionally scoped to a username.
|
||||
* tags:
|
||||
* - WebAuthn
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Authentication options and challenge id.
|
||||
* 404:
|
||||
* description: No passkeys found for the user.
|
||||
*/
|
||||
router.post("/webauthn/authenticate/options", async (req, res) => {
|
||||
const origin = getRequestOrigin(req);
|
||||
const rpID = getRpID(origin);
|
||||
const userVerification = normalizeUserVerification(
|
||||
req.body?.userVerification,
|
||||
);
|
||||
const username =
|
||||
typeof req.body?.username === "string" ? req.body.username.trim() : "";
|
||||
|
||||
let userId: string | undefined;
|
||||
let allowCredentials:
|
||||
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
|
||||
| undefined;
|
||||
|
||||
if (username) {
|
||||
const user = await createCurrentUserRepository().findByUsername(username);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "No passkeys found" });
|
||||
}
|
||||
|
||||
userId = user.id;
|
||||
const credentials =
|
||||
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
|
||||
|
||||
if (!credentials.length) {
|
||||
return res.status(404).json({ error: "No passkeys found" });
|
||||
}
|
||||
|
||||
allowCredentials = credentials.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
}));
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification,
|
||||
});
|
||||
|
||||
const challengeId = putChallenge(authenticationChallenges, {
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpID,
|
||||
origin,
|
||||
userVerification,
|
||||
});
|
||||
|
||||
res.json({ options, challengeId });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/webauthn/authenticate/verify:
|
||||
* post:
|
||||
* summary: Finish passkey login
|
||||
* description: Verifies the WebAuthn assertion and issues a session token. Verified WebAuthn user verification satisfies 2FA; possession-only keys retain the configured TOTP challenge.
|
||||
* tags:
|
||||
* - WebAuthn
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login succeeded.
|
||||
* 400:
|
||||
* description: Challenge expired or invalid response.
|
||||
* 401:
|
||||
* description: Passkey not recognized or authentication failed.
|
||||
*/
|
||||
router.post("/webauthn/authenticate/verify", async (req, res) => {
|
||||
try {
|
||||
const identity = await verifyPasskeyLogin(req);
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: "passkey",
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/webauthn/credentials/{credentialId}:
|
||||
* delete:
|
||||
* summary: Delete a passkey
|
||||
* description: Removes one of the authenticated user's passkeys.
|
||||
* tags:
|
||||
* - WebAuthn
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: credentialId
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Passkey deleted.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
*/
|
||||
router.delete(
|
||||
"/webauthn/credentials/:credentialId",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const credentialId = String(req.params.credentialId);
|
||||
|
||||
await createCurrentWebauthnCredentialRepository().deleteForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,6 @@ import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
|
||||
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
||||
import { registerUserWebAuthnRoutes } from "./user-webauthn-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";
|
||||
@@ -43,6 +41,7 @@ import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
@@ -215,9 +214,6 @@ router.post("/create", async (req, res) => {
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -750,9 +746,13 @@ router.post("/proxy-login", async (req, res) => {
|
||||
if (!userRecord) {
|
||||
return res.status(403).json({ error: "Proxy user must already exist" });
|
||||
}
|
||||
if (userRecord.isOidc || userRecord.totpEnabled) {
|
||||
if (
|
||||
userRecord.isOidc ||
|
||||
(await createCurrentUserAuthRepository().hasSecondFactor(userRecord.id))
|
||||
) {
|
||||
return res.status(409).json({
|
||||
error: "Proxy authentication cannot be used with OIDC or TOTP users",
|
||||
error:
|
||||
"Proxy authentication cannot be used with OIDC or second factor users",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -795,10 +795,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
if (
|
||||
!(await authManager.authenticateWebAuthnUser(
|
||||
userRecord.id,
|
||||
deviceInfo.type,
|
||||
))
|
||||
!(await authManager.unlockWithSystemKey(userRecord.id, deviceInfo.type))
|
||||
) {
|
||||
return res
|
||||
.status(409)
|
||||
@@ -1057,7 +1054,10 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
is_admin: !!user.isAdmin,
|
||||
is_oidc: !!user.isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
totp_enabled: !!user.totpEnabled,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
show_donation_modal: showDonationModal,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -1702,9 +1702,9 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid value for allowed" });
|
||||
}
|
||||
if (!allowed) {
|
||||
const totpEnabledCount =
|
||||
await createCurrentUserRepository().countTotpEnabled();
|
||||
if (totpEnabledCount > 0) {
|
||||
const secondFactorUsers =
|
||||
await createCurrentUserAuthRepository().countUsersWithSecondFactors();
|
||||
if (secondFactorUsers > 0) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.",
|
||||
@@ -1978,18 +1978,6 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
|
||||
registerUserAdminRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserTotpRoutes(router, {
|
||||
authenticateJWT,
|
||||
authManager,
|
||||
isNativeAppRequest,
|
||||
});
|
||||
|
||||
registerUserWebAuthnRoutes(router, {
|
||||
authenticateJWT,
|
||||
authManager,
|
||||
isNativeAppRequest,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/delete-user:
|
||||
|
||||
Reference in new issue
Block a user