mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(security): alert on a new-device sign-in
On a completed sign-in (password, second-factor, or passkey) from a device or location not seen before, send one notification through the dispatcher cascade, honouring the user's per-channel preferences via a new SECURITY_ALERT event type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the coarse User-Agent family, and a country/ASN-level geo signal, stored in the new user_known_devices ledger (migration 0203). The first sighting of a fingerprint alerts and records; later sightings only bump last-seen and stay silent. The raw User-Agent and IP are never persisted — only the salted hash and a coarse label. Registration records its device silently so a fresh account does not alert on its own first session. The whole path is fire-and-forget and never blocks or fails a sign-in.
This commit is contained in:
1 parent
b882698a44
commit
bed6588699
10 files changed
+618
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RecordSignInResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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)
|
||||
|
||||
@@ -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<EventType, boolean> = {
|
||||
// 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<ChannelType, string> = {
|
||||
|
||||
Reference in new issue
Block a user