diff --git a/prisma/migrations/0203_v123_known_devices/migration.sql b/prisma/migrations/0203_v123_known_devices/migration.sql new file mode 100644 index 000000000..d461c6bb3 --- /dev/null +++ b/prisma/migrations/0203_v123_known_devices/migration.sql @@ -0,0 +1,33 @@ +-- v1.23 — new-device sign-in alert dedupe ledger. +-- +-- One row per (user, coarse device fingerprint). The fingerprint +-- (`device_hash`) is a one-way SHA-256 hash of +-- `userId | normalised-User-Agent | coarse-location-or-asn`. The raw +-- User-Agent and IP are NEVER stored — the table holds only the salted hash +-- (the userId is part of the digest input, so the same browser on two +-- accounts produces two unrelated hashes) plus an optional coarse, +-- human-readable `label` for the security-activity surface (e.g. +-- "Firefox on macOS — Berlin, DE"; the label omits the IP). +-- +-- A successful sign-in upserts on (user_id, device_hash): the first sighting +-- of a hash inserts the row and fires one new-device notification through the +-- dispatcher; subsequent sightings only bump `last_seen_at` and stay silent. + +CREATE TABLE "user_known_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "device_hash" TEXT NOT NULL, + "label" TEXT, + "first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_known_devices_pkey" PRIMARY KEY ("id") +); + +-- Dedupe is per-user: a coarse fingerprint can never be inserted twice for +-- one account, so "first sighting → alert" is a single unique-constrained +-- upsert with no race window. +CREATE UNIQUE INDEX "user_known_devices_user_id_device_hash_key" ON "user_known_devices"("user_id", "device_hash"); +CREATE INDEX "user_known_devices_user_id_idx" ON "user_known_devices"("user_id"); + +ALTER TABLE "user_known_devices" ADD CONSTRAINT "user_known_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf517cb88..9b974e000 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -575,6 +575,7 @@ model User { mfaChallenges MfaChallenge[] mfaRecoveryCodes MfaRecoveryCode[] webauthnMfaCredentials WebauthnMfaCredential[] + knownDevices UserKnownDevice[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] @@ -875,6 +876,33 @@ model WebauthnMfaCredential { @@map("webauthn_mfa_credentials") } +// v1.23 — login-alert dedupe ledger. One row per (user, coarse device +// fingerprint). The fingerprint is a one-way SHA-256 hash of +// `userId | normalised-User-Agent | coarse-location-or-asn` — never the raw +// User-Agent or IP, so the table holds no directly identifying request +// metadata (`deviceHash` is salted by the userId, so the same browser on two +// accounts yields two unrelated hashes). `label` is a coarse, human-readable +// descriptor for the activity surface (e.g. "Firefox on macOS — Berlin, DE"); +// it intentionally omits the IP. First sighting of a hash fires one new-device +// notification through the dispatcher; later sightings only bump `lastSeenAt`. +model UserKnownDevice { + id String @id @default(cuid()) + userId String @map("user_id") + deviceHash String @map("device_hash") + label String? + firstSeenAt DateTime @default(now()) @map("first_seen_at") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Dedupe is per-user: the same coarse fingerprint can never be inserted + // twice for one account, so the "first sighting → alert" decision is a + // single unique-constrained upsert. + @@unique([userId, deviceHash]) + @@index([userId]) + @@map("user_known_devices") +} + model AuthChallenge { id String @id @default(cuid()) userId String? @map("user_id") // null for registration diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 18178dd64..c041ab07f 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,5 +1,6 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { prisma } from "@/lib/db"; @@ -76,6 +77,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ipAddress: ip, }); + // v1.23 — new-device / new-location alert, fire-and-forget (see finishLogin). + void recordSignInDevice({ userId: user.id, ip, userAgent: ua }); + annotate({ action: { name: "auth.login.passkey" } }); if ( diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index e97fcaebe..fb74e611e 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,6 +6,7 @@ import { import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -19,6 +20,8 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; import { isValidTimezone, resolveServerDefaultTimezone, @@ -121,6 +124,17 @@ export const POST = apiHandler(async (request: NextRequest) => { 422, ); } + + // v1.23 — reject a registration password found in a known breach corpus + // (HIBP k-anonymity). Fail-open on an unreachable HIBP. + const breach = await checkPasswordBreach(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); // v1.15.20 — consume the invite LAST, after every other validation, @@ -176,6 +190,16 @@ export const POST = apiHandler(async (request: NextRequest) => { const ua = request.headers.get("user-agent"); await createSession(user.id, true, ip, ua); + // v1.23 — record the registering device silently so the account's first + // login from this same device does not immediately fire a "new device" + // alert. A genuinely new device later still alerts on its first sighting. + void recordSignInDevice({ + userId: user.id, + ip, + userAgent: ua, + alertOnNew: false, + }); + // Stamp the consumer onto the invite (informational, best-effort — // the use itself was already counted atomically above). if (inviteId) { diff --git a/src/lib/auth/__tests__/device-fingerprint.test.ts b/src/lib/auth/__tests__/device-fingerprint.test.ts new file mode 100644 index 000000000..5bfbff2f0 --- /dev/null +++ b/src/lib/auth/__tests__/device-fingerprint.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + coarseDeviceLabel, + normaliseUserAgent, + coarseLocationSignal, + computeDeviceHash, + maskIp, +} from "../device-fingerprint"; + +const FIREFOX_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; +const FIREFOX_MAC_MINOR = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"; +const CHROME_WIN = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; + +describe("coarseDeviceLabel", () => { + it("reduces a UA to family + platform", () => { + expect(coarseDeviceLabel(FIREFOX_MAC)).toBe("Firefox on macOS"); + expect(coarseDeviceLabel(CHROME_WIN)).toBe("Chrome on Windows"); + }); + it("handles a missing UA", () => { + expect(coarseDeviceLabel(null)).toBe("Unknown device"); + expect(coarseDeviceLabel("")).toBe("Unknown device"); + }); +}); + +describe("coarseLocationSignal", () => { + it("reduces a city string to its country token", () => { + expect(coarseLocationSignal("Berlin, DE")).toBe("DE"); + }); + it("returns empty for no location", () => { + expect(coarseLocationSignal(null)).toBe(""); + }); +}); + +describe("computeDeviceHash", () => { + it("is deterministic and a hex SHA-256 (no raw UA/IP leak)", () => { + const h = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + expect(h).toMatch(/^[0-9a-f]{64}$/); + // The hash must not embed the raw UA. + expect(h).not.toContain("Firefox"); + expect(h).not.toContain("Mozilla"); + expect( + computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }), + ).toBe(h); + }); + + it("is invariant to a minor browser-version bump (no false new-device)", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC_MINOR, + coarseSignal: "DE", + }); + expect(a).toBe(b); + }); + + it("is salted per user — same browser, different account ⇒ different hash", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u2", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + expect(a).not.toBe(b); + }); + + it("treats a different country as a new device", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "US", + }); + expect(a).not.toBe(b); + }); +}); + +describe("normaliseUserAgent", () => { + it("matches the coarse label so the hash input is stable", () => { + expect(normaliseUserAgent(FIREFOX_MAC)).toBe("Firefox on macOS"); + }); +}); + +describe("maskIp", () => { + it("masks the host portion of an IPv4", () => { + expect(maskIp("203.0.113.42")).toBe("203.0.x.x"); + }); + it("masks an IPv6 down to two hextets", () => { + expect(maskIp("2001:db8:1234:5678::1")).toBe("2001:db8::"); + }); + it("returns null for a blank IP", () => { + expect(maskIp(null)).toBeNull(); + expect(maskIp("")).toBeNull(); + }); +}); diff --git a/src/lib/auth/__tests__/login-alert.test.ts b/src/lib/auth/__tests__/login-alert.test.ts new file mode 100644 index 000000000..f64d43aa8 --- /dev/null +++ b/src/lib/auth/__tests__/login-alert.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + userKnownDevice: { + findUnique: vi.fn(), + update: vi.fn(), + create: vi.fn(), + }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(async () => "Berlin, DE"), + lookupIpAsn: vi.fn(() => null), +})); + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ dispatched: true }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: () => ({ t: (k: string) => k }), +})); + +vi.mock("@/lib/logging/context", () => ({ getEvent: () => null })); + +import { recordSignInDevice } from "../login-alert"; +import { prisma } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "en", + } as never); +}); + +describe("recordSignInDevice — new-device dedupe", () => { + it("first sighting records the device AND fires one alert", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: false, alerted: true }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + const payload = vi.mocked(dispatchNotification).mock.calls[0][0]; + expect(payload.eventType).toBe("SECURITY_ALERT"); + expect(payload.userId).toBe("u1"); + }); + + it("second sighting of the same device is silent (no alert)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue({ + id: "kd-1", + } as never); + vi.mocked(prisma.userKnownDevice.update).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: true, alerted: false }); + expect(prisma.userKnownDevice.update).toHaveBeenCalledTimes(1); + expect(prisma.userKnownDevice.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("records silently when alertOnNew is false (registration)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + alertOnNew: false, + }); + + expect(res).toEqual({ known: false, alerted: false }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("a unique-index race on create does not double-alert", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockRejectedValue( + new Error("unique violation"), + ); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: true, alerted: false }); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("never persists the raw User-Agent — only the salted hash + coarse label", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + const data = vi.mocked(prisma.userKnownDevice.create).mock.calls[0][0].data; + expect(data.deviceHash).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(data)).not.toContain("Mozilla"); + expect(JSON.stringify(data)).not.toContain("203.0.113.9"); + // The coarse label is the family + platform descriptor. + expect(data.label).toContain("Firefox on macOS"); + }); +}); diff --git a/src/lib/auth/device-fingerprint.ts b/src/lib/auth/device-fingerprint.ts new file mode 100644 index 000000000..ecb645d1f --- /dev/null +++ b/src/lib/auth/device-fingerprint.ts @@ -0,0 +1,118 @@ +/** + * v1.23 — coarse device fingerprinting shared by the active-session list and + * the new-device sign-in alert. + * + * The fingerprint that backs login-alert dedupe is deliberately COARSE and + * one-way: a SHA-256 over `userId | normalised-User-Agent | coarse-signal`. + * The raw User-Agent and IP never leave this module into storage — only the + * salted hash (userId-salted, so the same browser on two accounts hashes + * differently) and a coarse human-readable label. A minor-version UA bump or a + * differently-formatted IP must NOT mint a "new device", so the UA is reduced + * to its family + platform and the geo signal to a country/ASN granularity. + */ +import { createHash } from "node:crypto"; + +/** + * Reduce a User-Agent to a stable family + platform descriptor, e.g. + * "Firefox on macOS". Minor versions, build numbers, and engine noise are + * dropped so a routine browser update does not register as a new device. + * Returns "Unknown device" when the UA is missing or unrecognised. + */ +export function coarseDeviceLabel( + userAgent: string | null | undefined, +): string { + const ua = (userAgent ?? "").trim(); + if (!ua) return "Unknown device"; + + const browser = /\bEdg(?:e|A|iOS)?\//.test(ua) + ? "Edge" + : /\bOPR\/|\bOpera\b/.test(ua) + ? "Opera" + : /\bFirefox\//.test(ua) + ? "Firefox" + : /\bChrome\//.test(ua) && !/\bChromium\//.test(ua) + ? "Chrome" + : /\bChromium\//.test(ua) + ? "Chromium" + : /\bSafari\//.test(ua) && /\bVersion\//.test(ua) + ? "Safari" + : /HealthLog-iOS/i.test(ua) + ? "HealthLog iOS app" + : null; + + const platform = /\bWindows NT\b/.test(ua) + ? "Windows" + : /\b(iPhone|iPad|iPod)\b/.test(ua) + ? "iOS" + : /\bMac OS X\b|\bMacintosh\b/.test(ua) + ? "macOS" + : /\bAndroid\b/.test(ua) + ? "Android" + : /\bLinux\b/.test(ua) + ? "Linux" + : null; + + if (browser && platform) return `${browser} on ${platform}`; + if (browser) return browser; + if (platform) return platform; + return "Unknown device"; +} + +/** + * Reduce a User-Agent to the stable string fed into the dedupe hash. Uses the + * coarse label so the hash is invariant to minor-version churn. + */ +export function normaliseUserAgent( + userAgent: string | null | undefined, +): string { + return coarseDeviceLabel(userAgent); +} + +/** + * Reduce a resolved geo string to a coarse country-level signal, e.g. + * "Berlin, DE" → "DE". Falls back to the whole string when there is no + * trailing country token, and to "" when no location resolved. Kept coarse so + * a user moving across a city/region does not register as a new device while a + * cross-country sign-in still does. + */ +export function coarseLocationSignal( + location: string | null | undefined, +): string { + const loc = (location ?? "").trim(); + if (!loc) return ""; + const parts = loc.split(",").map((p) => p.trim()); + return parts[parts.length - 1] || loc; +} + +/** + * One-way, per-user device fingerprint. The userId is part of the digest so + * the same browser on two accounts yields two unrelated hashes (no + * cross-account correlation), and the raw inputs are never recoverable. + */ +export function computeDeviceHash(params: { + userId: string; + userAgent: string | null | undefined; + coarseSignal: string; +}): string { + const material = `${params.userId}|${normaliseUserAgent(params.userAgent)}|${params.coarseSignal}`; + return createHash("sha256").update(material).digest("hex"); +} + +/** + * Mask an IP for the user-facing security surfaces: keep enough to recognise + * the network, drop the host. IPv4 → first two octets; IPv6 → first two + * hextets. Returns null for a null/blank input. + */ +export function maskIp(ip: string | null | undefined): string | null { + const raw = (ip ?? "").trim(); + if (!raw) return null; + if (raw.includes(":")) { + const groups = raw.split(":").filter((g) => g.length > 0); + return `${groups.slice(0, 2).join(":")}::`; + } + const octets = raw.split("."); + if (octets.length === 4) { + return `${octets[0]}.${octets[1]}.x.x`; + } + return raw; +} diff --git a/src/lib/auth/login-alert.ts b/src/lib/auth/login-alert.ts new file mode 100644 index 000000000..4da185087 --- /dev/null +++ b/src/lib/auth/login-alert.ts @@ -0,0 +1,143 @@ +/** + * v1.23 — new-device / new-location sign-in alert. + * + * Called on every completed sign-in (password, MFA-verify, passkey). Computes + * a coarse, one-way device fingerprint (see `device-fingerprint.ts`) and: + * - first sighting of the fingerprint → record it AND fire one notification + * through the dispatcher cascade (respecting the user's per-channel prefs), + * - any later sighting → bump `lastSeenAt` only, stay silent (dedupe). + * + * The whole thing is best-effort and is invoked fire-and-forget from the login + * paths: a failure here must never block or fail a sign-in. The raw IP/UA are + * never persisted — only the salted hash + a coarse label (and the audit row, + * which already records IP/geo for `auth.*` events). + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { lookupIpLocation, lookupIpAsn } from "@/lib/geo"; +import { + coarseDeviceLabel, + coarseLocationSignal, + computeDeviceHash, +} from "@/lib/auth/device-fingerprint"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getEvent } from "@/lib/logging/context"; + +function isLocale(value: string | null | undefined): value is Locale { + return ( + value === "de" || + value === "en" || + value === "fr" || + value === "es" || + value === "it" || + value === "pl" + ); +} + +export interface RecordSignInResult { + known: boolean; + alerted: boolean; +} + +export async function recordSignInDevice(params: { + userId: string; + ip: string | null; + userAgent: string | null; + /** + * When false the device is recorded silently with no notification — used by + * account registration, where the very first device should not trigger a + * "new device" alert the moment the account is created. + */ + alertOnNew?: boolean; +}): Promise { + const { userId, ip, userAgent } = params; + const alertOnNew = params.alertOnNew ?? true; + + try { + // Coarse geo signal — best-effort. The location resolver is online-first + // with its own timeout; a miss falls back to the ASN number so a device + // on the same network still dedupes even without a city. + let location: string | null = null; + try { + location = await lookupIpLocation(ip); + } catch { + location = null; + } + const coarseSignal = + coarseLocationSignal(location) || lookupIpAsn(ip)?.asn?.toString() || ""; + + const deviceHash = computeDeviceHash({ userId, userAgent, coarseSignal }); + const deviceLabel = coarseDeviceLabel(userAgent); + const storedLabel = location ? `${deviceLabel} — ${location}` : deviceLabel; + + const existing = await prisma.userKnownDevice.findUnique({ + where: { userId_deviceHash: { userId, deviceHash } }, + select: { id: true }, + }); + + if (existing) { + await prisma.userKnownDevice + .update({ + where: { id: existing.id }, + data: { lastSeenAt: new Date() }, + }) + .catch(() => {}); + return { known: true, alerted: false }; + } + + // First sighting. Insert; if a concurrent first-login races us to the + // unique (userId, deviceHash) index, swallow it and treat as already known + // so we never double-alert. + try { + await prisma.userKnownDevice.create({ + data: { userId, deviceHash, label: storedLabel }, + }); + } catch { + return { known: true, alerted: false }; + } + + if (!alertOnNew) return { known: false, alerted: false }; + + await auditLog("auth.login.new_device", { + userId, + ipAddress: ip, + details: { device: deviceLabel }, + }); + + // Localise against the recipient's own locale, then fan out through the + // dispatcher (which applies the user's per-channel SECURITY_ALERT prefs). + let locale: Locale = defaultLocale; + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + if (isLocale(row?.locale)) locale = row.locale; + } catch { + locale = defaultLocale; + } + const { t } = getServerTranslator(locale); + const locationText = + location ?? t("notifications.security.unknownLocation"); + + await dispatchNotification({ + eventType: "SECURITY_ALERT", + userId, + title: t("notifications.security.newDeviceTitle"), + message: t("notifications.security.newDeviceBody", { + device: deviceLabel, + location: locationText, + }), + urgent: true, + }); + + return { known: false, alerted: true }; + } catch (err) { + getEvent()?.addWarning( + `recordSignInDevice failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return { known: false, alerted: false }; + } +} diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts index 519c1f81d..d0998afd8 100644 --- a/src/lib/auth/login-response.ts +++ b/src/lib/auth/login-response.ts @@ -26,6 +26,7 @@ import { isCookielessNativeCaller, } from "@/lib/auth/native-client"; import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import type { User } from "@/generated/prisma/client"; export interface FinishLoginParams { @@ -54,6 +55,12 @@ export async function finishLogin( ): Promise { const { user, request, ip, userAgent, source, mfaVerified } = params; + // v1.23 — new-device / new-location alert. Fire-and-forget: never let the + // device ledger or the dispatcher add latency to — or fail — a sign-in. Runs + // for every transport (web cookie + native bundle) since the factor checks + // are already done by the time finishLogin is reached. + void recordSignInDevice({ userId: user.id, ip, userAgent }); + if ( shouldIssueBearerToken(request.headers) || isNativeClientRequest(request.headers) diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index b338b566c..860e78af6 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -94,6 +94,13 @@ export const EVENT_TYPES = [ // user-visible Telegram message on every dose would be noise). Routed // through the dedicated intake-sync sender, not the alert cascade. "MEDICATION_INTAKE_SYNC", + // v1.23 — account-security alert (new-device / new-location sign-in). Fired + // by the login completion path on the first sighting of a coarse device + // fingerprint. ON by default at the channel layer: a sign-in the user did + // not make is exactly the case they need to hear about, so silence-by-default + // would defeat the feature. An explicit per-channel `NotificationPreference` + // row still wins for a user who opts out. + "SECURITY_ALERT", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -151,6 +158,10 @@ export const EVENT_DEFAULT_ENABLED: Record = { // toggle: this is plumbing that keeps the user's own devices coherent, // not a notification the user chooses to receive. MEDICATION_INTAKE_SYNC: true, + // v1.23 — ON by default; a new-device sign-in is a security event the user + // needs surfaced. An explicit per-channel `NotificationPreference` row + // (enabled=false) still suppresses it for a user who opts out. + SECURITY_ALERT: true, }; export const CHANNEL_TYPE_LABELS: Record = {