Files
Termix/src/backend/auth/login-pipeline.ts
T

703 lines
22 KiB
TypeScript

/**
* 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<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) {
// 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<string, string> })
.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<boolean> {
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<typeof parseUserAgent>["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<UserRecord, "isExternal" | "passwordHash">,
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<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.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<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);
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<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 };
}
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<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 = 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<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());
}