/** * 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 { 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, isSecondFactorAfterExternalLoginEnabled, } from "./core-auth.js"; import { findOrProvisionExternalUser } from "./provisioning.js"; import { SECOND_FACTOR_SETUP, evaluateAuthPolicy, hasAuthPolicies, rememberDeviceAllowed, } from "./policy.js"; import { getLoginMethod, getSecondFactor, listSecondFactors, type SecondFactor, } from "./registry.js"; import { isNativeAppRequest, issueSession, sendSession, syncSharedCredentialsForUserRoles, } from "./session-issuer.js"; import { LoginMethodError, type PendingLogin, type VerifiedIdentity, } from "./types.js"; const PENDING_TTL_MS = 10 * 60 * 1000; const PENDING_LOGIN_COOKIE = "termix_pending_login"; const pendingLogins = new Map(); 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 { ensureCoreLoginProviders(); const rows = await createCurrentUserAuthRepository().listSecondFactors(userId); const required = new Map(); 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) { // Unknown is not "not enrolled": the login stops instead of skipping // a factor the user may well have. blocked.push({ pluginId: factor.pluginId, factorId: factor.id }); authLogger.warn("Second factor enrolment check failed", { operation: "second_factor_enrolment_check", factorId: factor.id, error, }); } } return { required: [...required.values()], blocked }; } /** * Whether second factors should even be asked for this login. Off, they only * run for password and other local form logins, matching 2.8: the setting * governs external methods (SSO, LDAP) alone. */ function shouldRunSecondFactors(methodId: string): boolean { if (!isExternalMethod(methodId)) return true; return isSecondFactorAfterExternalLoginEnabled(); } function isExternalMethod(methodId: string): boolean { ensureCoreLoginProviders(); return !!getLoginMethod(methodId)?.external; } export const TRUST_DEVICE_COOKIE = "termix_trust_device"; const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/; const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; /** * The remember-this-device token the server handed out after a full login. * Browsers carry it in an HttpOnly cookie; the desktop app, which talks to * the server cross-origin without cookies, sends it back as a header. */ function readTrustToken(req: Request): string | null { const cookies = (req as Request & { cookies?: Record }) .cookies; const value = cookies?.[TRUST_DEVICE_COOKIE] || req.get("x-termix-trust-token"); return typeof value === "string" && TRUST_TOKEN_PATTERN.test(value) ? value : null; } export function hashTrustToken(token: string): string { return crypto.createHash("sha256").update(`trust-v2|${token}`).digest("hex"); } async function isTrustedDevice(req: Request, userId: string): Promise { const token = readTrustToken(req); if (!token) return false; const fingerprint = hashTrustToken(token); 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["type"]; /** * An account that only signs in through SSO or LDAP must keep doing so. A * passkey or any other local method would skip the identity provider, so a * user disabled there could still get in. */ export function assertMethodAllowedFor( user: Pick, identity: VerifiedIdentity, methodId: string, ): void { if (identity.kind === "external" || !user.isExternal) return; if (user.passwordHash && user.passwordHash.trim() !== "") return; ensureCoreLoginProviders(); if (getLoginMethod(methodId)?.external) return; throw new LoginMethodError( "This account signs in through its identity provider", 403, "external_login_required", ); } async function resolveUser( identity: VerifiedIdentity, deviceType: DeviceType, ): Promise { 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 { const authManager = AuthManager.getInstance(); if (identity.kind === "external" || user.isExternal) { try { await authManager.authenticateExternalUser(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.unlockWithSystemKey(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; } | { 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 { const deviceType = parseUserAgent(req).type; const user = await resolveUser(identity, deviceType); assertMethodAllowedFor(user, identity, context.methodId); await unlockUser(user, identity, deviceType); if ("password" in identity && identity.password) { // A 2.8 key still wrapped by the password only opens here, so this is the // first chance to move that user's TOTP secret and channel configs. const { runTotpMigration } = await import("../upgrade/totp-migration.js"); await runTotpMigration(user.id); const { runNotificationChannelMigration } = await import("../upgrade/notification-channel-migration.js"); await runNotificationChannelMigration(user.id); const { runProtocolAuthMigration } = await import("../upgrade/protocol-auth-migration.js"); await runProtocolAuthMigration(user.id); } await syncSharedCredentialsForUserRoles( user.id, `${context.methodId}_role_shared_credentials`, ); // A login policy may want a factor where core would skip it, so it always // sees the full picture. const runFactors = shouldRunSecondFactors(context.methodId); const factors = runFactors || hasAuthPolicies() ? await evaluateSecondFactors(user.id) : { required: [], blocked: [] }; const trusted = factors.required.length > 0 || hasAuthPolicies() ? await isTrustedDevice(req, user.id) : false; const policy = await evaluateAuthPolicy({ userId: user.id, methodId: context.methodId, external: isExternalMethod(context.methodId), deviceType, trustedDevice: trusted, mfaSatisfied: !!identity.mfaSatisfied, secondFactors: listSecondFactors().map((factor) => ({ id: factor.id, pluginId: factor.pluginId, enrolled: factors.required.some( (required) => required.id === factor.id && required.pluginId === factor.pluginId, ), })), }); if (!runFactors && !policy.secondFactor) { factors.required = []; factors.blocked = []; } const rememberMe = context.rememberMe && policy.allowRememberDevice; const skipByDevice = trusted && policy.allowRememberDevice && policy.secondFactor !== "always"; 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 = { externalSession: identity.externalSession ?? null }; if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) { const tempToken = await AuthManager.getInstance().generateJWTToken( user.id, { pendingTOTP: true, expiresIn: "10m" }, ); prunePending(); pendingLogins.set(pendingKey(tempToken), { userId: user.id, methodId: context.methodId, rememberMe, allowRememberDevice: policy.allowRememberDevice, maxSessionSeconds: policy.maxSessionSeconds, idleTimeoutSeconds: policy.idleTimeoutSeconds, ...ssoClaims, createdAt: Date.now(), }); return { kind: "second-factor", tempToken, rememberMe, factors: factors.required.map((factor) => ({ id: factor.id, pluginId: factor.pluginId, labelKey: factor.labelKey, })), }; } // Required but nothing set up yet: a session that can only set one up. const needsSetup = !!policy.secondFactor && factors.required.length === 0 && !identity.mfaSatisfied && !skipByDevice; const session = await issueSession(req, user, { methodId: context.methodId, rememberMe, rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername, longLivedForApps: identity.kind === "external" && policy.allowRememberDevice, maxAgeSeconds: policy.maxSessionSeconds, idleTimeoutSeconds: policy.idleTimeoutSeconds, restriction: needsSetup ? SECOND_FACTOR_SETUP : null, ...ssoClaims, }); return { kind: "session", ...session }; } /** JSON form of a login result, the shape the login screen expects. */ function secondFactorBody( result: Extract, ): Record { 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 { 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 { 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 }; } 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. Without a factor * id the user's first required factor answers, for the 2.8 route. */ export async function verifySecondFactorAndRespond( req: Request, res: Response, factorId?: string, ): Promise { 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 = factorId ? factors.required.find((candidate) => candidate.id === factorId) : factors.required[0]; 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: factor.id, }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId: user.id, username: user.username, action: "login_second_factor_failed", resourceType: "session", details: JSON.stringify({ factor: factor.id }), ipAddress, userAgent, success: false, }); return res.status(401).json({ error: "Invalid TOTP code", remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id), }); } loginRateLimiter.resetTOTPAttempts(user.id); // A pending entry lost to a restart falls back to the current policy. const allowRemember = lookup.pending ? lookup.pending.allowRememberDevice !== false : rememberDeviceAllowed(); const rememberMe = allowRemember && (typeof req.body?.rememberMe === "boolean" ? req.body.rememberMe : (lookup.pending?.rememberMe ?? false)); let trustToken: string | null = null; if (rememberMe) { const deviceInfo = parseUserAgent(req); trustToken = crypto.randomBytes(32).toString("hex"); await AuthManager.getInstance().addTrustedDevice( user.id, hashTrustToken(trustToken), deviceInfo.type, deviceInfo.deviceInfo, ); res.cookie( TRUST_DEVICE_COOKIE, trustToken, AuthManager.getInstance().getSecureCookieOptions( req, TRUST_TOKEN_MAX_AGE_MS, ), ); 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, externalSession: pending?.externalSession ?? null, maxAgeSeconds: pending?.maxSessionSeconds ?? null, idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null, }); if (trustToken && isNativeAppRequest(req)) { session.body.trustToken = trustToken; } 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 { 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()); }