IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

totp-service.ts

Buka raw
import "server-only";

import { randomUUID } from "node:crypto";
import { toString as qrCodeToString } from "qrcode";

import type {
  TwoFactorDisableResponse,
  TwoFactorErrorCode,
  TwoFactorSetupResponse,
  TwoFactorVerifyResponse,
} from "@/lib/shared/contracts/auth";
import { serverEnv } from "@/lib/server/env";
import {
  clearTotpEnrollment as persistClearedTotp,
  completeTotpEnrollment as persistEnrolledTotp,
  createSession,
  deleteSessionsForUser,
  findUserWithTotpById,
  setPendingTotpSecret,
  updateTotpBackupCodes,
} from "@/lib/server/modules/auth/repository";
import { verifyPartialAuthToken } from "@/lib/server/modules/auth/partial-auth-token";
import {
  isPartialAuthTokenBlocked,
  isPartialAuthTokenConsumed,
  isTotpCodeReplayed,
  markPartialAuthTokenConsumed,
  markTotpCodeUsed,
  recordPartialAuthFailure,
} from "@/lib/server/modules/auth/rate-limit";
import { createSessionToken } from "@/lib/server/modules/auth/session-token";
import {
  decryptSecret,
  encryptSecret,
} from "@/lib/server/modules/auth/totp-crypto";
import {
  generateBackupCodes,
  generateTotpSecret,
  verifyBackupCode,
  verifyTotp,
} from "@/lib/server/modules/auth/totp";

/**
 * Short, code-driven strings returned in the response body. Kept narrow on
 * purpose — operational detail like "stored backup codes are unreadable"
 * should never leak to a probing attacker; that goes to logs only.
 */
const TOTP_PUBLIC_MESSAGES: Record<TwoFactorErrorCode, string> = {
  invalid_totp: "Invalid verification code",
  invalid_backup_code: "Invalid backup code",
  already_enabled: "Two-factor authentication is already enabled",
  not_enabled: "Two-factor authentication is not enabled",
  not_enrolled: "Two-factor authentication is not enrolled",
  no_pending_enrollment: "No pending two-factor enrolment",
  partial_auth_expired:
    "Two-factor login session expired. Please sign in again.",
  partial_auth_consumed:
    "Two-factor login session already used. Please sign in again.",
  too_many_attempts: "Too many failed attempts. Try again shortly.",
};

export class TotpServiceError extends Error {
  readonly code: TwoFactorErrorCode;
  readonly statusCode: number;

  constructor(
    message: string,
    options: { code: TwoFactorErrorCode; statusCode: number; cause?: unknown },
  ) {
    super(message, { cause: options.cause });
    this.name = "TotpServiceError";
    this.code = options.code;
    this.statusCode = options.statusCode;
  }

  /**
   * The string that's safe to return to API callers. Routes should prefer
   * this over `error.message`, which may carry operational detail intended
   * for logs only.
   */
  get publicMessage(): string {
    return TOTP_PUBLIC_MESSAGES[this.code];
  }
}

// Higher error correction makes the QR more robust to print/scan glitches
// at the cost of a few extra modules; "M" is the qrcode default but we
// pick it explicitly to be defensive against library default drift.
const QR_OPTIONS = {
  type: "svg" as const,
  errorCorrectionLevel: "M" as const,
  margin: 1,
};

/**
 * Starts enrolment for a user that has not yet confirmed TOTP. Generates a
 * fresh secret, stores it encrypted on the user row (leaving `totp_enabled`
 * false), and returns the secret + otpauth URL + inline SVG QR.
 *
 * Throws {@link TotpServiceError} with code `already_enabled` (HTTP 409)
 * if the caller already completed enrolment. Callers must disable first.
 */
