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:
Marc-André Bombeck committed 2026-06-27 20:34:31 +02:00
1 parent b882698a44
commit bed6588699
10 files changed
+618

No files matched your search

@@ -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 (
+24
View File
@@ -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) {