mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -8,6 +8,7 @@ import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { registerPasswordLoginMethod } from "./builtin-login-methods.js";
|
||||
import { registerLegacyLoginProviders } from "./legacy-providers.js";
|
||||
import { listLoginMethods } from "./registry.js";
|
||||
import { LoginMethodError } from "./types.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
@@ -36,6 +37,29 @@ export function isPasswordLoginSettingOn(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a new second-factor enrolment when core could not honour it: with
|
||||
* trusted proxy login on the proxy decides who signs in, and with password
|
||||
* login off users sign in through external methods that skip second factors
|
||||
* by default.
|
||||
*/
|
||||
export function assertSecondFactorEnrollmentAllowed(): void {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
throw new LoginMethodError(
|
||||
"Second factors are disabled while trusted proxy authentication is enabled",
|
||||
409,
|
||||
"trusted_proxy_enabled",
|
||||
);
|
||||
}
|
||||
if (!isPasswordLoginSettingOn()) {
|
||||
throw new LoginMethodError(
|
||||
"Cannot enable 2FA while password login is disabled. Enable password login first.",
|
||||
409,
|
||||
"password_login_disabled",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off by default, matching 2.8: second factors only ran after password and
|
||||
* other local form logins. On, they also run after external methods (SSO,
|
||||
|
||||
@@ -16,12 +16,7 @@ import type {
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
type LoginMethod,
|
||||
} from "./registry.js";
|
||||
import { totpSecondFactor } from "./legacy/totp-factor.js";
|
||||
import { registerLoginMethod, type LoginMethod } from "./registry.js";
|
||||
|
||||
const OPKSSH_REQUIRED_MESSAGE =
|
||||
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
|
||||
@@ -328,22 +323,6 @@ const ldapLoginMethod: LoginMethod = {
|
||||
},
|
||||
};
|
||||
|
||||
// Passkeys prove the user as well as possession, so a passkey login can
|
||||
// stand in for the second factor. No describe: a passkey only helps a user
|
||||
// who already registered one, so it never counts for the lockout guard.
|
||||
const passkeyLoginMethod: LoginMethod = {
|
||||
id: "passkey",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithPasskey",
|
||||
icon: "fingerprint",
|
||||
kind: "form",
|
||||
verify: async (request) => {
|
||||
const { verifyPasskeyLogin } =
|
||||
await import("../database/routes/user-webauthn-routes.js");
|
||||
return verifyPasskeyLogin(request as never);
|
||||
},
|
||||
};
|
||||
|
||||
let loginRegistered = false;
|
||||
|
||||
export function registerLegacyLoginProviders(): void {
|
||||
@@ -351,8 +330,6 @@ export function registerLegacyLoginProviders(): void {
|
||||
loginRegistered = true;
|
||||
registerLoginMethod(oidcLoginMethod);
|
||||
registerLoginMethod(ldapLoginMethod);
|
||||
registerLoginMethod(passkeyLoginMethod);
|
||||
registerSecondFactor(totpSecondFactor);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* TOTP as a second factor. Enrolment stays in the TOTP routes; this is the
|
||||
* login half. Phase C moves both into the TOTP plugin.
|
||||
*/
|
||||
|
||||
import speakeasy from "speakeasy";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { SecondFactor } from "../registry.js";
|
||||
|
||||
export const TOTP_FACTOR_ID = "totp";
|
||||
|
||||
/**
|
||||
* Checks a backup code and burns it. Codes are stored encrypted under the
|
||||
* user's data key when it was available at enrolment, so both forms are read.
|
||||
*/
|
||||
async function consumeBackupCode(
|
||||
userId: string,
|
||||
storedCodes: string | null,
|
||||
code: string,
|
||||
userDataKey: Buffer,
|
||||
): Promise<boolean> {
|
||||
if (!storedCodes) return false;
|
||||
const raw = LazyFieldEncryption.safeGetFieldValue(
|
||||
storedCodes,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
);
|
||||
let codes: unknown = [];
|
||||
try {
|
||||
codes = raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
codes = [];
|
||||
}
|
||||
if (!Array.isArray(codes)) return false;
|
||||
const index = codes.indexOf(code);
|
||||
if (index === -1) return false;
|
||||
|
||||
codes.splice(index, 1);
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpBackupCodes: FieldCrypto.encryptField(
|
||||
JSON.stringify(codes),
|
||||
userDataKey,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export const totpSecondFactor: SecondFactor = {
|
||||
id: TOTP_FACTOR_ID,
|
||||
pluginId: "core",
|
||||
labelKey: "auth.totpFactor",
|
||||
|
||||
isEnrolled: async (userId) => {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return !!user?.totpEnabled;
|
||||
},
|
||||
|
||||
verify: async (userId, body) => {
|
||||
const code = String(body.totp_code ?? body.code ?? "").trim();
|
||||
if (!code) return false;
|
||||
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user?.totpEnabled || !user.totpSecret) {
|
||||
return { ok: false, error: "TOTP not enabled for this user" };
|
||||
}
|
||||
|
||||
const userDataKey = AuthManager.getInstance().getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
};
|
||||
}
|
||||
|
||||
const secret = LazyFieldEncryption.safeGetFieldValue(
|
||||
user.totpSecret,
|
||||
userDataKey,
|
||||
userId,
|
||||
"totp_secret",
|
||||
);
|
||||
if (!secret) {
|
||||
// The secret was encrypted under a key a password reset replaced.
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().removeSecondFactor(
|
||||
userId,
|
||||
"core",
|
||||
TOTP_FACTOR_ID,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"TOTP has been disabled due to password reset. Please set up TOTP again.",
|
||||
};
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: "base32",
|
||||
token: code,
|
||||
window: 2,
|
||||
});
|
||||
if (verified) return true;
|
||||
|
||||
return consumeBackupCode(userId, user.totpBackupCodes, code, userDataKey);
|
||||
},
|
||||
|
||||
reset: async (userId) => {
|
||||
await createCurrentUserRepository().update(userId, {
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -186,7 +186,7 @@ async function unlockUser(
|
||||
|
||||
const ok = identity.password
|
||||
? await authManager.authenticateUser(user.id, identity.password, deviceType)
|
||||
: await authManager.authenticateWebAuthnUser(user.id, deviceType);
|
||||
: await authManager.unlockWithSystemKey(user.id, deviceType);
|
||||
if (!ok) {
|
||||
throw new LoginMethodError(
|
||||
identity.password
|
||||
@@ -378,12 +378,13 @@ export function consumePendingLogin(token: string): void {
|
||||
|
||||
/**
|
||||
* Finishes a login that stopped for a second factor. The pending token comes
|
||||
* from the body, or from the cookie a redirect method set.
|
||||
* from the body, or from the cookie a redirect method set. Without a factor
|
||||
* id the user's first required factor answers, for the 2.8 route.
|
||||
*/
|
||||
export async function verifySecondFactorAndRespond(
|
||||
req: Request,
|
||||
res: Response,
|
||||
factorId: string,
|
||||
factorId?: string,
|
||||
): Promise<Response> {
|
||||
const lookup = await readPendingLogin(req);
|
||||
if (!lookup) {
|
||||
@@ -416,9 +417,9 @@ export async function verifySecondFactorAndRespond(
|
||||
code: "second_factor_unavailable",
|
||||
});
|
||||
}
|
||||
const factor = factors.required.find(
|
||||
(candidate) => candidate.id === factorId,
|
||||
);
|
||||
const factor = factorId
|
||||
? factors.required.find((candidate) => candidate.id === factorId)
|
||||
: factors.required[0];
|
||||
if (!factor) {
|
||||
return res
|
||||
.status(400)
|
||||
@@ -436,7 +437,7 @@ export async function verifySecondFactorAndRespond(
|
||||
authLogger.warn("Second factor verification failed", {
|
||||
operation: "totp_verify_failed",
|
||||
userId: user.id,
|
||||
factorId,
|
||||
factorId: factor.id,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
@@ -444,7 +445,7 @@ export async function verifySecondFactorAndRespond(
|
||||
username: user.username,
|
||||
action: "login_second_factor_failed",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ factor: factorId }),
|
||||
details: JSON.stringify({ factor: factor.id }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
|
||||
@@ -11,7 +11,10 @@ import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { emitInternalEvent } from "../hosts/internal-events.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
@@ -139,7 +142,10 @@ export async function issueSession(
|
||||
username: user.username,
|
||||
userId: user.id,
|
||||
is_oidc: !!user.isOidc,
|
||||
totp_enabled: !!user.totpEnabled,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,6 +24,8 @@ interface ActorStore {
|
||||
userId: string;
|
||||
/** Where the identity came from, for the audit line. */
|
||||
source: "request" | "asUser" | "service";
|
||||
/** The session a request came in on, so core can spare it when revoking. */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<ActorStore>();
|
||||
@@ -32,8 +34,13 @@ export function runAsActor<T>(
|
||||
userId: string,
|
||||
source: ActorStore["source"],
|
||||
fn: () => T,
|
||||
sessionId?: string,
|
||||
): T {
|
||||
return storage.run({ userId, source }, fn);
|
||||
return storage.run({ userId, source, sessionId }, fn);
|
||||
}
|
||||
|
||||
export function getActorSessionId(): string | undefined {
|
||||
return storage.getStore()?.sessionId;
|
||||
}
|
||||
|
||||
export function getActor(): string | undefined {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
PluginSshInteractionError,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import { getActor, getActorSessionId } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
@@ -511,13 +511,26 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
recordEnrollment: async (userId, factorId) => {
|
||||
requireDeclared(contributes.secondFactors, factorId, "secondFactors");
|
||||
await granted();
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { assertSecondFactorEnrollmentAllowed } =
|
||||
await import("../auth/core-auth.js");
|
||||
assertSecondFactorEnrollmentAllowed();
|
||||
const {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
await createCurrentUserAuthRepository().recordSecondFactor(
|
||||
userId,
|
||||
pluginId,
|
||||
factorId,
|
||||
);
|
||||
// A new factor signs the user out everywhere else and forgets trusted
|
||||
// devices, so nothing that skipped it before keeps skipping it.
|
||||
await createCurrentSessionRepository().revokeAllForUser(
|
||||
userId,
|
||||
getActor() === userId ? getActorSessionId() : undefined,
|
||||
);
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -600,6 +600,26 @@ export function createPluginContext(
|
||||
{ action: "secret_delete", details: () => "deleted a secret" },
|
||||
),
|
||||
|
||||
seal: async (value) => {
|
||||
await assertCapability(pluginId, "secrets:own", declared);
|
||||
const { encryptSystemSecret } =
|
||||
await import("../utils/system-secret-crypto.js");
|
||||
return encryptSystemSecret(value);
|
||||
},
|
||||
|
||||
unseal: async (sealed) => {
|
||||
await assertCapability(pluginId, "secrets:own", declared);
|
||||
const { decryptSystemSecret, isSystemEncrypted } =
|
||||
await import("../utils/system-secret-crypto.js");
|
||||
// Only values seal() wrote: a plain string must not pass as sealed.
|
||||
if (!sealed || !isSystemEncrypted(sealed)) return null;
|
||||
try {
|
||||
return await decryptSystemSecret(sealed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
offer: (key, resolve) => {
|
||||
const entry = manifest.providesSecret?.find(
|
||||
(candidate) => candidate.key === key,
|
||||
|
||||
@@ -204,12 +204,15 @@ export function createPluginRouter({
|
||||
// After auth, so the actor is the user core authenticated rather than
|
||||
// whatever the request claimed.
|
||||
outer.use((req: Request, _res: Response, next: NextFunction) => {
|
||||
const userId = (req as Request & { userId?: string }).userId;
|
||||
const { userId, sessionId } = req as Request & {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
if (!userId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
runAsActor(userId, "request", () => next());
|
||||
runAsActor(userId, "request", () => next(), sessionId);
|
||||
});
|
||||
|
||||
outer.use(inner);
|
||||
|
||||
+14
-10
@@ -70,9 +70,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -203,18 +200,21 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} = await import("./database/repositories/factory.js");
|
||||
const [legacyOidc, providers, users] = await Promise.all([
|
||||
createCurrentSettingsRepository().get("oidc_config"),
|
||||
createCurrentSsoProviderRepository().listEnabled(),
|
||||
createCurrentUserRepository().listAll(),
|
||||
]);
|
||||
const [legacyOidc, providers, users, secondFactorUsers] =
|
||||
await Promise.all([
|
||||
createCurrentSettingsRepository().get("oidc_config"),
|
||||
createCurrentSsoProviderRepository().listEnabled(),
|
||||
createCurrentUserRepository().listAll(),
|
||||
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
|
||||
]);
|
||||
const conflictingProvider = providers.some((provider) =>
|
||||
["oidc", "github", "google"].includes(provider.type),
|
||||
);
|
||||
const conflictingUser = users.some(
|
||||
(user) => user.isOidc || user.totpEnabled,
|
||||
(user) => user.isOidc || secondFactorUsers.has(user.id),
|
||||
);
|
||||
if (
|
||||
legacyOidc ||
|
||||
@@ -223,7 +223,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
conflictingUser
|
||||
) {
|
||||
throw new Error(
|
||||
"Trusted proxy authentication cannot start while OIDC or TOTP is enabled",
|
||||
"Trusted proxy authentication cannot start while OIDC or a second factor is enabled",
|
||||
);
|
||||
}
|
||||
systemLogger.info("Trusted proxy authentication enabled", {
|
||||
@@ -409,6 +409,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runSecretSourcesTokenMigration } =
|
||||
await import("./utils/crypto-migration/secret-sources-token-migration.js");
|
||||
await runSecretSourcesTokenMigration();
|
||||
|
||||
const { runTotpMigration } =
|
||||
await import("./utils/crypto-migration/totp-migration.js");
|
||||
await runTotpMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -8,9 +8,6 @@ export interface FakeUser {
|
||||
isOidc: boolean;
|
||||
oidcIdentifier?: string | null;
|
||||
ssoProviderId?: number | null;
|
||||
totpEnabled?: boolean;
|
||||
totpSecret?: string | null;
|
||||
totpBackupCodes?: string | null;
|
||||
}
|
||||
|
||||
/** In-memory stand-ins for the repositories the login pipeline touches. */
|
||||
@@ -99,6 +96,12 @@ export function fakeFactory(state: AuthState) {
|
||||
},
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.factors.filter((row) => row.userId === userId),
|
||||
hasSecondFactor: async (userId: string) =>
|
||||
state.factors.some((row) => row.userId === userId),
|
||||
listUserIdsWithSecondFactors: async () =>
|
||||
new Set(state.factors.map((row) => row.userId)),
|
||||
countUsersWithSecondFactors: async () =>
|
||||
new Set(state.factors.map((row) => row.userId)).size,
|
||||
recordSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
@@ -199,7 +202,7 @@ export function fakeAuthManager(state: AuthState) {
|
||||
}),
|
||||
authenticateUser: vi.fn(async () => true),
|
||||
authenticateOIDCUser: vi.fn(async () => true),
|
||||
authenticateWebAuthnUser: vi.fn(async () => true),
|
||||
unlockWithSystemKey: vi.fn(async () => true),
|
||||
registerOIDCUser: vi.fn(async () => {}),
|
||||
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
|
||||
state.trusted.has(`${userId}:${fingerprint}`),
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("LDAP through the pipeline", () => {
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("skips TOTP for an LDAP user who enrolled when the external-login setting is off", async () => {
|
||||
it("skips second factors for an LDAP user who enrolled when the external-login setting is off", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
@@ -367,8 +367,11 @@ describe("LDAP through the pipeline", () => {
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
totpEnabled: true,
|
||||
totpSecret: "JBSWY3DPEHPK3PXP",
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
pluginId: "fixture",
|
||||
factorId: "pin",
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
@@ -381,7 +384,7 @@ describe("LDAP through the pipeline", () => {
|
||||
expect(result.kind).toBe("session");
|
||||
});
|
||||
|
||||
it("runs TOTP for an LDAP user who enrolled when the external-login setting is on", async () => {
|
||||
it("runs second factors for an LDAP user who enrolled when the external-login setting is on", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
h.state.users.set("u-bob", {
|
||||
@@ -391,17 +394,32 @@ describe("LDAP through the pipeline", () => {
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
totpEnabled: true,
|
||||
totpSecret: "JBSWY3DPEHPK3PXP",
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
pluginId: "fixture",
|
||||
factorId: "pin",
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
ip: "10.0.0.3",
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||
const dispose = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "fixture",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => true,
|
||||
});
|
||||
expect(result.kind).toBe("second-factor");
|
||||
try {
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("second-factor");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
import {
|
||||
createAuthState,
|
||||
fakeAuthManager,
|
||||
@@ -98,7 +97,19 @@ const { registerLoginMethod, registerSecondFactor } =
|
||||
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
|
||||
|
||||
const PASSWORD = "correct-horse";
|
||||
const totpSecret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
// Stands in for the totp plugin's factor: core only sees the registration.
|
||||
const TOTP_CODE = "123456";
|
||||
let disposeTotp: (() => void) | null = null;
|
||||
function registerTotpFixture() {
|
||||
disposeTotp = registerSecondFactor({
|
||||
id: "totp",
|
||||
pluginId: "totp",
|
||||
labelKey: "totp:factor",
|
||||
isEnrolled: async () => false,
|
||||
verify: async (_userId, body) => body.totp_code === TOTP_CODE,
|
||||
reset: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function addUser(
|
||||
overrides: Partial<import("./auth-test-helpers.js").FakeUser> = {},
|
||||
@@ -109,7 +120,6 @@ function addUser(
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
totpEnabled: false,
|
||||
...overrides,
|
||||
};
|
||||
h.state.users.set(user.id, user);
|
||||
@@ -124,6 +134,8 @@ beforeEach(() => {
|
||||
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
|
||||
loginRateLimiter.resetTOTPAttempts("u1");
|
||||
delete process.env.ALLOW_PASSWORD_LOGIN;
|
||||
disposeTotp?.();
|
||||
registerTotpFixture();
|
||||
});
|
||||
|
||||
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
|
||||
@@ -241,12 +253,8 @@ describe("password login through the pipeline", () => {
|
||||
|
||||
describe("second factors", () => {
|
||||
function enrolTotp() {
|
||||
addUser({
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01"]),
|
||||
});
|
||||
h.state.factors.push({ userId: "u1", pluginId: "core", factorId: "totp" });
|
||||
addUser();
|
||||
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
|
||||
}
|
||||
|
||||
it("stops for TOTP with the pending token the old route returned", async () => {
|
||||
@@ -259,7 +267,7 @@ describe("second factors", () => {
|
||||
temp_token: "pending-1",
|
||||
rememberMe: false,
|
||||
second_factors: [
|
||||
expect.objectContaining({ id: "totp", pluginId: "core" }),
|
||||
expect.objectContaining({ id: "totp", pluginId: "totp" }),
|
||||
],
|
||||
});
|
||||
expect(h.manager.generateJWTToken).toHaveBeenCalledWith("u1", {
|
||||
@@ -280,7 +288,7 @@ describe("second factors", () => {
|
||||
const req = fakeRequest({
|
||||
body: {
|
||||
temp_token: tempToken,
|
||||
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
|
||||
totp_code: TOTP_CODE,
|
||||
rememberMe: true,
|
||||
},
|
||||
});
|
||||
@@ -298,24 +306,40 @@ describe("second factors", () => {
|
||||
expect(h.state.audits.at(-1)).toMatchObject({ action: "login" });
|
||||
});
|
||||
|
||||
it("accepts a backup code once, stored encrypted", async () => {
|
||||
it("answers the 2.8 route with the user's first factor when none is named", async () => {
|
||||
enrolTotp();
|
||||
const first = await passwordLogin({
|
||||
username: "alice",
|
||||
password: PASSWORD,
|
||||
});
|
||||
const tempToken = (first.body as { temp_token: string }).temp_token;
|
||||
const res = fakeResponse();
|
||||
await verifySecondFactorAndRespond(
|
||||
fakeRequest({
|
||||
body: { temp_token: tempToken, totp_code: "BACKUP01" },
|
||||
body: {
|
||||
temp_token: (first.body as { temp_token: string }).temp_token,
|
||||
totp_code: TOTP_CODE,
|
||||
},
|
||||
}) as never,
|
||||
res as never,
|
||||
"totp",
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Re-encrypted after use, and the code is gone.
|
||||
expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01");
|
||||
});
|
||||
|
||||
it("fails closed for a 2.8 TOTP user while the totp plugin is disabled", async () => {
|
||||
enrolTotp();
|
||||
disposeTotp?.();
|
||||
disposeTotp = null;
|
||||
const req = fakeRequest({
|
||||
body: { username: "alice", password: PASSWORD },
|
||||
});
|
||||
const identity = await verifyPasswordLogin(req as never);
|
||||
await expect(
|
||||
runLogin(req as never, identity, {
|
||||
methodId: "password",
|
||||
rememberMe: false,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" });
|
||||
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still stops password login for TOTP when the external-login setting is off", async () => {
|
||||
@@ -562,9 +586,8 @@ describe("external identities", () => {
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
});
|
||||
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u9",
|
||||
@@ -586,9 +609,8 @@ describe("external identities", () => {
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
});
|
||||
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u9",
|
||||
@@ -658,9 +680,8 @@ describe("redirect logins", () => {
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
totpEnabled: true,
|
||||
totpSecret,
|
||||
});
|
||||
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u9",
|
||||
@@ -686,7 +707,7 @@ describe("redirect logins", () => {
|
||||
fakeRequest({
|
||||
cookies: { termix_pending_login: res.cookies[0].value },
|
||||
body: {
|
||||
totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }),
|
||||
totp_code: TOTP_CODE,
|
||||
},
|
||||
}) as never,
|
||||
verify as never,
|
||||
|
||||
@@ -54,7 +54,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
});
|
||||
|
||||
expect(await repo.users.countAdmins()).toBe(1);
|
||||
expect(await repo.users.countTotpEnabled()).toBe(0);
|
||||
expect((await repo.users.listAll()).map((user) => user.id)).toEqual([
|
||||
"user-1",
|
||||
]);
|
||||
@@ -66,10 +65,8 @@ describe("UserRepository and SessionRepository", () => {
|
||||
|
||||
const updated = await repo.users.update("user-1", {
|
||||
oidcIdentifier: "oidc:admin",
|
||||
totpEnabled: true,
|
||||
});
|
||||
expect(updated?.oidcIdentifier).toBe("oidc:admin");
|
||||
expect(await repo.users.countTotpEnabled()).toBe(1);
|
||||
expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ function makeUser(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
username: "local",
|
||||
passwordHash: "",
|
||||
isOidc: false,
|
||||
totpEnabled: false,
|
||||
isAdmin: false,
|
||||
registeredAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
@@ -146,11 +145,6 @@ describe("resolveDesktopAutoSessionUser", () => {
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the sole local user even when TOTP-enabled", () => {
|
||||
const user = makeUser({ totpEnabled: true });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the auto-provisioned passwordless placeholder", () => {
|
||||
const user = makeUser({ passwordHash: "" });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
|
||||
const userUpdate = vi.fn().mockResolvedValue(null);
|
||||
const findById = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById, update: userUpdate }),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
createCurrentUserSessionRepository: () => ({}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
removeSecondFactor: vi.fn().mockResolvedValue(true),
|
||||
recordSecondFactor: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
const { registerUserTotpRoutes } =
|
||||
await import("../../../database/routes/user-totp-routes.js");
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
const PASSWORD = "correct-horse";
|
||||
|
||||
/**
|
||||
* The disable dialog has one field, labelled "Enter TOTP code or password",
|
||||
* and its caller passes that single value as `disableTOTP(input)` — which
|
||||
* lands in the `password` argument, leaving `totp_code` undefined.
|
||||
*
|
||||
* 2.5.1 changed the route to require both, so from then on the first check
|
||||
* rejected every attempt regardless of what was typed. Nobody could turn 2FA
|
||||
* off, and the client reported it as the generic "Failed to disable 2FA".
|
||||
*/
|
||||
describe("POST /totp/disable", () => {
|
||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
||||
let setupHandler: (req: unknown, res: unknown) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const routes = new Map<string, (req: unknown, res: unknown) => unknown>();
|
||||
const router = {
|
||||
post: (path: string, ...rest: unknown[]) => {
|
||||
routes.set(path, rest[rest.length - 1] as never);
|
||||
},
|
||||
get: () => {},
|
||||
put: () => {},
|
||||
delete: () => {},
|
||||
};
|
||||
|
||||
registerUserTotpRoutes(
|
||||
router as never,
|
||||
{
|
||||
authenticateJWT: (() => {}) as never,
|
||||
authManager: { getUserDataKey: () => null } as never,
|
||||
isNativeAppRequest: () => false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
handler = routes.get("/totp/disable") as never;
|
||||
setupHandler = routes.get("/totp/setup") as never;
|
||||
findById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "test-user",
|
||||
isOidc: false,
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01"]),
|
||||
totpEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
function call(body: Record<string, unknown>) {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return handler({ userId: "user-1", body }, res).then(() => res);
|
||||
}
|
||||
|
||||
function callSetup(body: Record<string, unknown>) {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return setupHandler({ userId: "user-1", body }, res).then(() => res);
|
||||
}
|
||||
|
||||
it("reveals the existing enrollment only after re-authentication", async () => {
|
||||
const denied = await callSetup({ credential: "wrong" });
|
||||
expect(denied.statusCode).toBe(401);
|
||||
|
||||
const allowed = await callSetup({ credential: PASSWORD });
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
expect(allowed.body).toEqual(
|
||||
expect.objectContaining({ secret, additional: true }),
|
||||
);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a credential before adding another authenticator", async () => {
|
||||
const res = await callSetup({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the TOTP code on its own", async () => {
|
||||
// What the dialog sends: one value, in whichever field the client used.
|
||||
const res = await call({
|
||||
totp_code: speakeasy.totp({ secret, encoding: "base32" }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(userUpdate).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ totpEnabled: false, totpSecret: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the account password on its own", async () => {
|
||||
// The single field is labelled "TOTP code or password", and the client
|
||||
// passes it as the password argument — this is the exact failing call.
|
||||
const res = await call({ password: PASSWORD });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(userUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a backup code", async () => {
|
||||
const res = await call({ totp_code: "BACKUP01" });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("still refuses a wrong value", async () => {
|
||||
const res = await call({ password: "not-my-password" });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an empty request rather than disabling anything", async () => {
|
||||
const res = await call({});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let an OIDC user disable it with a password", async () => {
|
||||
// No password to compare against; only a TOTP or backup code will do.
|
||||
findById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
isOidc: true,
|
||||
passwordHash: null,
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify([]),
|
||||
totpEnabled: true,
|
||||
});
|
||||
|
||||
const res = await call({ password: "anything" });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ const state = vi.hoisted(() => ({
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash: string | null;
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
>(),
|
||||
unlockedUsers: new Set<string>(),
|
||||
@@ -94,6 +93,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.secondFactors.filter((row) => row.userId === userId),
|
||||
listUserIdsWithSecondFactors: async () =>
|
||||
new Set(state.secondFactors.map((row) => row.userId)),
|
||||
removeSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
@@ -127,6 +128,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
|
||||
const { registerUserAdminRoutes } =
|
||||
await import("../../../database/routes/user-admin-routes.js");
|
||||
const { registerSecondFactor } = await import("../../../auth/registry.js");
|
||||
|
||||
// Capture the handlers registered on the router so we can invoke them directly
|
||||
// without spinning up an HTTP server.
|
||||
@@ -225,7 +227,6 @@ beforeEach(() => {
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -236,7 +237,6 @@ beforeEach(() => {
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -247,7 +247,6 @@ beforeEach(() => {
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -257,15 +256,19 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /list", () => {
|
||||
it("includes data_unlocked and totp_enabled for admin callers", async () => {
|
||||
it("includes data_unlocked and second_factor_enabled for admin callers", async () => {
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "totp", factorId: "totp" },
|
||||
];
|
||||
const res = await invoke("get", "/list");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
|
||||
const target = users.find((u) => u.userId === "target1")!;
|
||||
expect(target.data_unlocked).toBe(true);
|
||||
expect(target.totp_enabled).toBe(true);
|
||||
expect(target.second_factor_enabled).toBe(true);
|
||||
const locked = users.find((u) => u.userId === "locked1")!;
|
||||
expect(locked.data_unlocked).toBe(false);
|
||||
expect(locked.second_factor_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("omits management fields for non-admin callers", async () => {
|
||||
@@ -273,7 +276,7 @@ describe("GET /list", () => {
|
||||
const res = await invoke("get", "/list");
|
||||
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
|
||||
expect(users[0].data_unlocked).toBeUndefined();
|
||||
expect(users[0].totp_enabled).toBeUndefined();
|
||||
expect(users[0].second_factor_enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns every user when no limit is given", async () => {
|
||||
@@ -335,46 +338,6 @@ describe("GET /list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /admin/totp/disable", () => {
|
||||
it("clears TOTP fields for the target and audits", async () => {
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "target1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const update = state.updates.find((u) => u.id === "target1");
|
||||
expect(update?.changes).toMatchObject({
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
expect(
|
||||
state.auditCalls.some((c) => c.action === "admin_disable_totp"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("403s when the caller is not an admin", async () => {
|
||||
state.currentUserId = "target1";
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "locked1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("400s when TOTP is not enabled for the target", async () => {
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "locked1" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("404s for an unknown target", async () => {
|
||||
const res = await invoke("post", "/admin/totp/disable", {
|
||||
body: { userId: "ghost" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /admin/export/:userId", () => {
|
||||
it("423s when the target's data is locked", async () => {
|
||||
const res = await invoke("get", "/admin/export/:userId", {
|
||||
@@ -395,23 +358,31 @@ describe("GET /admin/export/:userId", () => {
|
||||
|
||||
describe("DELETE /admin/:userId/second-factors", () => {
|
||||
it("clears every factor, including one whose plugin is gone, and audits", async () => {
|
||||
const reset = vi.fn(async () => {});
|
||||
const dispose = registerSecondFactor({
|
||||
id: "totp",
|
||||
pluginId: "totp",
|
||||
labelKey: "factor",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => false,
|
||||
reset,
|
||||
});
|
||||
state.secondFactors = [
|
||||
{ userId: "target1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "target1", pluginId: "totp", factorId: "totp" },
|
||||
{ userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" },
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "locked1", pluginId: "totp", factorId: "totp" },
|
||||
];
|
||||
const res = await invoke("delete", "/admin/:userId/second-factors", {
|
||||
params: { userId: "target1" },
|
||||
});
|
||||
dispose();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.secondFactors).toEqual([
|
||||
{ userId: "locked1", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "locked1", pluginId: "totp", factorId: "totp" },
|
||||
]);
|
||||
expect(state.trustedDevicesCleared).toContain("target1");
|
||||
// TOTP's own reset cleared its secret.
|
||||
expect(
|
||||
state.updates.find((u) => u.id === "target1")?.changes,
|
||||
).toMatchObject({ totpEnabled: false, totpSecret: null });
|
||||
// The running plugin's own reset dropped its secret.
|
||||
expect(reset).toHaveBeenCalledWith("target1");
|
||||
const audit = state.auditCalls.find(
|
||||
(c) => c.action === "admin_reset_second_factors",
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ const calls = vi.hoisted(() => ({
|
||||
rotatedFor: [] as string[],
|
||||
legacyWrapsDeletedFor: [] as string[],
|
||||
dataWipedEvents: [] as Array<{ userId: string }>,
|
||||
secondFactorsReset: [] as string[],
|
||||
}));
|
||||
|
||||
function deletingRepo(label: string) {
|
||||
@@ -59,6 +60,13 @@ vi.mock("../../../utils/crypto-migration/dek-migration.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../auth/second-factor-admin.js", () => ({
|
||||
resetUserSecondFactors: async (userId: string) => {
|
||||
calls.secondFactorsReset.push(userId);
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
|
||||
import { resetUserPassword } from "../../../database/routes/user-password-reset-routes.js";
|
||||
|
||||
function fakeAuthManager(unlocked: boolean): AuthManager {
|
||||
@@ -70,6 +78,7 @@ function fakeAuthManager(unlocked: boolean): AuthManager {
|
||||
|
||||
beforeEach(() => {
|
||||
calls.userUpdates = [];
|
||||
calls.secondFactorsReset = [];
|
||||
calls.deletedFor = [];
|
||||
calls.rotatedFor = [];
|
||||
calls.legacyWrapsDeletedFor = [];
|
||||
@@ -124,8 +133,6 @@ describe("resetUserPassword", () => {
|
||||
expect(calls.dataWipedEvents).toEqual([{ userId: "user-1" }]);
|
||||
expect(calls.rotatedFor).toEqual(["user-1"]);
|
||||
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
|
||||
expect(
|
||||
calls.userUpdates.some(([, update]) => update.totpEnabled === false),
|
||||
).toBe(true);
|
||||
expect(calls.secondFactorsReset).toEqual(["user-1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
|
||||
// The route module imports repository factories and the logger; stub both so
|
||||
// importing stays inert.
|
||||
const userRepositoryUpdate = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
update: userRepositoryUpdate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { verifyTotpReauth } =
|
||||
await import("../../../database/routes/user-totp-routes.js");
|
||||
|
||||
type AnyUser = Parameters<typeof verifyTotpReauth>[0];
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
|
||||
function makeUser(overrides: Partial<AnyUser> = {}): AnyUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
isOidc: false,
|
||||
passwordHash: bcrypt.hashSync("correct-horse", 4),
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01", "BACKUP02"]),
|
||||
totpEnabled: true,
|
||||
...overrides,
|
||||
} as AnyUser;
|
||||
}
|
||||
|
||||
describe("verifyTotpReauth", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not accept the account password as a second factor", async () => {
|
||||
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a valid TOTP code without a password", async () => {
|
||||
const token = speakeasy.totp({ secret, encoding: "base32" });
|
||||
expect(await verifyTotpReauth(makeUser(), token)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a valid backup code and consumes it", async () => {
|
||||
const result = await verifyTotpReauth(makeUser(), "BACKUP01");
|
||||
expect(result).toBe(true);
|
||||
expect(userRepositoryUpdate).toHaveBeenCalledWith("user-1", {
|
||||
totpBackupCodes: JSON.stringify(["BACKUP02"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong password / invalid code", async () => {
|
||||
expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false);
|
||||
expect(userRepositoryUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores the password path for OIDC users but still accepts TOTP", async () => {
|
||||
const token = speakeasy.totp({ secret, encoding: "base32" });
|
||||
const oidcUser = makeUser({ isOidc: true, passwordHash: null });
|
||||
expect(await verifyTotpReauth(oidcUser, token)).toBe(true);
|
||||
expect(await verifyTotpReauth(oidcUser, "anything")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles malformed backup-code JSON without throwing", async () => {
|
||||
const user = makeUser({ totpBackupCodes: "not json" });
|
||||
expect(await verifyTotpReauth(user, "BACKUP01")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AuthManager } from "../../../utils/auth-manager.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verify: vi.fn(),
|
||||
user: vi.fn(),
|
||||
update: vi.fn(),
|
||||
manager: {
|
||||
authenticateWebAuthnUser: vi.fn(),
|
||||
authenticateOIDCUser: vi.fn(),
|
||||
isTrustedDevice: vi.fn(),
|
||||
generateJWTToken: vi.fn(),
|
||||
getSecureCookieOptions: vi.fn(),
|
||||
getClearCookieOptions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => mocks.manager },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateAuthenticationOptions: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ challenge: "challenge" }),
|
||||
verifyAuthenticationResponse: mocks.verify,
|
||||
generateRegistrationOptions: vi.fn(),
|
||||
verifyRegistrationResponse: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById: mocks.user }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listSecondFactors: async () => [],
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentWebauthnCredentialRepository: () => ({
|
||||
findByCredentialId: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
credentialId: "key",
|
||||
userId: "user",
|
||||
publicKey: "YQ",
|
||||
counter: 0,
|
||||
transports: "[]",
|
||||
}),
|
||||
updateAuthState: mocks.update,
|
||||
}),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return { authLogger: log, sshLogger: log, logger: log, databaseLogger: log };
|
||||
});
|
||||
vi.mock("../../../utils/user-agent-parser.js", () => ({
|
||||
parseUserAgent: () => ({ type: "browser", deviceInfo: "test" }),
|
||||
generateDeviceFingerprint: () => "device",
|
||||
getDeviceId: () => "id",
|
||||
}));
|
||||
const { registerUserWebAuthnRoutes } =
|
||||
await import("../../../database/routes/user-webauthn-routes.js");
|
||||
type Handler = (req: Request, res: Response) => Promise<unknown>;
|
||||
const handlers = new Map<string, Handler>();
|
||||
const manager = mocks.manager;
|
||||
registerUserWebAuthnRoutes(
|
||||
{
|
||||
post: (path: string, ...callbacks: Handler[]) =>
|
||||
handlers.set(path, callbacks.at(-1)!),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Router,
|
||||
{
|
||||
authenticateJWT: vi.fn(),
|
||||
authManager: manager as unknown as AuthManager,
|
||||
isNativeAppRequest: () => false,
|
||||
},
|
||||
);
|
||||
function response() {
|
||||
const res = { json: vi.fn(), status: vi.fn(), cookie: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
res.cookie.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
async function login(userVerification: string) {
|
||||
const options = response();
|
||||
await handlers.get("/webauthn/authenticate/options")!(
|
||||
{
|
||||
body: { userVerification },
|
||||
get: () => "https://termix.test",
|
||||
} as unknown as Request,
|
||||
options as unknown as Response,
|
||||
);
|
||||
const challengeId = options.json.mock.calls[0][0].challengeId;
|
||||
const res = response();
|
||||
await handlers.get("/webauthn/authenticate/verify")!(
|
||||
{
|
||||
body: { challengeId, response: { id: "key" }, rememberMe: true },
|
||||
get: () => undefined,
|
||||
headers: {},
|
||||
socket: {},
|
||||
} as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager.authenticateWebAuthnUser.mockResolvedValue(true);
|
||||
manager.authenticateOIDCUser.mockResolvedValue(true);
|
||||
manager.generateJWTToken.mockResolvedValue("token");
|
||||
manager.getSecureCookieOptions.mockReturnValue({ httpOnly: true });
|
||||
manager.getClearCookieOptions.mockReturnValue({});
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
totpEnabled: true,
|
||||
});
|
||||
manager.isTrustedDevice.mockResolvedValue(false);
|
||||
mocks.verify.mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { userVerified: false, newCounter: 1 },
|
||||
});
|
||||
});
|
||||
describe("WebAuthn second factor policy", () => {
|
||||
it.each(["discouraged", "preferred", "required"])(
|
||||
"does not let request preference %s bypass TOTP without verified UV",
|
||||
async (preference) => {
|
||||
const res = await login(preference);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requires_totp: true, temp_token: "token" }),
|
||||
);
|
||||
expect(manager.generateJWTToken).toHaveBeenCalledWith("user", {
|
||||
pendingTOTP: true,
|
||||
expiresIn: "10m",
|
||||
});
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("allows verified user verification to satisfy TOTP", async () => {
|
||||
mocks.verify.mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { userVerified: true, newCounter: 1 },
|
||||
});
|
||||
const res = await login("preferred");
|
||||
expect(res.cookie).toHaveBeenCalledWith("jwt", "token", { httpOnly: true });
|
||||
expect(manager.isTrustedDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves trusted-device exemption", async () => {
|
||||
manager.isTrustedDevice.mockResolvedValue(true);
|
||||
const res = await login("discouraged");
|
||||
expect(manager.isTrustedDevice).toHaveBeenCalledWith("user", "device");
|
||||
expect(res.cookie).toHaveBeenCalled();
|
||||
});
|
||||
it("never creates a session for an invalid assertion", async () => {
|
||||
mocks.verify.mockResolvedValue({ verified: false });
|
||||
const res = await login("preferred");
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(manager.generateJWTToken).not.toHaveBeenCalled();
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
it("allows users without TOTP to log in with a possession-only key", async () => {
|
||||
mocks.user.mockResolvedValue({
|
||||
id: "user",
|
||||
username: "alice",
|
||||
totpEnabled: false,
|
||||
});
|
||||
expect((await login("discouraged")).cookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:"),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
@@ -156,4 +157,20 @@ describe("ctx.secrets store", () => {
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("seals without an acting user and refuses without secrets:own", async () => {
|
||||
const denied = contextFor([]);
|
||||
await expect(denied.secrets.seal("v")).rejects.toThrow(/secrets:own/);
|
||||
await expect(denied.secrets.unseal("sysenc:v")).rejects.toThrow(
|
||||
/secrets:own/,
|
||||
);
|
||||
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
const sealed = await ctx.secrets.seal("totp-secret");
|
||||
expect(sealed).toBe("sysenc:totp-secret");
|
||||
expect(await ctx.secrets.unseal(sealed)).toBe("totp-secret");
|
||||
// A plain string was never sealed and must not read back as one.
|
||||
expect(await ctx.secrets.unseal("totp-secret")).toBeNull();
|
||||
expect(JSON.stringify(auditEntries)).not.toContain("totp-secret");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ const h = vi.hoisted(() => ({
|
||||
cleared: [] as string[],
|
||||
clearedAll: 0,
|
||||
pooled: [] as string[],
|
||||
sessionId: undefined as string | undefined,
|
||||
revoked: [] as Array<{ userId: string; except?: string }>,
|
||||
trustedCleared: [] as string[],
|
||||
policyError: null as Error | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
@@ -29,7 +33,15 @@ vi.mock("../../plugins/permissions.js", async () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../plugins/actor.js", () => ({
|
||||
getActor: () => h.actor,
|
||||
getActorSessionId: () => h.sessionId,
|
||||
}));
|
||||
vi.mock("../../auth/core-auth.js", () => ({
|
||||
assertSecondFactorEnrollmentAllowed: () => {
|
||||
if (h.policyError) throw h.policyError;
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
key: string,
|
||||
@@ -74,6 +86,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
},
|
||||
removeSecondFactor: async () => true,
|
||||
}),
|
||||
createCurrentSessionRepository: () => ({
|
||||
revokeAllForUser: async (userId: string, except?: string) => {
|
||||
h.revoked.push({ userId, except });
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
createCurrentTrustedDeviceRepository: () => ({
|
||||
deleteByUserId: async (userId: string) => {
|
||||
h.trustedCleared.push(userId);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginAuth, createPluginSsh } =
|
||||
@@ -359,4 +382,41 @@ describe("ctx.auth", () => {
|
||||
expect(getLoginMethod("corp-sso")).toBeUndefined();
|
||||
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes other sessions and trusted devices on enrolment, keeping the caller's session", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.factors = [];
|
||||
h.revoked = [];
|
||||
h.trustedCleared = [];
|
||||
h.policyError = null;
|
||||
h.actor = "user-1";
|
||||
h.sessionId = "session-a";
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await auth.recordEnrollment("user-1", "pin");
|
||||
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
|
||||
expect(h.trustedCleared).toEqual(["user-1"]);
|
||||
h.actor = undefined;
|
||||
h.sessionId = undefined;
|
||||
});
|
||||
|
||||
it("refuses enrolment when core policy forbids second factors", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.factors = [];
|
||||
const { LoginMethodError } = await import("@termix/plugin-sdk/backend");
|
||||
h.policyError = new LoginMethodError("password login is off", 409);
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(auth.recordEnrollment("user-1", "pin")).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(h.factors).toEqual([]);
|
||||
h.policyError = null;
|
||||
});
|
||||
});
|
||||
@@ -74,24 +74,19 @@ describe("parseLegacyIdentifier", () => {
|
||||
});
|
||||
|
||||
describe("runExternalIdentityMigration", () => {
|
||||
it("moves every identifier and TOTP enrolment without touching the old data", async () => {
|
||||
it("moves every identifier without touching the old data", async () => {
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
|
||||
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
|
||||
user("oidc-user", {
|
||||
oidcIdentifier: "sub-123",
|
||||
ssoProviderId: 3,
|
||||
totpEnabled: true,
|
||||
});
|
||||
user("env-user", { oidcIdentifier: "someone@example.com" });
|
||||
user("local", {
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
totpEnabled: true,
|
||||
});
|
||||
user("local", { isOidc: false, passwordHash: "hash" });
|
||||
|
||||
const result = await runExternalIdentityMigration();
|
||||
|
||||
expect(result).toEqual({ identities: 4, factors: 2, skipped: 0 });
|
||||
expect(result).toEqual({ identities: 4, skipped: 0 });
|
||||
expect(
|
||||
h.state.identities.map(({ userId, providerId, subject }) => ({
|
||||
userId,
|
||||
@@ -108,21 +103,15 @@ describe("runExternalIdentityMigration", () => {
|
||||
subject: "someone@example.com",
|
||||
},
|
||||
]);
|
||||
expect(h.state.factors).toEqual([
|
||||
{ userId: "oidc-user", pluginId: "core", factorId: "totp" },
|
||||
{ userId: "local", pluginId: "core", factorId: "totp" },
|
||||
]);
|
||||
// Lossless: the old column and flag stay.
|
||||
// Lossless: the old column stays.
|
||||
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
|
||||
expect(h.state.users.get("local")!.totpEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob", totpEnabled: true });
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
|
||||
await runExternalIdentityMigration();
|
||||
const second = await runExternalIdentityMigration();
|
||||
expect(second).toEqual({ identities: 0, factors: 0, skipped: 2 });
|
||||
expect(second).toEqual({ identities: 0, skipped: 1 });
|
||||
expect(h.state.identities).toHaveLength(1);
|
||||
expect(h.state.factors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The 2.8 to 2.9 TOTP move. A user who had TOTP on must still be asked for it
|
||||
* after upgrading: their enrolment row points at the totp plugin, and their
|
||||
* secret and backup codes reach the plugin's table readable. Running it twice
|
||||
* changes nothing, and without the plugin's table the enrolment still moves
|
||||
* so the login fails closed.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
deks: new Map<string, Buffer>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
|
||||
}));
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runTotpMigration } =
|
||||
await import("../../utils/crypto-migration/totp-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function setup({ pluginTable }: { pluginTable: boolean }) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT
|
||||
);
|
||||
CREATE TABLE user_second_factors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
plugin_id TEXT NOT NULL,
|
||||
factor_id TEXT NOT NULL,
|
||||
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, plugin_id, factor_id)
|
||||
);
|
||||
`);
|
||||
if (pluginTable) {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE p_totp_enrollments (
|
||||
user_id TEXT PRIMARY KEY NOT NULL,
|
||||
secret TEXT,
|
||||
pending_secret TEXT,
|
||||
backup_codes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
h.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
function addTotpUser(id: string, secret: string, codes: string[]) {
|
||||
const dek = crypto.randomBytes(32);
|
||||
h.deks.set(id, dek);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO users (id, username, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, 1, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
id,
|
||||
FieldCrypto.encryptField(secret, dek, id, "totpSecret"),
|
||||
FieldCrypto.encryptField(
|
||||
JSON.stringify(codes),
|
||||
dek,
|
||||
id,
|
||||
"totpBackupCodes",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const factors = () =>
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, plugin_id, factor_id FROM user_second_factors ORDER BY user_id",
|
||||
)
|
||||
.all();
|
||||
|
||||
beforeEach(() => {
|
||||
h.deks.clear();
|
||||
});
|
||||
|
||||
describe("runTotpMigration", () => {
|
||||
it("keeps a 2.8 TOTP user behind their factor, with a readable secret", async () => {
|
||||
setup({ pluginTable: true });
|
||||
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111", "BBBB2222"]);
|
||||
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
|
||||
sqlite.exec(`INSERT INTO users (id, username) VALUES ('carol', 'carol')`);
|
||||
// A8 recorded alice under core; bob only has the flag.
|
||||
sqlite.exec(
|
||||
`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('alice', 'core', 'totp')`,
|
||||
);
|
||||
|
||||
expect(await runTotpMigration()).toEqual({ factors: 2, secrets: 2 });
|
||||
|
||||
expect(factors()).toEqual([
|
||||
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
|
||||
{ user_id: "bob", plugin_id: "totp", factor_id: "totp" },
|
||||
]);
|
||||
const alice = sqlite
|
||||
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'alice'")
|
||||
.get() as Record<string, string>;
|
||||
expect(alice.secret).toBe("sysenc:JBSWY3DPEHPK3PXP");
|
||||
expect(alice.backup_codes).toBe(
|
||||
`sysenc:${JSON.stringify(["AAAA1111", "BBBB2222"])}`,
|
||||
);
|
||||
expect(alice.pending_secret).toBeNull();
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = 'alice'",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ totp_enabled: 0, totp_secret: null, totp_backup_codes: null });
|
||||
|
||||
// A second boot changes nothing.
|
||||
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
|
||||
expect(factors()).toHaveLength(2);
|
||||
expect(
|
||||
sqlite.prepare("SELECT COUNT(*) AS n FROM p_totp_enrollments").get(),
|
||||
).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it("moves the enrolment but keeps the columns while the plugin table is missing", async () => {
|
||||
setup({ pluginTable: false });
|
||||
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111"]);
|
||||
|
||||
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
|
||||
expect(factors()).toEqual([
|
||||
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
|
||||
]);
|
||||
const row = sqlite
|
||||
.prepare("SELECT totp_enabled, totp_secret FROM users WHERE id = 'alice'")
|
||||
.get() as { totp_enabled: number; totp_secret: string };
|
||||
expect(row.totp_enabled).toBe(1);
|
||||
expect(row.totp_secret).toBeTruthy();
|
||||
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
|
||||
});
|
||||
|
||||
it("skips a user whose data key is unavailable and retries later", async () => {
|
||||
setup({ pluginTable: true });
|
||||
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
|
||||
const dek = h.deks.get("alice")!;
|
||||
h.deks.delete("alice");
|
||||
|
||||
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
|
||||
h.deks.set("alice", dek);
|
||||
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
|
||||
});
|
||||
|
||||
it("drops a duplicate core row when the plugin row already exists", async () => {
|
||||
setup({ pluginTable: true });
|
||||
sqlite.exec(`
|
||||
INSERT INTO users (id, username) VALUES ('dave', 'dave');
|
||||
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'core', 'totp');
|
||||
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'totp', 'totp');
|
||||
`);
|
||||
await runTotpMigration();
|
||||
expect(factors()).toEqual([
|
||||
{ user_id: "dave", plugin_id: "totp", factor_id: "totp" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install without the legacy columns", async () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
|
||||
CREATE TABLE user_second_factors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL,
|
||||
plugin_id TEXT NOT NULL, factor_id TEXT NOT NULL,
|
||||
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
h.db = drizzle(sqlite);
|
||||
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,11 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
}
|
||||
|
||||
it("owns legacy user encryption migration reads", () => {
|
||||
const { db, all, get } = createDb();
|
||||
const { db, all } = createDb();
|
||||
const store = new RawSqliteUserEncryptionMigrationStore(db);
|
||||
|
||||
expect(store.listHostRecords("user-1")).toEqual([{ id: 1 }]);
|
||||
expect(store.listCredentialRecords("user-1")).toEqual([{ id: 1 }]);
|
||||
expect(store.getUserRecord("user-1")).toEqual({ id: "user-1" });
|
||||
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
"SELECT * FROM ssh_data WHERE user_id = ?",
|
||||
@@ -25,9 +24,7 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
"SELECT * FROM ssh_credentials WHERE user_id = ?",
|
||||
);
|
||||
expect(db.prepare).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?");
|
||||
expect(all).toHaveBeenCalledWith("user-1");
|
||||
expect(get).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("owns legacy sensitive field update statements", () => {
|
||||
@@ -52,12 +49,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
public_key: "pub",
|
||||
key_type: "rsa",
|
||||
});
|
||||
store.updateUserSensitiveFields("user-1", {
|
||||
totp_secret: "totp",
|
||||
totp_backup_codes: "codes",
|
||||
client_secret: "client",
|
||||
oidc_identifier: "oidc",
|
||||
});
|
||||
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE ssh_data"),
|
||||
@@ -65,9 +56,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE ssh_credentials"),
|
||||
);
|
||||
expect(db.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE users"),
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
"p",
|
||||
"k",
|
||||
@@ -80,13 +68,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
|
||||
12,
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
"totp",
|
||||
"codes",
|
||||
"client",
|
||||
"oidc",
|
||||
"user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("owns password-reset dynamic field updates", () => {
|
||||
|
||||
@@ -168,7 +168,11 @@ class AuthManager {
|
||||
return this.ensureUserDEK(userId);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnUser(
|
||||
/**
|
||||
* Opens the user's data key with the server-held wrapping, for a sign-in
|
||||
* that carries no password (a passkey, trusted proxy login).
|
||||
*/
|
||||
async unlockWithSystemKey(
|
||||
userId: string,
|
||||
_deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Moves sign-in identities and 2FA enrolment into their own tables.
|
||||
* Moves sign-in identities into their own table.
|
||||
*
|
||||
* users.oidc_identifier packed provider and subject into one string:
|
||||
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
|
||||
* whose provider was users.sso_provider_id. Each becomes a
|
||||
* user_external_identities row. users.totp_enabled becomes a
|
||||
* user_second_factors row for core's TOTP factor, so a login can tell a user
|
||||
* has a factor even after the plugin that runs it is gone.
|
||||
* user_external_identities row. TOTP enrolment moves in totp-migration.ts.
|
||||
*
|
||||
* Lossless: the old column and flag are left as they are. Idempotent: rows
|
||||
* that already exist are skipped, so it runs on every boot.
|
||||
* Lossless: the old column is left as it is. Idempotent: rows that already
|
||||
* exist are skipped, so it runs on every boot.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../logger.js";
|
||||
@@ -23,7 +21,6 @@ const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
export interface ExternalIdentityMigrationResult {
|
||||
identities: number;
|
||||
factors: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
@@ -58,7 +55,6 @@ export function parseLegacyIdentifier(
|
||||
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
|
||||
const result: ExternalIdentityMigrationResult = {
|
||||
identities: 0,
|
||||
factors: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
@@ -89,24 +85,10 @@ export async function runExternalIdentityMigration(): Promise<ExternalIdentityMi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user.totpEnabled) {
|
||||
const enrolled = await auth.listSecondFactors(user.id);
|
||||
if (
|
||||
enrolled.some(
|
||||
(row) => row.pluginId === "core" && row.factorId === "totp",
|
||||
)
|
||||
) {
|
||||
result.skipped++;
|
||||
} else {
|
||||
await auth.recordSecondFactor(user.id, "core", "totp");
|
||||
result.factors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.identities > 0 || result.factors > 0) {
|
||||
databaseLogger.info("Moved sign-in identities and 2FA enrolment", {
|
||||
if (result.identities > 0) {
|
||||
databaseLogger.info("Moved sign-in identities", {
|
||||
operation: "external_identity_migration",
|
||||
...result,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Moves 2.8 TOTP enrolments into the totp plugin.
|
||||
*
|
||||
* 1. Every user_second_factors row A8 wrote for core's TOTP ("core"/"totp")
|
||||
* becomes the plugin's ("totp"/"totp"), and every user with
|
||||
* users.totp_enabled gets one. This runs whether or not the plugin is
|
||||
* enabled, so those users stay behind a second factor: with the plugin off
|
||||
* the login fails closed instead of skipping it.
|
||||
* 2. Once the plugin's p_totp_enrollments table exists, each enabled user's
|
||||
* secret and backup codes are decrypted with their data key and written
|
||||
* there sealed with the installation key (what ctx.secrets.seal writes),
|
||||
* then the users columns are cleared.
|
||||
*
|
||||
* The columns stay in the database until 3.0.0, since core's drizzle
|
||||
* migrations run before this does. A user whose data key cannot be opened
|
||||
* at boot is skipped and tried again on the next one. Idempotent.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
|
||||
import { encryptSystemSecret } from "../system-secret-crypto.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "totp";
|
||||
const FACTOR_ID = "totp";
|
||||
const ENROLLMENTS_TABLE = "p_totp_enrollments";
|
||||
|
||||
export interface TotpMigrationResult {
|
||||
/** user_second_factors rows written or moved to the plugin. */
|
||||
factors: number;
|
||||
/** Secrets moved into the plugin's table. */
|
||||
secrets: number;
|
||||
}
|
||||
|
||||
interface LegacyTotpUser {
|
||||
id: string;
|
||||
totp_enabled: unknown;
|
||||
totp_secret: string | null;
|
||||
totp_backup_codes: string | null;
|
||||
}
|
||||
|
||||
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[] | null> {
|
||||
try {
|
||||
return await selectRows<T>(query);
|
||||
} catch {
|
||||
// A fresh install never had the column or the table.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOn(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "t";
|
||||
}
|
||||
|
||||
function readBackupCodes(
|
||||
stored: string | null,
|
||||
dek: Buffer,
|
||||
userId: string,
|
||||
): string[] {
|
||||
if (!stored) return [];
|
||||
const raw = LazyFieldEncryption.safeGetFieldValue(
|
||||
stored,
|
||||
dek,
|
||||
userId,
|
||||
"totpBackupCodes",
|
||||
);
|
||||
try {
|
||||
const codes = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(codes)
|
||||
? codes.filter((code): code is string => typeof code === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function factorUsers(pluginId: string): Promise<Set<string>> {
|
||||
const rows = await selectRows<{ user_id: string }>(
|
||||
sql`SELECT user_id FROM user_second_factors WHERE plugin_id = ${pluginId} AND factor_id = ${FACTOR_ID}`,
|
||||
);
|
||||
return new Set(rows.map((row) => row.user_id));
|
||||
}
|
||||
|
||||
export async function runTotpMigration(): Promise<TotpMigrationResult> {
|
||||
const result: TotpMigrationResult = { factors: 0, secrets: 0 };
|
||||
|
||||
try {
|
||||
const enrolled = await factorUsers(PLUGIN_ID);
|
||||
|
||||
for (const userId of await factorUsers("core")) {
|
||||
if (enrolled.has(userId)) {
|
||||
await runStatement(
|
||||
sql`DELETE FROM user_second_factors WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
|
||||
);
|
||||
} else {
|
||||
await runStatement(
|
||||
sql`UPDATE user_second_factors SET plugin_id = ${PLUGIN_ID} WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
|
||||
);
|
||||
enrolled.add(userId);
|
||||
result.factors++;
|
||||
}
|
||||
}
|
||||
|
||||
const users = (
|
||||
(await tryRows<LegacyTotpUser>(
|
||||
sql`SELECT id, totp_enabled, totp_secret, totp_backup_codes FROM users`,
|
||||
)) ?? []
|
||||
).filter((user) => isOn(user.totp_enabled));
|
||||
|
||||
for (const user of users) {
|
||||
if (enrolled.has(user.id)) continue;
|
||||
await runStatement(
|
||||
sql`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES (${user.id}, ${PLUGIN_ID}, ${FACTOR_ID})`,
|
||||
);
|
||||
enrolled.add(user.id);
|
||||
result.factors++;
|
||||
}
|
||||
|
||||
const moved = await tryRows<{ user_id: string }>(
|
||||
sql`SELECT user_id FROM ${sql.identifier(ENROLLMENTS_TABLE)}`,
|
||||
);
|
||||
if (moved) {
|
||||
const done = new Set(moved.map((row) => row.user_id));
|
||||
for (const user of users) {
|
||||
if (done.has(user.id) || !user.totp_secret) continue;
|
||||
try {
|
||||
const dek = DataCrypto.getUserDataKey(user.id);
|
||||
if (!dek) continue;
|
||||
const secret = LazyFieldEncryption.safeGetFieldValue(
|
||||
user.totp_secret,
|
||||
dek,
|
||||
user.id,
|
||||
"totpSecret",
|
||||
);
|
||||
if (!secret) continue;
|
||||
const codes = readBackupCodes(user.totp_backup_codes, dek, user.id);
|
||||
await runStatement(
|
||||
sql`INSERT INTO ${sql.identifier(ENROLLMENTS_TABLE)} (user_id, secret, backup_codes) VALUES (${user.id}, ${await encryptSystemSecret(secret)}, ${await encryptSystemSecret(JSON.stringify(codes))})`,
|
||||
);
|
||||
await runStatement(
|
||||
sql`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_backup_codes = NULL WHERE id = ${user.id}`,
|
||||
);
|
||||
result.secrets++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("TOTP migration failed for a user", {
|
||||
operation: "totp_migration",
|
||||
userId: user.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("TOTP migration failed", error, {
|
||||
operation: "totp_migration",
|
||||
});
|
||||
}
|
||||
|
||||
if (result.factors > 0 || result.secrets > 0) {
|
||||
databaseLogger.info("Moved TOTP enrolment into the totp plugin", {
|
||||
operation: "totp_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -180,29 +180,6 @@ class DataCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
const userRecord = store.getUserRecord(userId);
|
||||
if (userRecord) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("users");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
userRecord,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
store.updateUserSensitiveFields(userId, updatedRecord);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes("users")) {
|
||||
migratedTables.push("users");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { migrated, migratedTables, migratedFieldsCount };
|
||||
} catch (error) {
|
||||
databaseLogger.error("User sensitive fields migration failed", error, {
|
||||
|
||||
@@ -15,13 +15,7 @@ class FieldCrypto {
|
||||
private static readonly SALT_LENGTH = 32;
|
||||
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: new Set([
|
||||
"passwordHash",
|
||||
"clientSecret",
|
||||
"totpSecret",
|
||||
"totpBackupCodes",
|
||||
"oidcIdentifier",
|
||||
]),
|
||||
users: new Set(["passwordHash", "clientSecret", "oidcIdentifier"]),
|
||||
ssh_data: new Set([
|
||||
"password",
|
||||
"key",
|
||||
|
||||
@@ -286,8 +286,6 @@ export class LazyFieldEncryption {
|
||||
rdpPassword: "rdp_password",
|
||||
vncPassword: "vnc_password",
|
||||
telnetPassword: "telnet_password",
|
||||
totpSecret: "totp_secret",
|
||||
totpBackupCodes: "totp_backup_codes",
|
||||
clientSecret: "client_secret",
|
||||
oidcIdentifier: "oidc_identifier",
|
||||
};
|
||||
@@ -314,7 +312,6 @@ export class LazyFieldEncryption {
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
],
|
||||
users: ["totpSecret", "totpBackupCodes"],
|
||||
};
|
||||
|
||||
return sensitiveFieldsMap[tableName] || [];
|
||||
@@ -439,33 +436,6 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
const user = store.getUserRecord(userId);
|
||||
if (user) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("users");
|
||||
const userPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
const column = this.propertyToColumn(field);
|
||||
const value = user[column];
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value &&
|
||||
this.fieldNeedsMigration(value, userKEK, userId, field)
|
||||
) {
|
||||
userPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (userPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: "users",
|
||||
recordId: userId,
|
||||
fields: userPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { needsMigration, plaintextFields };
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check user migration needs", error, {
|
||||
|
||||
@@ -167,10 +167,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "SSO",
|
||||
description: "Single sign-on provider configuration",
|
||||
},
|
||||
{
|
||||
name: "WebAuthn",
|
||||
description: "Passkey registration and authentication",
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
description: "HashiCorp Vault SSH signing profiles",
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface UserEncryptionMigrationRecord {
|
||||
export interface UserEncryptionMigrationStore {
|
||||
listHostRecords(userId: string): UserEncryptionMigrationRecord[];
|
||||
listCredentialRecords(userId: string): UserEncryptionMigrationRecord[];
|
||||
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined;
|
||||
updateHostSensitiveFields(
|
||||
recordId: number | string,
|
||||
record: Record<string, unknown>,
|
||||
@@ -17,10 +16,6 @@ export interface UserEncryptionMigrationStore {
|
||||
recordId: number | string,
|
||||
record: Record<string, unknown>,
|
||||
): void;
|
||||
updateUserSensitiveFields(
|
||||
userId: string,
|
||||
record: Record<string, unknown>,
|
||||
): void;
|
||||
updatePasswordResetFields(
|
||||
table: "ssh_data" | "ssh_credentials" | "users",
|
||||
recordId: number | string,
|
||||
@@ -52,10 +47,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
|
||||
.all(userId);
|
||||
}
|
||||
|
||||
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined {
|
||||
return this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
}
|
||||
|
||||
updateHostSensitiveFields(
|
||||
recordId: number | string,
|
||||
record: Record<string, unknown>,
|
||||
@@ -104,27 +95,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
|
||||
);
|
||||
}
|
||||
|
||||
updateUserSensitiveFields(
|
||||
userId: string,
|
||||
record: Record<string, unknown>,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
record.totp_secret || null,
|
||||
record.totp_backup_codes || null,
|
||||
record.client_secret || null,
|
||||
record.oidc_identifier || null,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
updatePasswordResetFields(
|
||||
table: "ssh_data" | "ssh_credentials" | "users",
|
||||
recordId: number | string,
|
||||
|
||||
Reference in new issue
Block a user