export async function beginTotpEnrollment(
  userId: string,
): Promise<TwoFactorSetupResponse> {
  const user = await findUserWithTotpById(userId);
  if (!user) {
    // The session pointed at a userId that no longer exists. Treat as
    // unauthenticated by the time we get here — this should be exceedingly
    // rare; we still surface a 401-shaped error rather than 500.
    throw new TotpServiceError("User not found", {
      code: "not_enrolled",
      statusCode: 401,
    });
  }

  if (user.totpEnabled) {
    throw new TotpServiceError(
      "Two-factor authentication is already enabled. Disable it first.",
      { code: "already_enabled", statusCode: 409 },
    );
  }

  const { secret, otpAuthUrl } = generateTotpSecret(user.username);
  const stored = await setPendingTotpSecret(user.id, encryptSecret(secret));
  if (!stored) {
    // CAS failed: a concurrent /verify completed enrolment between our read
    // above and our write here. Treat as already_enabled; the caller can
    // disable and retry. Prevents handing out a QR for a secret the DB will
    // never accept.
    throw new TotpServiceError(
      "Two-factor authentication is already enabled. Disable it first.",
      { code: "already_enabled", statusCode: 409 },
    );
  }

  const qrCodeSvg = await qrCodeToString(otpAuthUrl, QR_OPTIONS);

  return { secret, otpAuthUrl, qrCodeSvg };
}

/**
 * Confirms a pending enrolment: validates the first TOTP code, generates
 * ten single-use backup codes, and flips `totp_enabled` on. Returns the
 * plaintext backup codes — the caller MUST surface them to the user
 * exactly once; only their hashes are persisted.
 *
 * Error mapping:
 * - 401 `not_enrolled` — session points at a non-existent user (shouldn't
 *   happen in practice, but we surface it explicitly).
 * - 409 `already_enabled` — verify called after enrolment is complete.
 * - 409 `no_pending_enrollment` — no `totp_secret` stored; caller must
 *   hit `/2fa/setup` first.
 * - 400 `invalid_totp` — code did not match (also covers replays of the
 *   most recently accepted code within the drift window).
 */
export async function completeTotpEnrollment(params: {
  userId: string;
  code: string;
}): Promise<TwoFactorVerifyResponse> {
  const user = await findUserWithTotpById(params.userId);
  if (!user) {
    throw new TotpServiceError("User not found", {
      code: "not_enrolled",
      statusCode: 401,
    });
  }

  if (user.totpEnabled) {
    throw new TotpServiceError(
      "Two-factor authentication is already enabled.",
      { code: "already_enabled", statusCode: 409 },
    );
  }

  if (!user.totpSecret) {
    throw new TotpServiceError(
      "No pending two-factor enrolment. Start setup first.",
      { code: "no_pending_enrollment", statusCode: 409 },
    );
  }

  // Replay check runs before HMAC verification so a leaked-and-reused code
  // is rejected with the same shape as a wrong code (no oracle for the
  // attacker about whether a code was ever valid).
  if (isTotpCodeReplayed(user.id, params.code)) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 400,
    });
  }

  let plaintextSecret: string;
  try {
    plaintextSecret = decryptSecret(user.totpSecret);
  } catch (cause) {
    // Corrupt or wrong-key ciphertext: surface as invalid_totp so we do
    // not leak details, but log via the thrown cause for operators.
    throw new TotpServiceError("Stored TOTP secret is unreadable", {
      code: "invalid_totp",
      statusCode: 400,
      cause,
    });
  }

  if (!verifyTotp(plaintextSecret, params.code)) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 400,
    });
  }

  // Generate + persist backup codes BEFORE returning so a transient DB
  // failure doesn't leave the user thinking 2FA is enabled while the DB
  // still says otherwise.
  const { plaintext, hashes } = await generateBackupCodes();
  const enrolledAt = new Date();
  const persisted = await persistEnrolledTotp({
    userId: user.id,
    encryptedBackupCodes: encryptSecret(JSON.stringify(hashes)),
    enrolledAt,
  });
  if (!persisted) {
    // CAS lost: a concurrent /verify (e.g., double-click in the UI) flipped
    // totp_enabled to true between our read and our write. The other request
    // already returned its own backup codes to a client. We must NOT return
    // OUR plaintext codes here — those codes do not exist in the DB, so the
    // user would be locked out of recovery. Fail closed.
    throw new TotpServiceError(
      "Two-factor authentication is already enabled.",
      { code: "already_enabled", statusCode: 409 },
    );
  }

  markTotpCodeUsed(user.id, params.code);

  return {
    enabled: true,
    enrolledAt: enrolledAt.toISOString(),
    backupCodes: plaintext,
  };
}

