mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(auth): record an identifier HMAC on failed-login audit rows
Failed-login audit rows currently carry `ipAddress` plus `reason` (`user_not_found_or_no_password` or `invalid_password`). The typed identifier is deliberately omitted (H-1 contract: PII never lands in operator artefacts) — but that leaves no anchor a future spray detector can group on. Recording `hashToken(identifier)` alongside the reason keeps the H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one- way; the raw identifier never serialises into the row) while giving any future "same identifier hit from many IPs" detector a correlation key. Test mock for `hashToken` now returns a deterministic hex string that does not echo the raw input — mirrors the real HMAC shape and keeps the existing "audit row must not contain the typed email" assertion passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e74eff2979
commit
1757b35327
2 files changed
+21
-4
No files matched your search
@@ -47,7 +47,14 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
// Return a deterministic hex string that does NOT echo the raw
|
||||
// value — mirrors the real HMAC-SHA256 shape. The audit row's
|
||||
// privacy contract is "the typed identifier never serialises into
|
||||
// the row's body", so the mock must not embed the raw input.
|
||||
hashToken: vi.fn(
|
||||
(raw: string) =>
|
||||
"aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -54,6 +55,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const identifier = email.trim();
|
||||
// HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`,
|
||||
// mirrors the `/api/auth/check-user` pattern. The raw identifier
|
||||
// stays out of the audit row (H-1 contract); the hash gives a
|
||||
// future spray-detector a forensic anchor it can correlate across
|
||||
// IPs without having to look up users by email.
|
||||
const identifierHash = hashToken(identifier);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -67,10 +74,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!user || !user.passwordHash) {
|
||||
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
|
||||
// the audit row — `reason` already tells the operator what
|
||||
// happened, and PII must not land in operator artefacts.
|
||||
// happened, and PII must not land in operator artefacts. The
|
||||
// HMAC anchor below is one-way — recoverable only with the HMAC
|
||||
// key (operator secret), used purely to group same-identifier
|
||||
// attempts across IPs.
|
||||
await auditLog("auth.login.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: "user_not_found_or_no_password" },
|
||||
details: { reason: "user_not_found_or_no_password", identifierHash },
|
||||
});
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
@@ -80,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await auditLog("auth.login.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { reason: "invalid_password" },
|
||||
details: { reason: "invalid_password", identifierHash },
|
||||
});
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user