mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(security): keep the first sign-in from alerting on a fresh account
Record the very first device a user is ever seen on silently. The new-device sign-in alert only carries meaning once there is an established device to contrast against, so an empty device ledger establishes the baseline without firing a notification; alerts begin with the second distinct device. Registration's silent first-device behaviour is unchanged. Also extend the destructive-action step-up to treat a registered security key as an active second factor, matching requireFreshMfa, so a key-only account cannot delete or reset without a fresh factor.
This commit is contained in:
1 parent
b271a39fe4
commit
45a49f3d6d
3 files changed
+47
-1
No files matched your search
@@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
session: { findUnique: vi.fn() },
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -57,6 +58,9 @@ beforeEach(() => {
|
||||
// resetAllMocks wipes the factory mockResolvedValue; auditLog is awaited
|
||||
// via `.catch(...)` in the Bearer path, so it must return a thenable.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Default: no registered security key, so a TOTP-less account is genuinely
|
||||
// unenrolled and passes straight through.
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
function mockCookieSession(user: unknown) {
|
||||
@@ -74,6 +78,17 @@ describe("requireFreshMfaIfEnrolled", () => {
|
||||
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gates a security-key-only user (no TOTP) on step-up too", async () => {
|
||||
mockCookieSession(PLAIN_USER);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
await expect(
|
||||
requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS),
|
||||
).rejects.toBeInstanceOf(StepUpRequiredError);
|
||||
});
|
||||
|
||||
it("passes an MFA user with a fresh verification", async () => {
|
||||
mockCookieSession(MFA_USER);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
@@ -42,6 +43,9 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
locale: "en",
|
||||
} as never);
|
||||
// Default: the user already has at least one established device, so a new
|
||||
// sighting is a genuine second-or-later device and may alert.
|
||||
vi.mocked(prisma.userKnownDevice.count).mockResolvedValue(1 as never);
|
||||
});
|
||||
|
||||
describe("recordSignInDevice — new-device dedupe", () => {
|
||||
@@ -63,6 +67,22 @@ describe("recordSignInDevice — new-device dedupe", () => {
|
||||
expect(payload.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("first-ever device on an empty ledger records silently (baseline)", async () => {
|
||||
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.userKnownDevice.count).mockResolvedValue(0 as never);
|
||||
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: false });
|
||||
expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("second sighting of the same device is silent (no alert)", async () => {
|
||||
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue({
|
||||
id: "kd-1",
|
||||
|
||||
@@ -87,6 +87,17 @@ export async function recordSignInDevice(params: {
|
||||
return { known: true, alerted: false };
|
||||
}
|
||||
|
||||
// First sighting of THIS device. If the user has no known devices at all
|
||||
// the ledger is empty — this is the account's baseline (the first login
|
||||
// after the feature shipped, or an account whose registration predates the
|
||||
// ledger). Establish it silently: a "new device" alert only means anything
|
||||
// once there is at least one established device to contrast against, so we
|
||||
// never alert on the very first device a user is ever seen on.
|
||||
const knownDeviceCount = await prisma.userKnownDevice.count({
|
||||
where: { userId },
|
||||
});
|
||||
const isBaseline = knownDeviceCount === 0;
|
||||
|
||||
// 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.
|
||||
@@ -98,7 +109,7 @@ export async function recordSignInDevice(params: {
|
||||
return { known: true, alerted: false };
|
||||
}
|
||||
|
||||
if (!alertOnNew) return { known: false, alerted: false };
|
||||
if (!alertOnNew || isBaseline) return { known: false, alerted: false };
|
||||
|
||||
await auditLog("auth.login.new_device", {
|
||||
userId,
|
||||
|
||||
Reference in new issue
Block a user