const TOTP_CODE_PATTERN = /^\d{6}$/;

/**
 * Turns 2FA off for an already-enrolled user. The caller must prove
 * possession of the second factor with either a current TOTP code or one of
 * their unused backup codes. On success the entire enrolment is cleared
 * (`totp_secret`, `totp_enabled`, `totp_backup_codes`, `totp_enrolled_at`)
 * — there's no point preserving the consumed backup code separately because
 * everything goes back to null.
 *
 * Error mapping:
 * - 401 `not_enrolled` — session points at a non-existent user row.
 * - 409 `not_enabled` — TOTP isn't currently turned on for this user.
 * - 400 `invalid_totp` — code didn't match TOTP or any stored backup,
 *   was a replay, or the stored ciphertext is unreadable. We return one
 *   shape regardless of which path failed so the response doesn't leak
 *   whether a TOTP or backup-code attempt was closer.
 */
export async function disableTotp(params: {
  userId: string;
  code: string;
}): Promise<TwoFactorDisableResponse> {
  const user = await findUserWithTotpById(params.userId);
  if (!user) {
    throw new TotpServiceError("User not found", {
      code: "not_enrolled",
      statusCode: 401,
    });
  }

  if (!user.totpEnabled) {
    throw new TotpServiceError(
      "Two-factor authentication is not enabled.",
      { code: "not_enabled", statusCode: 409 },
    );
  }

  if (isTotpCodeReplayed(user.id, params.code)) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 400,
    });
  }

  // Route by shape: a stripped 6-digit string is a TOTP attempt; anything
  // else falls through to backup-code verification (which is heavier — scrypt
  // per stored hash — so we avoid running it on TOTP-shaped input).
  const stripped = params.code.replace(/[\s-]+/g, "");
  let codeValid = false;

  if (TOTP_CODE_PATTERN.test(stripped) && user.totpSecret) {
    let plaintextSecret: string;
    try {
      plaintextSecret = decryptSecret(user.totpSecret);
    } catch (cause) {
      throw new TotpServiceError("Stored TOTP secret is unreadable", {
        code: "invalid_totp",
        statusCode: 400,
        cause,
      });
    }
    codeValid = verifyTotp(plaintextSecret, params.code);
  }

  if (!codeValid && user.totpBackupCodes) {
    let hashes: string[];
    try {
      const parsed: unknown = JSON.parse(decryptSecret(user.totpBackupCodes));
      if (
        !Array.isArray(parsed) ||
        !parsed.every((entry): entry is string => typeof entry === "string")
      ) {
        throw new Error("Backup-code blob is not a string array");
      }
      hashes = parsed;
    } catch (cause) {
      throw new TotpServiceError("Stored backup codes are unreadable", {
        code: "invalid_totp",
        statusCode: 400,
        cause,
      });
    }

    const result = await verifyBackupCode(hashes, params.code);
    if (result.matchedHash !== null) {
      codeValid = true;
    }
  }

  if (!codeValid) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 400,
    });
  }

  await persistClearedTotp(user.id);
  // Revoke every session for this user. Disabling 2FA is a credential-grade
  // change; the caller proved current possession via TOTP/backup, and we
  // force a fresh login on every device so an old captured cookie can't ride
  // the disable. The route is responsible for clearing the caller's cookie.
  await deleteSessionsForUser(user.id);
  markTotpCodeUsed(user.id, params.code);

  return { enabled: false };
}

