fix(auth): backfill HMAC phone/email hashes so login works against the live DB
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 41s
Deploy / Build API Image (push) Failing after 6s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 49s
Security Scanning / Trivy Scan — Web Image (push) Failing after 31s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 33s
Security Scanning / Trivy Filesystem Scan (push) Failing after 32s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Production DB had 11 User rows with `phoneHash` / `emailHash` either
NULL (legacy seed before the privacy hashing layer) or filled with the
wrong format (an earlier short-circuit used plain SHA-256). Either way,
`PrismaUserRepository.findByPhone` calls
`fieldEncryption.computeHash(phone)` and looks up `phoneHash` —
returning null and surfacing "Số điện thoại hoặc mật khẩu không đúng"
even when the password is correct.

Two fixes:

1. `scripts/backfill-user-pii-hashes.ts` — re-run-safe one-shot:
   - Reads `FIELD_ENCRYPTION_KEY` (or `KYC_ENCRYPTION_KEY`),
   - Derives the same HMAC key the runtime uses (HKDF-SHA256 with the
     "goodgo-field-hash" info string),
   - Recomputes `phoneHash` + `emailHash` for every User and writes
     them back if they differ from the stored value.
   Verified: after run, login of seed-admin-001, seed-agent-001,
   seed-buyer-001 and seed-developer-001 all succeed against
   api.goodgo.vn with the seed default password.

2. `prisma/seed.ts` — `seedUsers()` now computes the HMAC hashes on
   create AND update (idempotent), so future `pnpm db:seed` runs
   produce rows that work with the runtime auth flow out of the box.
   When `FIELD_ENCRYPTION_KEY` isn't set (dev mode without encryption),
   the hash is `null` and the repository falls back to the plaintext
   `phone` / `email` query — preserving local-dev behaviour.

Default seed password remains `Velik@2026`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-30 14:26:26 +07:00
parent 416d1a5959
commit cec643ce5f
2 changed files with 152 additions and 1 deletions

View File

@@ -8,6 +8,7 @@
* Phone: 0876677771 | Email: hongochai10@icloud.com | Password: Velik@2026
*/
import * as crypto from 'node:crypto';
import { PrismaPg } from '@prisma/adapter-pg';
import {
PrismaClient,
@@ -64,6 +65,35 @@ const oneYearLater = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
// Phase 2 — Users & Identity
// =============================================================================
/**
* Compute the same `phoneHash` / `emailHash` that the runtime
* `FieldEncryptionService` writes — HMAC-SHA256 over the lowercased,
* trimmed value, keyed by HKDF(FIELD_ENCRYPTION_KEY, "goodgo-field-hash").
* If neither key is set, returns `null` (matches dev-mode behaviour
* where lookups fall back to plaintext `phone` / `email`).
*/
function buildFieldHasher(): (value: string | null | undefined) => string | null {
const primaryKey =
process.env['FIELD_ENCRYPTION_KEY'] ?? process.env['KYC_ENCRYPTION_KEY'];
if (!primaryKey) return () => null;
const hmacKey = crypto.hkdfSync(
'sha256',
Buffer.from(primaryKey, 'hex'),
Buffer.alloc(0),
Buffer.from('goodgo-field-hash', 'utf8'),
32,
) as unknown as Buffer;
return (value) => {
if (!value) return null;
return crypto
.createHmac('sha256', hmacKey)
.update(value.toLowerCase().trim())
.digest('hex');
};
}
const computeFieldHash = buildFieldHasher();
async function seedUsers(passwordHash: string) {
console.log('🔐 Seeding users...');
@@ -79,11 +109,24 @@ async function seedUsers(passwordHash: string) {
];
for (const u of users) {
const phoneHash = computeFieldHash(u.phone);
const emailHash = computeFieldHash(u.email);
await prisma.user.upsert({
where: { id: u.id },
update: { passwordHash, email: u.email, fullName: u.fullName, role: u.role, kycStatus: u.kycStatus },
update: {
passwordHash,
email: u.email,
fullName: u.fullName,
role: u.role,
kycStatus: u.kycStatus,
// Keep search-by-phone/email working when re-running seed against
// a freshly migrated DB or after a key rotation.
phoneHash,
emailHash,
},
create: {
id: u.id, phone: u.phone, email: u.email, passwordHash,
phoneHash, emailHash,
fullName: u.fullName, role: u.role, kycStatus: u.kycStatus,
avatarUrl: u.avatarUrl, isActive: true, totpEnabled: false, totpBackupCodes: [],
},