mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user