export type CompleteTotpLoginResult = {
  sessionToken: string;
  sessionExpiresAt: Date;
  user: { id: string; username: string };
};

/**
 * Finishes a two-step login: exchanges a valid partial-auth token + valid
 * TOTP (or unused backup) code for a real session cookie. On success a
 * session row is written, a session token is returned for the caller to
 * set as a cookie, and a consumed backup code (if that path was taken) is
 * pruned from the encrypted blob.
 *
 * Error mapping is intentionally narrow: every failure mode the caller can
 * surface to a user is 401, so an attacker probing this endpoint can't tell
 * whether they have the wrong token, the wrong code, or a stale enrolment.
 *
 * - 401 `partial_auth_expired` — token missing, malformed, tampered with,
 *   or past its 5-minute lifetime. Client must re-enter the password.
 * - 401 `invalid_totp` — token was valid but the code didn't match TOTP
 *   or any stored backup hash, was a replay, the stored ciphertext is
 *   unreadable, the user row vanished, or TOTP was disabled between A7
 *   and A8 (don't expose the state — the client should re-login).
 */
const BACKUP_CODE_CAS_RETRIES = 3;

function decodeBackupHashes(encrypted: string): string[] | null {
  try {
    const parsed: unknown = JSON.parse(decryptSecret(encrypted));
    if (
      !Array.isArray(parsed) ||
      !parsed.every((entry): entry is string => typeof entry === "string")
    ) {
      return null;
    }
    return parsed;
  } catch {
    return null;
  }
}

/**
 * Removes `matchedHash` from the user's encrypted backup-codes blob using a
 * compare-and-swap UPDATE, retrying on contention. Returns true if a slot
 * was consumed; false means another concurrent login already burned this
 * same hash (so the caller must reject the attempt as invalid_totp).
 *
 * The CAS closes the race where two concurrent /login/totp calls with
 * different backup codes both read the same blob and write back interleaved
 * versions — without it the second writer would silently lose the first's
 * consumption and a single backup code could be used twice.
 */
async function consumeBackupCodeHash(
  userId: string,
  matchedHash: string,
  initialBlob: string,
): Promise<boolean> {
  let currentBlob = initialBlob;
  for (let attempt = 0; attempt < BACKUP_CODE_CAS_RETRIES; attempt++) {
    const hashes = decodeBackupHashes(currentBlob);
    if (hashes === null) return false;
    if (!hashes.includes(matchedHash)) {
      // Same backup code was just consumed by a concurrent login. We MUST
      // refuse — single-use means single-use.
      return false;
    }

    const remaining = hashes.filter((hash) => hash !== matchedHash);
    const ok = await updateTotpBackupCodes({
      userId,
      previousBackupCodes: currentBlob,
      nextBackupCodes: encryptSecret(JSON.stringify(remaining)),
    });
    if (ok) return true;

    // Lost the CAS to a different code's consumer. Reload and try once more.
    const fresh = await findUserWithTotpById(userId);
    if (!fresh || !fresh.totpEnabled || !fresh.totpBackupCodes) return false;
    currentBlob = fresh.totpBackupCodes;
  }
  return false;
}

/**
 * Internal core of {@link completeTotpLogin}. Encapsulated so the outer
 * function can wrap it in the brute-force + single-use guards and convert
 * every invalid_totp failure into a recorded attempt against the partial
 * token.
 */
