feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+24
View File
@@ -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,
+1 -24
View File
@@ -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. */
-128
View File
@@ -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,
});
},
};
+9 -8
View File
@@ -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,
+8 -2
View File
@@ -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 } : {}),
},
};