41 lines · 2.0 KB
import { env } from "cloudflare:workers";
import { first } from "../db";
import { sha256Hex } from "../crypto";
import { emailTemplates, queueEmail } from "../email";
import { captureServerEvent } from "../analytics";

const DEVICE_TTL_SECONDS = 90 * 24 * 60 * 60;
const MAX_AGENT_LENGTH = 120;

function describeAgent(userAgent: string): string {
  const browser = /(Firefox|Edg|Chrome|Safari)\/[\d.]+/.exec(userAgent)?.[1]?.replace("Edg", "Edge");
  const os = /(Windows|Mac OS X|Android|iPhone|iPad|Linux)/.exec(userAgent)?.[1]?.replace("Mac OS X", "macOS");
  if (browser || os) return [browser ?? "a browser", os ? `on ${os}` : ""].filter(Boolean).join(" ");
  return userAgent ? userAgent.slice(0, MAX_AGENT_LENGTH) : "an unknown client";
}

/**
 * Remembers the (IP, browser family and OS) pair per user, so browser updates don't count as new; a sign-in from an unseen pair emails a security notice,
 * except for the very first sign-in on the account. Failures never block sign-in.
 */
export async function noteSignIn(userId: string, ip: string, userAgent: string): Promise<void> {
  captureServerEvent("user_signed_in", userId);
  try {
    const prefix = `signin-device:${userId}:`;
    const key = `${prefix}${await sha256Hex(`${ip}\n${describeAgent(userAgent)}`)}`;
    if (await env.KV.get(key)) return;
    const known = await env.KV.list({ prefix, limit: 1 });
    await env.KV.put(key, "1", { expirationTtl: DEVICE_TTL_SECONDS });
    if (known.keys.length === 0) return;
    const user = await first<{ email: string; email_verified_at: number | null; name: string }>(
      "SELECT u.email, u.email_verified_at, a.name FROM users u JOIN accounts a ON a.id = u.account_id WHERE u.account_id = ?",
      userId,
    );
    if (!user?.email_verified_at) return;
    const event = `there was a new sign-in to your account from ${describeAgent(userAgent)} (IP ${ip})`;
    await queueEmail(user.email, emailTemplates.securityNotice(user.name, event));
  } catch (error) {
    console.error("Failed to record sign-in", error);
  }
}