async function attemptTotpLogin(params: {
  userId: string;
  code: string;
}): Promise<CompleteTotpLoginResult> {
  const user = await findUserWithTotpById(params.userId);
  if (!user || !user.totpEnabled || !user.totpSecret) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 401,
    });
  }

  if (isTotpCodeReplayed(user.id, params.code)) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 401,
    });
  }

  const stripped = params.code.replace(/[\s-]+/g, "");
  let codeValid = false;
  let consumedBackupHash: string | null = null;

  if (TOTP_CODE_PATTERN.test(stripped)) {
    let plaintextSecret: string;
    try {
      plaintextSecret = decryptSecret(user.totpSecret);
    } catch (cause) {
      throw new TotpServiceError("Stored TOTP secret is unreadable", {
        code: "invalid_totp",
        statusCode: 401,
        cause,
      });
    }
    codeValid = verifyTotp(plaintextSecret, params.code);
  }

  if (!codeValid && user.totpBackupCodes) {
    const hashes = decodeBackupHashes(user.totpBackupCodes);
    if (hashes === null) {
      throw new TotpServiceError("Stored backup codes are unreadable", {
        code: "invalid_totp",
        statusCode: 401,
      });
    }
    const result = await verifyBackupCode(hashes, params.code);
    if (result.matchedHash !== null) {
      codeValid = true;
      consumedBackupHash = result.matchedHash;
    }
  }

  if (!codeValid) {
    throw new TotpServiceError("Invalid verification code", {
      code: "invalid_totp",
      statusCode: 401,
    });
  }

  if (consumedBackupHash !== null && user.totpBackupCodes) {
    const consumed = await consumeBackupCodeHash(
      user.id,
      consumedBackupHash,
      user.totpBackupCodes,
    );
    if (!consumed) {
      throw new TotpServiceError("Invalid verification code", {
        code: "invalid_totp",
        statusCode: 401,
      });
    }
  }

  const sessionExpiresAt = new Date(
    Date.now() + serverEnv.AUTH_SESSION_HOURS * 60 * 60 * 1000,
  );
  const sessionId = randomUUID();
  await createSession({
    id: sessionId,
    userId: user.id,
    expiresAt: sessionExpiresAt,
  });

  const sessionToken = createSessionToken(
    sessionId,
    Math.floor(sessionExpiresAt.getTime() / 1000),
  );

  markTotpCodeUsed(user.id, params.code);

  return {
    sessionToken,
    sessionExpiresAt,
    user: { id: user.id, username: user.username },
  };
}

export async function completeTotpLogin(params: {
  partialAuthToken: string;
  code: string;
}): Promise<CompleteTotpLoginResult> {
  const verified = verifyPartialAuthToken(params.partialAuthToken);
  if (!verified) {
    throw new TotpServiceError("Partial-auth token is invalid or expired", {
      code: "partial_auth_expired",
      statusCode: 401,
    });
  }

  // Single-use: once a partial token has been redeemed for a real session,
  // refuse to redeem it again even if still cryptographically valid. Defends
  // against an attacker who captures the partial token within its 5-minute
  // TTL after the legitimate user has already completed login.
  if (isPartialAuthTokenConsumed(params.partialAuthToken)) {
    throw new TotpServiceError("Partial-auth token already used", {
      code: "partial_auth_consumed",
      statusCode: 401,
    });
  }

  // Brute-force gate: 5 wrong-code attempts against the same partial token
  // trip a 429 for the rest of the token's lifetime. We do NOT invalidate
  // the token (per the A8 spec — "allow retry until token expires"); we
  // throttle instead. Attempts that exceed the cap during the legitimate
  // user's window just force them to re-enter the password.
  if (isPartialAuthTokenBlocked(params.partialAuthToken)) {
    throw new TotpServiceError(
      "Too many failed two-factor attempts for this login session",
      { code: "too_many_attempts", statusCode: 429 },
    );
  }

  try {
    const result = await attemptTotpLogin({
      userId: verified.userId,
      code: params.code,
    });
    markPartialAuthTokenConsumed(
      params.partialAuthToken,
      verified.expiresAtEpochSeconds,
    );
    return result;
  } catch (error) {
    if (error instanceof TotpServiceError && error.code === "invalid_totp") {
      recordPartialAuthFailure(
        params.partialAuthToken,
        verified.expiresAtEpochSeconds,
      );
    }
    throw error;
  }
}
byte
19.727
baris
592
Jenis
ts
Commit terakhir
16 Oktober 2026