mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Password login, core's one built-in method. It only proves who the user is;
|
||||
* the pipeline does second factors and the session.
|
||||
*/
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { createCurrentUserRepository } from "../database/repositories/factory.js";
|
||||
import { getPasswordLoginStatus } from "./core-auth.js";
|
||||
import { registerLoginMethod, type LoginMethod } from "./registry.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export async function verifyPasswordLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
socket?: { remoteAddress?: string };
|
||||
}): Promise<VerifiedIdentity & { rateLimitUsername: string }> {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
throw new LoginMethodError(
|
||||
"Password login is disabled while trusted proxy authentication is enabled",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = request.body;
|
||||
const clientIp = request.ip || request.socket?.remoteAddress || "unknown";
|
||||
|
||||
if (!nonEmpty(username) || !nonEmpty(password)) {
|
||||
throw new LoginMethodError("Invalid username or password", 400);
|
||||
}
|
||||
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Login attempt blocked due to rate limiting", {
|
||||
operation: "user_login_blocked",
|
||||
username,
|
||||
ip: clientIp,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!(await getPasswordLoginStatus()).allowed) {
|
||||
throw new LoginMethodError(
|
||||
"Password authentication is currently disabled",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findByUsername(username);
|
||||
if (!user) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn("Login failed: user not found", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
ip: clientIp,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) {
|
||||
authLogger.warn("OIDC-only user attempted traditional login", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new LoginMethodError("This user uses external authentication", 403);
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isMatch) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn("Login failed: incorrect password", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
ip: clientIp,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: user.id,
|
||||
password,
|
||||
rememberMe: !!request.body.rememberMe,
|
||||
rateLimitUsername: username,
|
||||
};
|
||||
}
|
||||
|
||||
export const passwordLoginMethod: LoginMethod = {
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.password",
|
||||
kind: "form",
|
||||
describe: async () => {
|
||||
const status = await getPasswordLoginStatus();
|
||||
return [{ id: "password", label: "Password", enabled: status.allowed }];
|
||||
},
|
||||
verify: (request) => verifyPasswordLogin(request as never),
|
||||
};
|
||||
|
||||
export function registerPasswordLoginMethod(): () => void {
|
||||
return registerLoginMethod(passwordLoginMethod);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Core's own login method (password) and the lockout guard.
|
||||
*/
|
||||
|
||||
import { getCurrentSettingValue } from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
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";
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Registers core's login methods and factors once. */
|
||||
export function ensureCoreLoginProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerPasswordLoginMethod();
|
||||
registerLegacyLoginProviders();
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetCoreLoginProvidersForTests(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
||||
/** The admin setting (or ALLOW_PASSWORD_LOGIN), before the lockout guard. */
|
||||
export function isPasswordLoginSettingOn(): boolean {
|
||||
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
|
||||
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
|
||||
try {
|
||||
const value = getCurrentSettingValue("allow_password_login");
|
||||
return value ? value === "true" : true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anyone could still sign in without a password: trusted proxy auth,
|
||||
* or a login method reporting an enabled instance (an SSO provider).
|
||||
*/
|
||||
export async function hasOtherEnabledLoginMethod(): Promise<boolean> {
|
||||
if (isTrustedProxyAuthEnabled()) return true;
|
||||
ensureCoreLoginProviders();
|
||||
for (const method of listLoginMethods()) {
|
||||
if (method.id === "password" || !method.describe) continue;
|
||||
try {
|
||||
const instances = await method.describe();
|
||||
if (instances.some((instance) => instance.enabled)) return true;
|
||||
} catch {
|
||||
// A method that cannot describe itself does not count.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface PasswordLoginStatus {
|
||||
allowed: boolean;
|
||||
/** Turned off by the admin but kept on so nobody is locked out. */
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login is off only when the admin turned it off AND another way to
|
||||
* sign in exists. Otherwise it stays on, with a warning, so that disabling it
|
||||
* (or losing the only SSO provider) can never lock everyone out.
|
||||
*/
|
||||
export async function getPasswordLoginStatus(): Promise<PasswordLoginStatus> {
|
||||
if (isPasswordLoginSettingOn()) return { allowed: true, forced: false };
|
||||
if (await hasOtherEnabledLoginMethod()) {
|
||||
return { allowed: false, forced: false };
|
||||
}
|
||||
authLogger.warn(
|
||||
"Password login is disabled but no other login method is enabled; keeping it on",
|
||||
{ operation: "password_login_lockout_guard" },
|
||||
);
|
||||
return { allowed: true, forced: true };
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Auth methods that still live in core for 2.9.0, registered through the same
|
||||
* interfaces a plugin uses. Phase C moves each one into its own plugin by
|
||||
* relocating its block from here; D1 deletes this file once it is empty.
|
||||
*
|
||||
* Nothing outside this file should branch on these type names.
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import {
|
||||
registerKeyboardInteractiveInterceptor,
|
||||
registerSshAuthProvider,
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
import type {
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
type LoginMethod,
|
||||
} from "./registry.js";
|
||||
import { totpSecondFactor } from "./legacy/totp-factor.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.";
|
||||
|
||||
const VAULT_REQUIRED_MESSAGE =
|
||||
"Vault SSH signer authentication required. Please open a Terminal connection first.";
|
||||
|
||||
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
|
||||
|
||||
/** How long a Tailscale check-mode login may sit waiting for approval. */
|
||||
export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
|
||||
|
||||
function issuedCertificateProvider(
|
||||
type: "opkssh" | "stepca",
|
||||
labelKey: string,
|
||||
): SshAuthProvider {
|
||||
return {
|
||||
type,
|
||||
pluginId: "core",
|
||||
labelKey,
|
||||
needsUserInteraction: true,
|
||||
supportsBackground: false,
|
||||
interaction: "opkssh",
|
||||
prepare: async (config, host, env) => {
|
||||
const { getOPKSSHToken } = await import("../hosts/opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(env.userId, env.hostId);
|
||||
if (!token) {
|
||||
env.log("info", "No valid certificate found, requesting sign-in");
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
message: OPKSSH_REQUIRED_MESSAGE,
|
||||
flag: "requiresOPKSSHAuth",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, env.client, token, host.username);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "OPKSSH authentication failed: " + getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached SSH certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (_host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
|
||||
sshLogger.warn("OPKSSH authentication failed - invalidating token", {
|
||||
operation: "opkssh_auth_failed",
|
||||
hostId: env.hostId,
|
||||
userId: env.userId,
|
||||
});
|
||||
void import("../hosts/opkssh-auth.js")
|
||||
.then(({ invalidateOPKSSHToken }) =>
|
||||
invalidateOPKSSHToken(env.userId, env.hostId, "SSH auth failed"),
|
||||
)
|
||||
.catch(() => {});
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
message:
|
||||
"OPKSSH authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
if (type === "stepca") {
|
||||
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
|
||||
await startStepCaAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
request.host.username,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { startOPKSSHAuth } = await import("../hosts/opkssh-auth.js");
|
||||
await startOPKSSHAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
request.host.name || request.host.ip,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function vaultProfileId(host: SshConnectHost): number | undefined {
|
||||
return host.vaultProfile?.id ?? undefined;
|
||||
}
|
||||
|
||||
const vaultProvider: SshAuthProvider = {
|
||||
type: "vault",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthVault",
|
||||
needsUserInteraction: true,
|
||||
interaction: "vault",
|
||||
prepare: async (config, host, env) => {
|
||||
const profileId = vaultProfileId(host);
|
||||
if (!profileId) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "Host has no Vault signer profile configured",
|
||||
};
|
||||
}
|
||||
const { getVaultCert } = await import("../hosts/vault-signer-auth.js");
|
||||
const cert = await getVaultCert(env.userId, profileId);
|
||||
if (!cert) {
|
||||
env.log(
|
||||
"info",
|
||||
"No valid Vault certificate found, requesting authentication",
|
||||
);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
message: VAULT_REQUIRED_MESSAGE,
|
||||
flag: "requiresVaultAuth",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config,
|
||||
env.client,
|
||||
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
|
||||
host.username,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message:
|
||||
"Vault SSH signer authentication failed: " + getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
env.log("info", "Using cached Vault-signed certificate");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (host, env, context) => {
|
||||
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
|
||||
sshLogger.warn("Vault certificate authentication failed", {
|
||||
operation: "vault_auth_failed",
|
||||
hostId: env.hostId,
|
||||
userId: env.userId,
|
||||
});
|
||||
const profileId = vaultProfileId(host);
|
||||
if (profileId) {
|
||||
void import("../hosts/vault-signer-auth.js")
|
||||
.then(({ deleteVaultCert }) => deleteVaultCert(env.userId, profileId))
|
||||
.catch(() => {});
|
||||
}
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
message:
|
||||
"Vault authentication failed or expired. Please authenticate again.",
|
||||
};
|
||||
},
|
||||
startInteraction: async (request) => {
|
||||
const { loadVaultProfileForHost, startVaultAuth } =
|
||||
await import("../hosts/vault-oidc-auth.js");
|
||||
const profile = await loadVaultProfileForHost(
|
||||
request.hostId,
|
||||
request.userId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new Error("No Vault signer profile configured for this host");
|
||||
}
|
||||
await startVaultAuth(
|
||||
request.userId,
|
||||
request.hostId,
|
||||
profile,
|
||||
request.socket,
|
||||
request.requestOrigin,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const tailscaleProvider: SshAuthProvider = {
|
||||
type: "tailscale",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthTailscale",
|
||||
connectOptions: (_host, purpose) =>
|
||||
purpose === "terminal"
|
||||
? {
|
||||
tryKeyboard: false,
|
||||
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
// The socket sits idle while a check-mode login is pending.
|
||||
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
}
|
||||
: { tryKeyboard: false },
|
||||
prepare: async (_config, _host, env) => {
|
||||
env.log("info", "Using Tailscale SSH");
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (host, _env, context) => {
|
||||
const failed =
|
||||
context.methodNotAvailable ||
|
||||
AUTH_FAILED_PATTERN.test(context.error.message);
|
||||
if (!failed) return;
|
||||
|
||||
// Tailscale documents the "+password" suffix for clients that mishandle a
|
||||
// successful "none" reply. The password value is ignored.
|
||||
if (context.retries === 0 && context.canRetry) {
|
||||
return {
|
||||
status: "retry",
|
||||
message: "Retrying Tailscale SSH in forced password mode",
|
||||
patch: {
|
||||
username: `${host.username}+password`,
|
||||
password: "termix",
|
||||
tryKeyboard: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const WARPGATE_PATTERN = /warpgate\s+authentication/i;
|
||||
|
||||
let sshRegistered = false;
|
||||
|
||||
export function registerLegacySshAuthProviders(): void {
|
||||
if (sshRegistered) return;
|
||||
sshRegistered = true;
|
||||
|
||||
registerSshAuthProvider(
|
||||
issuedCertificateProvider("opkssh", "hosts.filterAuthOpkssh"),
|
||||
);
|
||||
registerSshAuthProvider(
|
||||
issuedCertificateProvider("stepca", "hosts.filterAuthStepca"),
|
||||
);
|
||||
registerSshAuthProvider(vaultProvider);
|
||||
registerSshAuthProvider(tailscaleProvider);
|
||||
|
||||
// Warpgate is a flag on a host, not an auth type: it shows up as a
|
||||
// keyboard-interactive round with a sign-in URL.
|
||||
registerKeyboardInteractiveInterceptor({
|
||||
id: "warpgate",
|
||||
pluginId: "core",
|
||||
detect: ({ name, instructions, prompts }) => {
|
||||
const texts = prompts.map((p) => p.prompt);
|
||||
const isWarpgate =
|
||||
WARPGATE_PATTERN.test(name) ||
|
||||
WARPGATE_PATTERN.test(instructions) ||
|
||||
texts.some((p) => WARPGATE_PATTERN.test(p));
|
||||
if (!isWarpgate) return null;
|
||||
|
||||
const fullText = `${name}\n${instructions}\n${texts.join("\n")}`;
|
||||
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
|
||||
if (!urlMatch) return null;
|
||||
const keyMatch = fullText.match(
|
||||
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
|
||||
);
|
||||
return {
|
||||
kind: "warpgate",
|
||||
url: urlMatch[0],
|
||||
securityKey: keyMatch ? keyMatch[1] : "N/A",
|
||||
instructions,
|
||||
};
|
||||
},
|
||||
// Warpgate asks for the password before its browser round; answer it
|
||||
// silently so the only thing the user sees is the Warpgate dialog.
|
||||
autoAnswerPasswords: (host) => host.useWarpgate === true,
|
||||
});
|
||||
}
|
||||
|
||||
// Login methods and second factors -------------------------------------------
|
||||
|
||||
async function listPublicProviders(types: string[]) {
|
||||
const { listPublicSsoProviders } =
|
||||
await import("../database/routes/sso-provider-routes.js");
|
||||
return (await listPublicSsoProviders()).filter((provider) =>
|
||||
types.includes(provider.type),
|
||||
);
|
||||
}
|
||||
|
||||
const oidcLoginMethod: LoginMethod = {
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithSso",
|
||||
icon: "key-round",
|
||||
kind: "redirect",
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["oidc", "github", "google"])).map(
|
||||
(provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
}),
|
||||
),
|
||||
start: async (request, instanceId) => {
|
||||
const { startOidcLogin } = await import("./legacy/oidc-login.js");
|
||||
// Instance "0" is the provider synthesized from environment variables.
|
||||
const result = await startOidcLogin(request as never, {
|
||||
providerId: instanceId && instanceId !== "0" ? instanceId : undefined,
|
||||
});
|
||||
return { redirectUrl: result.auth_url };
|
||||
},
|
||||
callback: async (request) => {
|
||||
const { handleOidcCallback } = await import("./legacy/oidc-login.js");
|
||||
return handleOidcCallback(request as never);
|
||||
},
|
||||
};
|
||||
|
||||
const ldapLoginMethod: LoginMethod = {
|
||||
id: "ldap",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["ldap"])).map((provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
})),
|
||||
verify: async (request, instanceId) => {
|
||||
const { verifyLdapLogin } = await import("./legacy/ldap-login.js");
|
||||
const body = { ...request.body };
|
||||
if (instanceId && !body.providerId) body.providerId = Number(instanceId);
|
||||
return verifyLdapLogin({ body, ip: request.ip });
|
||||
},
|
||||
};
|
||||
|
||||
// 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 {
|
||||
if (loginRegistered) return;
|
||||
loginRegistered = true;
|
||||
registerLoginMethod(oidcLoginMethod);
|
||||
registerLoginMethod(ldapLoginMethod);
|
||||
registerLoginMethod(passkeyLoginMethod);
|
||||
registerSecondFactor(totpSecondFactor);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetLegacyProvidersForTests(): void {
|
||||
sshRegistered = false;
|
||||
loginRegistered = false;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* LDAP sign-in as a form login method. Moved out of the LDAP route so the
|
||||
* pipeline issues the session; Phase C moves it into the LDAP plugin.
|
||||
*/
|
||||
|
||||
import ldap from "ldapjs";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { createCurrentSsoProviderRepository } from "../../database/repositories/factory.js";
|
||||
import { loadProviderConfig } from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
export function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue(entry: ldap.SearchEntry, key: string): string {
|
||||
const attr = entry.attributes.find((a) => a.type === key);
|
||||
if (!attr) return "";
|
||||
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
|
||||
}
|
||||
|
||||
export async function verifyLdapLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}): Promise<VerifiedIdentity> {
|
||||
const { providerId, username, password, rememberMe } = request.body as {
|
||||
providerId?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
throw new LoginMethodError(
|
||||
"providerId, username, and password are required",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Same limits as password login: an LDAP bind is a password guess.
|
||||
const clientIp = request.ip || "unknown";
|
||||
const rateLimitKey = `ldap:${providerId}:${username}`;
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, rateLimitKey);
|
||||
if (lockStatus.locked) {
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const provider =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (!provider || provider.type !== "ldap" || !provider.enabled) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
throw new LoginMethodError("LDAP provider is misconfigured", 500);
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
[
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
],
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
const ldapIdentifier =
|
||||
firstValue(userEntry, config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
|
||||
const email =
|
||||
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
|
||||
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
isAdmin = false;
|
||||
try {
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
`(member=${ldapEscapeFilter(userDN)})`,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some(
|
||||
(group) =>
|
||||
firstValue(group, "cn") === config.adminGroup ||
|
||||
group.dn.toString() === config.adminGroup,
|
||||
);
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerId),
|
||||
subject: ldapIdentifier,
|
||||
email: email || null,
|
||||
name: displayName,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowedUsers ?? null,
|
||||
legacyIdentifier: `ldap:${providerId}:${ldapIdentifier}`,
|
||||
ssoProviderId: providerId,
|
||||
rememberMe: !!rememberMe,
|
||||
rateLimitUsername: rateLimitKey,
|
||||
};
|
||||
} finally {
|
||||
ldapUnbind(serviceClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* OIDC, GitHub and Google sign-in, moved out of the users routes so they can
|
||||
* register as a login method. Phase C moves this into the OIDC plugin.
|
||||
*
|
||||
* start builds the provider's authorize URL; callback turns the provider's
|
||||
* answer into a verified identity. Neither touches sessions.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
|
||||
import { getDesktopOidcCallbackUrl } from "../../utils/oidc-desktop-callback.js";
|
||||
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
OIDCTokenFormatError,
|
||||
buildFetchOptions,
|
||||
extractOidcGroupsFromSources,
|
||||
loadProviderConfig,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
} from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
/** An error raised after the return address is known redirects there. */
|
||||
export class RedirectLoginError extends LoginMethodError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly returnTo: string,
|
||||
status = 400,
|
||||
code?: string,
|
||||
) {
|
||||
super(message, status, code);
|
||||
this.name = "RedirectLoginError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider id used for OIDC identities that came from env configuration. */
|
||||
export const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// RFC 7636 PKCE: 43-128 char unreserved-character string.
|
||||
function generatePkceCodeVerifier(): string {
|
||||
return crypto.randomBytes(64).toString("base64url");
|
||||
}
|
||||
|
||||
function generatePkceCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
export async function deleteOIDCStateSettings(state: string): Promise<void> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.delete(`oidc_state_${state}`);
|
||||
await settingsRepository.delete(`oidc_backend_callback_${state}`);
|
||||
await settingsRepository.delete(`oidc_frontend_origin_${state}`);
|
||||
await settingsRepository.delete(`oidc_remember_me_${state}`);
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
await settingsRepository.delete(`oidc_pkce_verifier_${state}`);
|
||||
}
|
||||
|
||||
export async function startOidcLogin(
|
||||
req: Request,
|
||||
overrides: { providerId?: string | null } = {},
|
||||
): Promise<{ auth_url: string; state: string; nonce: string }> {
|
||||
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
|
||||
const providerIdStr = overrides.providerId ?? req.query.providerId;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, "");
|
||||
const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`;
|
||||
|
||||
const resolvedProviderId = providerIdStr
|
||||
? parseInt(providerIdStr as string, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
resolvedProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 404);
|
||||
}
|
||||
const { config, providerDbId } = providerResult;
|
||||
const state = nanoid();
|
||||
const nonce = nanoid();
|
||||
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin: string;
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
|
||||
if (!frontendOrigin) {
|
||||
throw new LoginMethodError("Invalid desktop callback port", 400);
|
||||
}
|
||||
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
callbackUrl = new URL(appCallbackUrl);
|
||||
} catch {
|
||||
throw new LoginMethodError("Invalid app callback URL", 400);
|
||||
}
|
||||
if (callbackUrl.protocol !== "termix-mobile:") {
|
||||
throw new LoginMethodError("Unsupported app callback URL", 400);
|
||||
}
|
||||
frontendOrigin = callbackUrl.toString();
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else {
|
||||
frontendOrigin = origin;
|
||||
}
|
||||
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = generatePkceCodeChallenge(codeVerifier);
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.set(`oidc_state_${state}`, nonce);
|
||||
await settingsRepository.set(
|
||||
`oidc_backend_callback_${state}`,
|
||||
backendCallbackUri,
|
||||
);
|
||||
await settingsRepository.set(`oidc_frontend_origin_${state}`, frontendOrigin);
|
||||
await settingsRepository.set(
|
||||
`oidc_remember_me_${state}`,
|
||||
rememberMe === "true" ? "true" : "false",
|
||||
);
|
||||
await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier);
|
||||
if (providerDbId != null) {
|
||||
await settingsRepository.set(
|
||||
`oidc_provider_${state}`,
|
||||
String(providerDbId),
|
||||
);
|
||||
}
|
||||
|
||||
const authUrl = new URL(config.authorization_url);
|
||||
authUrl.searchParams.set("client_id", config.client_id);
|
||||
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("scope", config.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("nonce", nonce);
|
||||
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return { auth_url: authUrl.toString(), state, nonce };
|
||||
}
|
||||
|
||||
type ProviderConfig = NonNullable<
|
||||
Awaited<ReturnType<typeof loadProviderConfig>>
|
||||
>["config"];
|
||||
|
||||
async function githubIdentity(
|
||||
code: string,
|
||||
state: string,
|
||||
config: ProviderConfig,
|
||||
providerDbId: number | null,
|
||||
backendCallbackUri: string,
|
||||
frontendOrigin: string,
|
||||
rememberMe: boolean,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const fetchOptions = buildFetchOptions(config.ca_cert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("GitHub token exchange failed", {
|
||||
operation: "github_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
const userInfoResponse = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new LoginMethodError("Failed to get GitHub user information", 400);
|
||||
}
|
||||
const userInfo = (await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
const emailResponse = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
});
|
||||
if (emailResponse.ok) {
|
||||
const emails = (await emailResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
if (primary) userInfo.email = primary.email;
|
||||
}
|
||||
|
||||
const subject = String(userInfo.id ?? userInfo.login);
|
||||
const legacyIdentifier = `github:${providerDbId}:${subject}`;
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier,
|
||||
ssoProviderId: providerDbId,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!path || !obj) return null;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(current, key) => (current as Record<string, unknown>)?.[key],
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's callback. Throws RedirectLoginError once the return address
|
||||
* is known, and a plain LoginMethodError (answered as JSON) before that.
|
||||
*/
|
||||
export async function handleOidcCallback(
|
||||
req: Request,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const { code, state } = req.query;
|
||||
if (!nonEmpty(code) || !nonEmpty(state)) {
|
||||
throw new LoginMethodError("Code and state are required", 400);
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const backendCallbackUri = await settingsRepository.get(
|
||||
`oidc_backend_callback_${state}`,
|
||||
);
|
||||
const frontendOrigin = await settingsRepository.get(
|
||||
`oidc_frontend_origin_${state}`,
|
||||
);
|
||||
const rememberMe =
|
||||
(await settingsRepository.get(`oidc_remember_me_${state}`)) === "true";
|
||||
const codeVerifier = await settingsRepository.get(
|
||||
`oidc_pkce_verifier_${state}`,
|
||||
);
|
||||
|
||||
if (!backendCallbackUri || !frontendOrigin) {
|
||||
throw new LoginMethodError(
|
||||
"Invalid state parameter - redirect URIs not found",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const storedNonce = await settingsRepository.get(`oidc_state_${state}`);
|
||||
if (!storedNonce) {
|
||||
throw new LoginMethodError("Invalid state parameter", 400);
|
||||
}
|
||||
|
||||
const storedProviderId = await settingsRepository.get(
|
||||
`oidc_provider_${state}`,
|
||||
);
|
||||
const callbackProviderId = storedProviderId
|
||||
? parseInt(storedProviderId, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
callbackProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 500);
|
||||
}
|
||||
const { config, providerType, providerDbId } = providerResult;
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
|
||||
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
|
||||
if (providerType === "github") {
|
||||
return await githubIdentity(
|
||||
code,
|
||||
state,
|
||||
config,
|
||||
providerDbId ?? null,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
rememberMe,
|
||||
);
|
||||
}
|
||||
|
||||
const caCert = config.ca_cert;
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("OIDC token exchange failed", {
|
||||
operation: "oidc_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
statusText: tokenResponse.statusText,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
let userInfo: Record<string, unknown> | null = null;
|
||||
const claimSources: Record<string, unknown>[] = [];
|
||||
const userInfoUrls: string[] = [];
|
||||
|
||||
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
|
||||
? config.issuer_url.slice(0, -1)
|
||||
: config.issuer_url;
|
||||
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "");
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(
|
||||
`${normalizedIssuerUrl}/.well-known/openid-configuration`,
|
||||
fetchOptions,
|
||||
);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (discovery.userinfo_endpoint) {
|
||||
userInfoUrls.push(discovery.userinfo_endpoint as string);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||
}
|
||||
|
||||
if (config.userinfo_url) userInfoUrls.unshift(config.userinfo_url);
|
||||
userInfoUrls.push(
|
||||
`${baseUrl}/userinfo/`,
|
||||
`${baseUrl}/userinfo`,
|
||||
`${normalizedIssuerUrl}/userinfo/`,
|
||||
`${normalizedIssuerUrl}/userinfo`,
|
||||
`${baseUrl}/oauth2/userinfo/`,
|
||||
`${baseUrl}/oauth2/userinfo`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo/`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo`,
|
||||
);
|
||||
|
||||
if (tokenData.id_token) {
|
||||
try {
|
||||
userInfo = await verifyOIDCToken(
|
||||
tokenData.id_token as string,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
caCert,
|
||||
);
|
||||
if (userInfo.nonce !== storedNonce) {
|
||||
authLogger.warn("OIDC ID token nonce mismatch", {
|
||||
operation: "oidc_nonce_mismatch",
|
||||
providerId: callbackProviderId,
|
||||
});
|
||||
throw new LoginMethodError("Invalid OIDC token nonce", 401);
|
||||
}
|
||||
claimSources.push(userInfo);
|
||||
} catch (error) {
|
||||
// A token that is not a JWS carries no claims we could trust; fall
|
||||
// through to userinfo. Signature and claim failures still reject.
|
||||
if (!(error instanceof OIDCTokenFormatError)) throw error;
|
||||
userInfo = null;
|
||||
authLogger.warn(
|
||||
"OIDC ID token cannot be verified, falling back to userinfo endpoint",
|
||||
{
|
||||
operation: "oidc_id_token_unverifiable",
|
||||
providerId: callbackProviderId,
|
||||
reason: error.message,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData.access_token) {
|
||||
for (const userInfoUrl of userInfoUrls) {
|
||||
try {
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
...fetchOptions,
|
||||
});
|
||||
if (response.ok) {
|
||||
const fetched = (await response.json()) as Record<string, unknown>;
|
||||
claimSources.push(fetched);
|
||||
userInfo = { ...userInfo, ...fetched };
|
||||
break;
|
||||
}
|
||||
authLogger.error(
|
||||
`Userinfo endpoint ${userInfoUrl} failed with status: ${response.status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
authLogger.error("Failed to get user information from all sources");
|
||||
throw new LoginMethodError("Failed to get user information", 400);
|
||||
}
|
||||
|
||||
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username) as string;
|
||||
const name = (getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier) as string;
|
||||
|
||||
if (!identifier) {
|
||||
throw new LoginMethodError(
|
||||
`User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.admin_group) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
isAdmin = groups.includes(config.admin_group);
|
||||
}
|
||||
|
||||
let roleSync: { desired: string[]; managed: string[] } | undefined;
|
||||
try {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
config.role_map ?? process.env.OIDC_ROLE_MAP,
|
||||
);
|
||||
if (roleMap.size > 0) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
||||
roleSync = { desired: [...desired], managed: [...managed] };
|
||||
}
|
||||
} catch (roleMapError) {
|
||||
authLogger.error("Failed to read the OIDC role map", roleMapError, {
|
||||
operation: "oidc_role_map_sync_failed",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject: identifier,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier: identifier,
|
||||
ssoProviderId: providerDbId ?? null,
|
||||
oidcSub: typeof userInfo.sub === "string" ? userInfo.sub : null,
|
||||
oidcSid: typeof userInfo.sid === "string" ? userInfo.sid : null,
|
||||
roleSync,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) throw error;
|
||||
authLogger.error("OIDC callback failed", error);
|
||||
throw new RedirectLoginError(
|
||||
"OIDC authentication failed",
|
||||
frontendOrigin,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Every login runs through here:
|
||||
*
|
||||
* method -> verified identity -> find or provision the user -> unlock the
|
||||
* data key -> second factors (fail closed) -> session
|
||||
*
|
||||
* A method never mints a session. It says who the user is and core does the
|
||||
* rest, so password, passkey, OIDC, LDAP and any plugin method get the same
|
||||
* second factors, the same cookie and the same audit line.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../utils/user-agent-parser.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { ensureCoreLoginProviders } from "./core-auth.js";
|
||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||
import {
|
||||
getSecondFactor,
|
||||
listSecondFactors,
|
||||
type SecondFactor,
|
||||
} from "./registry.js";
|
||||
import {
|
||||
issueSession,
|
||||
sendSession,
|
||||
syncSharedCredentialsForUserRoles,
|
||||
} from "./session-issuer.js";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PendingLogin,
|
||||
type VerifiedIdentity,
|
||||
} from "./types.js";
|
||||
|
||||
const PENDING_TTL_MS = 10 * 60 * 1000;
|
||||
export const PENDING_LOGIN_COOKIE = "termix_pending_login";
|
||||
|
||||
const pendingLogins = new Map<string, PendingLogin>();
|
||||
|
||||
function prunePending(now = Date.now()): void {
|
||||
for (const [key, pending] of pendingLogins) {
|
||||
if (now - pending.createdAt > PENDING_TTL_MS) pendingLogins.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pendingKey(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetPendingLoginsForTests(): void {
|
||||
pendingLogins.clear();
|
||||
}
|
||||
|
||||
export interface SecondFactorState {
|
||||
/** Factors the user must pass, all with an active provider. */
|
||||
required: SecondFactor[];
|
||||
/** Enrolled in a factor whose plugin is disabled or missing. */
|
||||
blocked: { pluginId: string; factorId: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which second factors apply to a user. A factor recorded in
|
||||
* user_second_factors whose plugin is not running blocks the login: skipping
|
||||
* it would turn "disable the plugin" into "remove everyone's 2FA".
|
||||
*/
|
||||
export async function evaluateSecondFactors(
|
||||
userId: string,
|
||||
): Promise<SecondFactorState> {
|
||||
ensureCoreLoginProviders();
|
||||
const rows =
|
||||
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
||||
const required = new Map<string, SecondFactor>();
|
||||
const blocked: SecondFactorState["blocked"] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const factor = getSecondFactor(row.pluginId, row.factorId);
|
||||
if (!factor) {
|
||||
blocked.push({ pluginId: row.pluginId, factorId: row.factorId });
|
||||
continue;
|
||||
}
|
||||
required.set(`${factor.pluginId}:${factor.id}`, factor);
|
||||
}
|
||||
|
||||
// A factor can also report enrolment itself, for data from before
|
||||
// user_second_factors existed.
|
||||
for (const factor of listSecondFactors()) {
|
||||
const key = `${factor.pluginId}:${factor.id}`;
|
||||
if (required.has(key)) continue;
|
||||
try {
|
||||
if (await factor.isEnrolled(userId)) required.set(key, factor);
|
||||
} catch (error) {
|
||||
authLogger.warn("Second factor enrolment check failed", {
|
||||
operation: "second_factor_enrolment_check",
|
||||
factorId: factor.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { required: [...required.values()], blocked };
|
||||
}
|
||||
|
||||
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
if (!fingerprint) return false;
|
||||
const trusted = await AuthManager.getInstance().isTrustedDevice(
|
||||
userId,
|
||||
fingerprint,
|
||||
);
|
||||
if (trusted) {
|
||||
authLogger.info("Second factor bypassed for trusted device", {
|
||||
operation: "totp_bypass",
|
||||
userId,
|
||||
deviceFingerprint: fingerprint,
|
||||
});
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
|
||||
|
||||
async function resolveUser(
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
): Promise<UserRecord> {
|
||||
if (identity.kind === "external") {
|
||||
return findOrProvisionExternalUser(identity, deviceType);
|
||||
}
|
||||
const user = await createCurrentUserRepository().findById(identity.userId);
|
||||
if (!user)
|
||||
throw new LoginMethodError("User not found", 404, "user_not_found");
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the user's data key. A password login migrates and opens the key
|
||||
* with the password; everything else uses the server-held wrapping.
|
||||
*/
|
||||
async function unlockUser(
|
||||
user: UserRecord,
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
): Promise<void> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
if (identity.kind === "external" || user.isOidc) {
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(user.id, deviceType);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to set up external user encryption", error, {
|
||||
operation: "external_user_encryption_setup_failed",
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = identity.password
|
||||
? await authManager.authenticateUser(user.id, identity.password, deviceType)
|
||||
: await authManager.authenticateWebAuthnUser(user.id, deviceType);
|
||||
if (!ok) {
|
||||
throw new LoginMethodError(
|
||||
identity.password
|
||||
? "Incorrect password"
|
||||
: (identity.unlockError ?? "This account could not be unlocked"),
|
||||
401,
|
||||
"unlock_failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginContext {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted, cleared on success. */
|
||||
rateLimitUsername?: string;
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| {
|
||||
kind: "session";
|
||||
token: string;
|
||||
maxAge: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: "second-factor";
|
||||
tempToken: string;
|
||||
factors: Array<{ id: string; pluginId: string; labelKey: string }>;
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a verified identity to either a session or a second-factor step.
|
||||
* Throws LoginMethodError for anything the user should be told.
|
||||
*/
|
||||
export async function runLogin(
|
||||
req: Request,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
): Promise<LoginResult> {
|
||||
const deviceType = parseUserAgent(req).type;
|
||||
const user = await resolveUser(identity, deviceType);
|
||||
|
||||
await unlockUser(user, identity, deviceType);
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
user.id,
|
||||
`${context.methodId}_role_shared_credentials`,
|
||||
);
|
||||
|
||||
const factors = await evaluateSecondFactors(user.id);
|
||||
if (factors.blocked.length > 0) {
|
||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||
operation: "second_factor_unavailable",
|
||||
userId: user.id,
|
||||
factors: factors.blocked,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login_blocked_second_factor",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ unavailable: factors.blocked }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
errorMessage: "Enrolled second factor unavailable",
|
||||
});
|
||||
throw new LoginMethodError(
|
||||
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
403,
|
||||
"second_factor_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
const ssoClaims = {
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
oidcSub: identity.oidcSub ?? null,
|
||||
oidcSid: identity.oidcSid ?? null,
|
||||
};
|
||||
|
||||
if (
|
||||
factors.required.length > 0 &&
|
||||
!identity.mfaSatisfied &&
|
||||
!(await isTrustedDevice(req, user.id))
|
||||
) {
|
||||
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
||||
user.id,
|
||||
{ pendingTOTP: true, expiresIn: "10m" },
|
||||
);
|
||||
prunePending();
|
||||
pendingLogins.set(pendingKey(tempToken), {
|
||||
userId: user.id,
|
||||
methodId: context.methodId,
|
||||
rememberMe: context.rememberMe,
|
||||
...ssoClaims,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
kind: "second-factor",
|
||||
tempToken,
|
||||
rememberMe: context.rememberMe,
|
||||
factors: factors.required.map((factor) => ({
|
||||
id: factor.id,
|
||||
pluginId: factor.pluginId,
|
||||
labelKey: factor.labelKey,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: context.methodId,
|
||||
rememberMe: context.rememberMe,
|
||||
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
||||
longLivedForApps: identity.kind === "external",
|
||||
...ssoClaims,
|
||||
});
|
||||
return { kind: "session", ...session };
|
||||
}
|
||||
|
||||
/** JSON form of a login result, the shape the login screen expects. */
|
||||
export function secondFactorBody(
|
||||
result: Extract<LoginResult, { kind: "second-factor" }>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
requires_second_factor: true,
|
||||
temp_token: result.tempToken,
|
||||
rememberMe: result.rememberMe,
|
||||
second_factors: result.factors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a form login and writes the response. */
|
||||
export async function respondWithLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
): Promise<Response> {
|
||||
const result = await runLogin(req, identity, context);
|
||||
if (result.kind === "second-factor") {
|
||||
return res.json(secondFactorBody(result));
|
||||
}
|
||||
return sendSession(req, res, result);
|
||||
}
|
||||
|
||||
export function sendLoginError(res: Response, error: unknown): Response {
|
||||
if (error instanceof LoginMethodError) {
|
||||
const remainingTime = (error as { remainingTime?: number }).remainingTime;
|
||||
return res.status(error.status).json({
|
||||
error: error.message,
|
||||
...(error.code ? { code: error.code } : {}),
|
||||
...(remainingTime !== undefined ? { remainingTime } : {}),
|
||||
});
|
||||
}
|
||||
authLogger.error("Login failed", error);
|
||||
return res.status(500).json({ error: "Login failed" });
|
||||
}
|
||||
|
||||
export interface PendingLoginLookup {
|
||||
userId: string;
|
||||
token: string;
|
||||
pending: PendingLogin | null;
|
||||
}
|
||||
|
||||
/** Checks a pending token from the body or the redirect cookie. */
|
||||
export async function readPendingLogin(
|
||||
req: Request,
|
||||
): Promise<PendingLoginLookup | null> {
|
||||
const token =
|
||||
(typeof req.body?.temp_token === "string" && req.body.temp_token) ||
|
||||
(req.cookies?.[PENDING_LOGIN_COOKIE] as string | undefined);
|
||||
if (!token) return null;
|
||||
const decoded = await AuthManager.getInstance().verifyJWTToken(token);
|
||||
if (!decoded || !decoded.pendingTOTP) return null;
|
||||
prunePending();
|
||||
const pending = pendingLogins.get(pendingKey(token)) ?? null;
|
||||
return { userId: decoded.userId, token, pending };
|
||||
}
|
||||
|
||||
export function consumePendingLogin(token: string): void {
|
||||
pendingLogins.delete(pendingKey(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a login that stopped for a second factor. The pending token comes
|
||||
* from the body, or from the cookie a redirect method set.
|
||||
*/
|
||||
export async function verifySecondFactorAndRespond(
|
||||
req: Request,
|
||||
res: Response,
|
||||
factorId: string,
|
||||
): Promise<Response> {
|
||||
const lookup = await readPendingLogin(req);
|
||||
if (!lookup) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findById(lookup.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
const lockStatus = loginRateLimiter.isTOTPLocked(user.id);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Second factor blocked due to rate limiting", {
|
||||
operation: "totp_verify_blocked",
|
||||
userId: user.id,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
return res.status(429).json({
|
||||
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
code: "TOTP_RATE_LIMITED",
|
||||
});
|
||||
}
|
||||
loginRateLimiter.recordFailedTOTPAttempt(user.id);
|
||||
|
||||
const factors = await evaluateSecondFactors(user.id);
|
||||
if (factors.blocked.length > 0) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
code: "second_factor_unavailable",
|
||||
});
|
||||
}
|
||||
const factor = factors.required.find(
|
||||
(candidate) => candidate.id === factorId,
|
||||
);
|
||||
if (!factor) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "That second factor is not enabled for this user" });
|
||||
}
|
||||
|
||||
const result = await factor.verify(user.id, req.body ?? {});
|
||||
if (result !== true) {
|
||||
if (result && typeof result === "object") {
|
||||
return res.status(result.code === "SESSION_EXPIRED" ? 401 : 400).json({
|
||||
error: result.error,
|
||||
...(result.code ? { code: result.code } : {}),
|
||||
});
|
||||
}
|
||||
authLogger.warn("Second factor verification failed", {
|
||||
operation: "totp_verify_failed",
|
||||
userId: user.id,
|
||||
factorId,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login_second_factor_failed",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ factor: factorId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Invalid TOTP code",
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id),
|
||||
});
|
||||
}
|
||||
loginRateLimiter.resetTOTPAttempts(user.id);
|
||||
|
||||
const rememberMe =
|
||||
typeof req.body?.rememberMe === "boolean"
|
||||
? req.body.rememberMe
|
||||
: (lookup.pending?.rememberMe ?? false);
|
||||
|
||||
if (rememberMe) {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
if (fingerprint) {
|
||||
await AuthManager.getInstance().addTrustedDevice(
|
||||
user.id,
|
||||
fingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: user.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pending = lookup.pending;
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: pending?.methodId ?? "password",
|
||||
rememberMe,
|
||||
ssoProviderId: pending?.ssoProviderId ?? null,
|
||||
oidcSub: pending?.oidcSub ?? null,
|
||||
oidcSid: pending?.oidcSid ?? null,
|
||||
});
|
||||
|
||||
consumePendingLogin(lookup.token);
|
||||
res.clearCookie(
|
||||
PENDING_LOGIN_COOKIE,
|
||||
AuthManager.getInstance().getClearCookieOptions(req),
|
||||
);
|
||||
return sendSession(req, res, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a redirect login (OIDC and friends): back to where the browser
|
||||
* came from, with a cookie, or with the token in the URL for the desktop and
|
||||
* mobile app callbacks that cannot read one.
|
||||
*/
|
||||
export async function respondWithRedirectLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
isTokenCallback: (url: string) => boolean,
|
||||
): Promise<void> {
|
||||
const returnTo = identity.returnTo;
|
||||
if (!returnTo) {
|
||||
res.status(500).json({ error: "Login method did not say where to return" });
|
||||
return;
|
||||
}
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
let result: LoginResult;
|
||||
try {
|
||||
result = await runLogin(req, identity, context);
|
||||
} catch (error) {
|
||||
redirectWithLoginError(res, returnTo, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(returnTo);
|
||||
const tokenCallback = isTokenCallback(returnTo);
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
if (result.kind === "second-factor") {
|
||||
url.searchParams.set("second_factor", "1");
|
||||
url.searchParams.set(
|
||||
"second_factors",
|
||||
result.factors.map((factor) => factor.id).join(","),
|
||||
);
|
||||
if (tokenCallback) {
|
||||
url.searchParams.set("temp_token", result.tempToken);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
res
|
||||
.cookie(
|
||||
PENDING_LOGIN_COOKIE,
|
||||
result.tempToken,
|
||||
authManager.getSecureCookieOptions(req, PENDING_TTL_MS),
|
||||
)
|
||||
.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
url.searchParams.set("success", "true");
|
||||
if (tokenCallback) {
|
||||
url.searchParams.set("token", result.token);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
res
|
||||
.cookie(
|
||||
"jwt",
|
||||
result.token,
|
||||
authManager.getSecureCookieOptions(req, result.maxAge),
|
||||
)
|
||||
.redirect(url.toString());
|
||||
}
|
||||
|
||||
export function redirectWithLoginError(
|
||||
res: Response,
|
||||
returnTo: string,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Redirect login failed", error);
|
||||
}
|
||||
const url = new URL(returnTo);
|
||||
url.searchParams.set(
|
||||
"error",
|
||||
error instanceof LoginMethodError
|
||||
? (error.code ?? error.message)
|
||||
: "OIDC authentication failed",
|
||||
);
|
||||
res.redirect(url.toString());
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Finds or creates the user behind an external sign-in (OIDC, LDAP, a
|
||||
* plugin's provider). The rules used to be copied into every SSO route; they
|
||||
* live here once so every login method gets the same ones:
|
||||
*
|
||||
* - the allowed-users list is checked on every sign-in, new user or not
|
||||
* - the first user ever becomes admin and skips the list
|
||||
* - anyone else is only created when SSO auto-provisioning is on
|
||||
* - an admin group, when the provider reports one, keeps admin in step
|
||||
* - a mapped role set, when the provider reports one, keeps roles in step
|
||||
*/
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { isOIDCUserAllowed } from "../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
|
||||
export async function isExternalProvisioningAllowed(): Promise<boolean> {
|
||||
try {
|
||||
if (
|
||||
await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
false,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the environment
|
||||
}
|
||||
return (
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a newly provisioned user must wait for an admin. There is no
|
||||
* approval queue yet, so nobody does; a plugin or a later release can change
|
||||
* that here without touching any login method.
|
||||
*/
|
||||
export async function isApprovalRequired(
|
||||
_identity: ExternalIdentity,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowed(identity: ExternalIdentity): boolean {
|
||||
if (!identity.allowedUsers) return true;
|
||||
const email = identity.email ?? undefined;
|
||||
return (
|
||||
isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
(!!identity.legacyIdentifier &&
|
||||
isOIDCUserAllowed(
|
||||
identity.allowedUsers,
|
||||
identity.legacyIdentifier,
|
||||
email,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async function syncAdmin(
|
||||
user: UserRecord,
|
||||
isAdmin: boolean,
|
||||
): Promise<UserRecord> {
|
||||
if (!!user.isAdmin === isAdmin) return user;
|
||||
const updated =
|
||||
(await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user;
|
||||
try {
|
||||
await createCurrentRoleRepository().switchUserRoleName({
|
||||
userId: user.id,
|
||||
addRoleName: isAdmin ? "admin" : "user",
|
||||
removeRoleName: isAdmin ? "user" : "admin",
|
||||
grantedBy: user.id,
|
||||
});
|
||||
} catch {
|
||||
// Roles follow on the next sign-in; the admin flag itself is saved.
|
||||
}
|
||||
authLogger.info("Admin status synced from the identity provider", {
|
||||
operation: "external_admin_sync",
|
||||
userId: user.id,
|
||||
isAdmin,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds and removes the roles a provider's group map manages. Roles assigned
|
||||
* by hand, and roles outside the map, are never touched.
|
||||
*/
|
||||
export async function applyRoleSync(
|
||||
userId: string,
|
||||
roleSync: { desired: string[]; managed: string[] },
|
||||
): Promise<void> {
|
||||
const desired = new Set(roleSync.desired);
|
||||
const managed = new Set(roleSync.managed);
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
const currentRoles = await roleRepository.listUserRoles(userId);
|
||||
const currentNames = new Set(currentRoles.map((role) => role.roleName));
|
||||
|
||||
const toAdd = [...desired].filter((name) => !currentNames.has(name));
|
||||
const toRemove = currentRoles.filter(
|
||||
(role) => managed.has(role.roleName) && !desired.has(role.roleName),
|
||||
);
|
||||
|
||||
for (const roleName of toAdd) {
|
||||
const assigned = await roleRepository.assignRoleNameToUser({
|
||||
userId,
|
||||
roleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
if (!assigned) {
|
||||
authLogger.warn("Role map references a role that does not exist", {
|
||||
operation: "external_role_map_missing_role",
|
||||
userId,
|
||||
roleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const role of toRemove) {
|
||||
await roleRepository.removeRoleFromUser(userId, role.roleId);
|
||||
}
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
authLogger.info("Roles synced from provider group membership", {
|
||||
operation: "external_role_map_sync",
|
||||
userId,
|
||||
added: toAdd,
|
||||
removed: toRemove.map((role) => role.roleName),
|
||||
});
|
||||
PermissionManager.getInstance().invalidateUserPermissionCache(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function findLinkedUser(
|
||||
identity: ExternalIdentity,
|
||||
): Promise<UserRecord | null> {
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const users = createCurrentUserRepository();
|
||||
|
||||
const linked = await identities.findIdentity(
|
||||
identity.provider,
|
||||
identity.subject,
|
||||
);
|
||||
if (linked) {
|
||||
const user = await users.findById(linked.userId);
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
// Accounts from before user_external_identities matched on the raw
|
||||
// identifier column; link them the first time they sign in.
|
||||
if (identity.legacyIdentifier) {
|
||||
const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier);
|
||||
if (legacy) {
|
||||
await identities.linkIdentity({
|
||||
userId: legacy.id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
identity: ExternalIdentity,
|
||||
deviceType: string,
|
||||
): Promise<UserRecord> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = nanoid();
|
||||
const created = await users.createFirstSsoUser({
|
||||
id,
|
||||
username: identity.name || identity.subject,
|
||||
passwordHash: "",
|
||||
isAdmin: !!identity.isAdmin,
|
||||
isOidc: true,
|
||||
oidcIdentifier:
|
||||
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
const assigned = await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: created.user.isAdmin ? "admin" : "user",
|
||||
grantedBy: id,
|
||||
});
|
||||
if (!assigned) {
|
||||
authLogger.warn("Default role not found for a provisioned user", {
|
||||
operation: "external_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error("Failed to assign a default role", roleError, {
|
||||
operation: "external_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? THIRTY_DAYS_MS
|
||||
: ONE_DAY_MS;
|
||||
await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await users.delete(id);
|
||||
authLogger.error(
|
||||
"Failed to set up a provisioned user's encryption, user removed",
|
||||
encryptionError,
|
||||
{ operation: "external_user_create_failed", userId: id },
|
||||
);
|
||||
throw new LoginMethodError(
|
||||
"Failed to setup user security - user creation cancelled",
|
||||
500,
|
||||
"setup_failed",
|
||||
);
|
||||
}
|
||||
|
||||
await createCurrentUserAuthRepository().linkIdentity({
|
||||
userId: id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("external_user_create");
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist a provisioned user", saveError, {
|
||||
operation: "external_user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
return created.user;
|
||||
}
|
||||
|
||||
export async function findOrProvisionExternalUser(
|
||||
identity: ExternalIdentity,
|
||||
deviceType: string,
|
||||
): Promise<UserRecord> {
|
||||
let user = await findLinkedUser(identity);
|
||||
|
||||
if (!user) {
|
||||
const isFirstUser = (await createCurrentUserRepository().countAll()) === 0;
|
||||
if (!isFirstUser) {
|
||||
if (!isAllowed(identity)) {
|
||||
authLogger.warn("External user not in the allowed list", {
|
||||
operation: "external_user_not_allowed",
|
||||
provider: identity.provider,
|
||||
});
|
||||
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
||||
}
|
||||
if (!(await isExternalProvisioningAllowed())) {
|
||||
authLogger.warn("External sign-in refused, provisioning is off", {
|
||||
operation: "external_registration_disabled",
|
||||
provider: identity.provider,
|
||||
});
|
||||
throw new LoginMethodError(
|
||||
"Registration is disabled",
|
||||
403,
|
||||
"registration_disabled",
|
||||
);
|
||||
}
|
||||
if (await isApprovalRequired(identity)) {
|
||||
throw new LoginMethodError(
|
||||
"Your account is waiting for an admin to approve it",
|
||||
403,
|
||||
"approval_required",
|
||||
);
|
||||
}
|
||||
}
|
||||
user = await createUser(identity, deviceType);
|
||||
} else {
|
||||
if (!isAllowed(identity)) {
|
||||
authLogger.warn("External user not in the allowed list", {
|
||||
operation: "external_user_not_allowed_existing",
|
||||
provider: identity.provider,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
||||
}
|
||||
|
||||
// A user who also has a password picked their own username; leave it.
|
||||
const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== "";
|
||||
if (!isDualAuth && identity.name && user.username !== identity.name) {
|
||||
user =
|
||||
(await createCurrentUserRepository().update(user.id, {
|
||||
username: identity.name,
|
||||
})) ?? user;
|
||||
}
|
||||
|
||||
if (identity.isAdmin !== undefined) {
|
||||
user = await syncAdmin(user, identity.isAdmin);
|
||||
}
|
||||
}
|
||||
|
||||
if (identity.roleSync) {
|
||||
try {
|
||||
await applyRoleSync(user.id, identity.roleSync);
|
||||
} catch (roleSyncError) {
|
||||
authLogger.error("Failed to sync roles from provider", roleSyncError, {
|
||||
operation: "external_role_map_sync_failed",
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Login methods and second factors, from core and from plugins.
|
||||
*
|
||||
* Password login is registered by core and always present. Everything else
|
||||
* (OIDC, LDAP, passkeys, TOTP, ...) registers here, from a plugin through
|
||||
* ctx.auth or, in 2.9.0, from legacy-providers.ts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginLoginMethod,
|
||||
PluginSecondFactor,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type LoginMethod = PluginLoginMethod & { pluginId: string };
|
||||
export type SecondFactor = PluginSecondFactor & { pluginId: string };
|
||||
|
||||
const loginMethods = new Map<string, LoginMethod>();
|
||||
const secondFactors = new Map<string, SecondFactor>();
|
||||
|
||||
function register<T extends { id: string; pluginId: string }>(
|
||||
map: Map<string, T>,
|
||||
kind: string,
|
||||
entry: T,
|
||||
): () => void {
|
||||
const existing = map.get(entry.id);
|
||||
if (existing && existing.pluginId !== entry.pluginId) {
|
||||
throw new Error(
|
||||
`${kind} "${entry.id}" is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
map.set(entry.id, entry);
|
||||
return () => {
|
||||
if (map.get(entry.id) === entry) map.delete(entry.id);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerLoginMethod(method: LoginMethod): () => void {
|
||||
return register(loginMethods, "Login method", method);
|
||||
}
|
||||
|
||||
export function getLoginMethod(id: string): LoginMethod | undefined {
|
||||
return loginMethods.get(id);
|
||||
}
|
||||
|
||||
export function listLoginMethods(): LoginMethod[] {
|
||||
return [...loginMethods.values()];
|
||||
}
|
||||
|
||||
export function registerSecondFactor(factor: SecondFactor): () => void {
|
||||
return register(secondFactors, "Second factor", factor);
|
||||
}
|
||||
|
||||
export function getSecondFactor(
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): SecondFactor | undefined {
|
||||
const factor = secondFactors.get(factorId);
|
||||
return factor && factor.pluginId === pluginId ? factor : undefined;
|
||||
}
|
||||
|
||||
export function listSecondFactors(): SecondFactor[] {
|
||||
return [...secondFactors.values()];
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetAuthRegistryForTests(): void {
|
||||
loginMethods.clear();
|
||||
secondFactors.clear();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Admin view and reset of a user's second factors. Covers factors whose
|
||||
* plugin is disabled or gone, which is exactly when a user gets locked out
|
||||
* and an admin has to step in.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { ensureCoreLoginProviders } from "./core-auth.js";
|
||||
import { getSecondFactor, listSecondFactors } from "./registry.js";
|
||||
|
||||
export interface UserSecondFactorSummary {
|
||||
pluginId: string;
|
||||
factorId: string;
|
||||
labelKey: string | null;
|
||||
/** False when the plugin behind it is not running. */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export async function listUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<UserSecondFactorSummary[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const rows =
|
||||
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
||||
const summaries: UserSecondFactorSummary[] = rows.map((row) => {
|
||||
const factor = getSecondFactor(row.pluginId, row.factorId);
|
||||
return {
|
||||
pluginId: row.pluginId,
|
||||
factorId: row.factorId,
|
||||
labelKey: factor?.labelKey ?? null,
|
||||
available: !!factor,
|
||||
};
|
||||
});
|
||||
|
||||
for (const factor of listSecondFactors()) {
|
||||
if (
|
||||
summaries.some(
|
||||
(summary) =>
|
||||
summary.pluginId === factor.pluginId &&
|
||||
summary.factorId === factor.id,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (await factor.isEnrolled(userId).catch(() => false)) {
|
||||
summaries.push({
|
||||
pluginId: factor.pluginId,
|
||||
factorId: factor.id,
|
||||
labelKey: factor.labelKey,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every factor: runs each running factor's own reset (so TOTP clears
|
||||
* its secret), drops the enrolment rows including orphaned ones, and forgets
|
||||
* trusted devices so an old "remember me" cannot skip the new factor.
|
||||
*/
|
||||
export async function resetUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<Array<{ pluginId: string; factorId: string }>> {
|
||||
const factors = await listUserSecondFactors(userId);
|
||||
for (const summary of factors) {
|
||||
const factor = getSecondFactor(summary.pluginId, summary.factorId);
|
||||
if (!factor?.reset) continue;
|
||||
try {
|
||||
await factor.reset(userId);
|
||||
} catch (error) {
|
||||
authLogger.error("Second factor reset failed", error, {
|
||||
operation: "admin_reset_second_factor",
|
||||
userId,
|
||||
factorId: summary.factorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await createCurrentUserAuthRepository().clearSecondFactors(userId);
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
return factors.map(({ pluginId, factorId }) => ({ pluginId, factorId }));
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The one place a login becomes a session: JWT, cookie, response body,
|
||||
* audit line and the automation event. Every login method ends here, so
|
||||
* they all behave the same way.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
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 { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isNativeAppRequest(req: Request): boolean {
|
||||
return (
|
||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||
req.get("X-Electron-App") === "true"
|
||||
);
|
||||
}
|
||||
|
||||
/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */
|
||||
export async function sessionCookieMaxAge(
|
||||
rememberMe: boolean,
|
||||
): Promise<number> {
|
||||
if (rememberMe) return THIRTY_DAYS_MS;
|
||||
const value = await createCurrentSettingsRepository().get(
|
||||
"session_timeout_hours",
|
||||
);
|
||||
const hours = value ? parseInt(value, 10) || 24 : 24;
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export async function syncSharedCredentialsForUserRoles(
|
||||
userId: string,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
||||
const { SharedCredentialSecretsManager } =
|
||||
await import("../utils/shared-credential-secrets-manager.js");
|
||||
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to sync role shared host secrets", {
|
||||
operation,
|
||||
userId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface IssueSessionOptions {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted attempts under, to clear them. */
|
||||
rateLimitUsername?: string;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||
longLivedForApps?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuedSession {
|
||||
token: string;
|
||||
maxAge: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints the JWT and writes the audit line. The caller decides how the token
|
||||
* reaches the client: a JSON body with a cookie, or a redirect.
|
||||
*/
|
||||
export async function issueSession(
|
||||
req: Request,
|
||||
user: UserRecord,
|
||||
options: IssueSessionOptions,
|
||||
): Promise<IssuedSession> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const token = await authManager.generateJWTToken(user.id, {
|
||||
rememberMe: options.rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
oidcSub: options.oidcSub ?? null,
|
||||
oidcSid: options.oidcSid ?? null,
|
||||
ssoProviderId: options.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
if (options.rateLimitUsername) {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ method: options.methodId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
notifyAutomationInternalEvent("user_login", user.id, undefined, {
|
||||
username: user.username,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
authLogger.success("User login successful", {
|
||||
operation: "user_login_complete",
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
method: options.methodId,
|
||||
});
|
||||
|
||||
const maxAge =
|
||||
options.longLivedForApps &&
|
||||
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
||||
? THIRTY_DAYS_MS
|
||||
: await sessionCookieMaxAge(options.rememberMe);
|
||||
|
||||
return {
|
||||
token,
|
||||
maxAge,
|
||||
body: {
|
||||
success: true,
|
||||
is_admin: !!user.isAdmin,
|
||||
username: user.username,
|
||||
userId: user.id,
|
||||
is_oidc: !!user.isOidc,
|
||||
totp_enabled: !!user.totpEnabled,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Sets the cookie and sends the body. */
|
||||
export function sendSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
session: IssuedSession,
|
||||
): Response {
|
||||
const authManager = AuthManager.getInstance();
|
||||
return res
|
||||
.cookie(
|
||||
"jwt",
|
||||
session.token,
|
||||
authManager.getSecureCookieOptions(req, session.maxAge),
|
||||
)
|
||||
.json(session.body);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export { LoginMethodError } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/** Extra fields core's own methods may set. Plugins never see these. */
|
||||
interface CoreIdentityExtras {
|
||||
/**
|
||||
* The identifier string the user row carried before user_external_identities
|
||||
* existed ("ldap:<provider>:<id>", "github:<provider>:<id>" or a bare OIDC
|
||||
* subject). Found users get their identity row linked lazily; new users keep
|
||||
* the column filled so older code that reads it still works.
|
||||
*/
|
||||
legacyIdentifier?: string;
|
||||
/** SSO provider row the session came from, for back-channel logout. */
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** Provider group to role mapping, applied on every login. */
|
||||
roleSync?: { desired: string[]; managed: string[] };
|
||||
/** Shown when the user's data key cannot be unlocked by this method. */
|
||||
unlockError?: string;
|
||||
/** Username the login rate limiter counted, cleared on success. */
|
||||
rateLimitUsername?: string;
|
||||
}
|
||||
|
||||
export type VerifiedIdentity = PluginVerifiedIdentity & CoreIdentityExtras;
|
||||
|
||||
/** What a login needs to remember while it waits for a second factor. */
|
||||
export interface PendingLogin {
|
||||
userId: string;
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
Reference in new issue
Block a user