Vectrum
Sign In
181 lines · 6.8 KB · TypeScript
import "server-only";
import { cache } from "react";
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase";
import { client, db } from "./db";
import { SECURE_COOKIES } from "./request-origin";
import { hasTwoFactor, verifySecondFactor } from "./two-factor";
import { authenticateUser, toUser } from "./users";

// Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the
// users collection's token duration) and stop working when the password or email changes.

const SESSION_COOKIE = "session";
const SESSION_SECONDS = 30 * 86400;

async function setSessionCookie(token: string) {
  (await cookies()).set(SESSION_COOKIE, token, {
    httpOnly: true,
    sameSite: "lax",
    secure: SECURE_COOKIES,
    path: "/",
    expires: new Date(getTokenPayload(token).exp * 1000),
  });
}

/**
 * Checks a username or email and password. Starts a session, or with two-factor authentication on,
 * a sign-in challenge that `completeSignIn` finishes with a code. Returns null for a wrong password.
 */
export async function signIn(login: string, password: string) {
  const auth = await authenticateUser(login, password);
  if (!auth) return null;
  if (await hasTwoFactor(auth.user.id)) {
    await startChallenge(auth.user.id, auth.token);
    return { user: auth.user, needsCode: true };
  }
  await setSessionCookie(auth.token);
  return { user: auth.user, needsCode: false };
}

/*
 * Sign-in challenges: after the password, the session token waits on the server for a code, for 5
 * minutes and 5 tries. The browser holds a random id for it in a cookie. They live in memory, as
 * one Node.js process serves all requests (see withLock); a restart only means signing in again.
 * Wrong codes also count per user, so new challenges don't give more guesses: after 10 in 15
 * minutes, codes for that account are refused until the 15 minutes are over.
 */

const CHALLENGE_COOKIE = "signin_challenge";
const CHALLENGE_MS = 5 * 60_000;
const CHALLENGE_TRIES = 5;
const FAILURE_WINDOW_MS = 15 * 60_000;
const FAILURE_LIMIT = 10;

type Challenge = { userId: string; token: string; expires: number; tries: number };
const g = globalThis as unknown as {
  __signinChallenges?: Map<string, Challenge>;
  __signinFailures?: Map<string, { count: number; since: number }>;
};
const challenges = (g.__signinChallenges ??= new Map());
const failures = (g.__signinFailures ??= new Map());

const challengeKey = (id: string) => createHash("sha256").update(id).digest("hex");

async function startChallenge(userId: string, token: string) {
  const now = Date.now();
  for (const [key, c] of challenges) if (c.expires < now) challenges.delete(key);
  const id = randomBytes(32).toString("base64url");
  challenges.set(challengeKey(id), { userId, token, expires: now + CHALLENGE_MS, tries: 0 });
  (await cookies()).set(CHALLENGE_COOKIE, id, {
    httpOnly: true,
    sameSite: "lax",
    secure: SECURE_COOKIES,
    path: "/login",
    maxAge: CHALLENGE_MS / 1000,
  });
}

async function currentChallenge() {
  const id = (await cookies()).get(CHALLENGE_COOKIE)?.value;
  if (!id) return null;
  const key = challengeKey(id);
  const challenge = challenges.get(key);
  if (!challenge || challenge.expires < Date.now()) {
    challenges.delete(key);
    return null;
  }
  return { key, challenge };
}

/** Whether this browser has entered a password and still needs to enter a code. */
export async function hasSignInChallenge() {
  return !!(await currentChallenge());
}

/**
 * Checks a code from the authenticator app or a recovery code, counting wrong ones per user. The
 * mobile app's sign-in (lib/app-api.ts) uses this too.
 */
export async function checkSecondFactor(userId: string, code: string): Promise<"ok" | "invalid" | "locked"> {
  const now = Date.now();
  const failed = failures.get(userId);
  if (failed && now - failed.since > FAILURE_WINDOW_MS) failures.delete(userId);
  if ((failures.get(userId)?.count ?? 0) >= FAILURE_LIMIT) return "locked";
  if (!(await verifySecondFactor(userId, code))) {
    const f = failures.get(userId) ?? { count: 0, since: now };
    failures.set(userId, { ...f, count: f.count + 1 });
    return "invalid";
  }
  failures.delete(userId);
  return "ok";
}

/** Finishes signing in with a code from the authenticator app or a recovery code. */
export async function completeSignIn(code: string): Promise<"ok" | "invalid" | "expired" | "locked"> {
  const current = await currentChallenge();
  if (!current) return "expired";
  const { key, challenge } = current;
  const result = await checkSecondFactor(challenge.userId, code);
  if (result === "locked") return "locked";
  if (result === "invalid") {
    if (++challenge.tries >= CHALLENGE_TRIES) {
      challenges.delete(key);
      await endChallenge();
      return "expired";
    }
    return "invalid";
  }
  challenges.delete(key);
  await endChallenge();
  await setSessionCookie(challenge.token);
  return "ok";
}

async function endChallenge() {
  (await cookies()).delete({ name: CHALLENGE_COOKIE, path: "/login" });
}

/** A new session for a signed-in user whose old token stopped working (after an email change). */
export async function renewSession(userId: string) {
  const impersonated = await (await db()).collection("users").impersonate(userId, SESSION_SECONDS);
  await setSessionCookie(impersonated.authStore.token);
}

export async function endSession() {
  (await cookies()).delete(SESSION_COOKIE);
}

/** The signed-in user for this request, or null. */
export const getViewer = cache(async () => userForSession((await cookies()).get(SESSION_COOKIE)?.value));

/** The user a session token belongs to, or null if it's expired or no longer valid. The mobile app sends these as bearer tokens (lib/app-api.ts). */
export async function userForSession(token: string | undefined) {
  if (!token || isTokenExpired(token)) return null;
  const { id, type } = getTokenPayload(token);
  if (type !== "auth" || typeof id !== "string") return null;
  await db(); // makes sure PocketBase is running
  // Reading the record with the user's own token is what checks the token.
  const pb = client();
  pb.authStore.save(token);
  try {
    return toUser(await pb.collection("users").getOne(id));
  } catch (e) {
    if (e instanceof ClientResponseError && [401, 403, 404].includes(e.status)) return null;
    throw e;
  }
}

/** Returns the signed-in user, or redirects to the login page. */
export async function requireViewer(returnTo?: string) {
  const viewer = await getViewer();
  if (!viewer) redirect(returnTo ? `/login?next=${encodeURIComponent(returnTo)}` : "/login");
  return viewer;
}

/** Only allow same-site relative redirects after login. */
export function safeNext(next: unknown) {
  return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") ? next : "/";
}