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
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:
@@ -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: [],
|
||||
},
|
||||
|
||||
108
scripts/backfill-user-pii-hashes.ts
Normal file
108
scripts/backfill-user-pii-hashes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* One-shot backfill: compute `phoneHash` + `emailHash` for User rows
|
||||
* that have phone/email but no hash (legacy seed data from before the
|
||||
* privacy hashing layer was introduced). Without hash, the auth flow
|
||||
* `findUnique({ phoneHash })` returns null → "Số điện thoại hoặc mật
|
||||
* khẩu không đúng" even when password is correct.
|
||||
*
|
||||
* Usage:
|
||||
* NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \
|
||||
* FIELD_ENCRYPTION_KEY=<hex-key-matching-cluster-secret> \
|
||||
* pnpm tsx scripts/backfill-user-pii-hashes.ts [--dry-run]
|
||||
*
|
||||
* Hash format MUST match `FieldEncryptionService.computeHash`:
|
||||
* hmacKey = HKDF-SHA256(primaryKey, "", "goodgo-field-hash", 32)
|
||||
* phoneHash = HMAC-SHA256(hmacKey, value.toLowerCase().trim())
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import pg from 'pg';
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
const primaryKey =
|
||||
process.env['FIELD_ENCRYPTION_KEY'] ?? process.env['KYC_ENCRYPTION_KEY'];
|
||||
if (!primaryKey) {
|
||||
console.error(
|
||||
'✗ FIELD_ENCRYPTION_KEY (or KYC_ENCRYPTION_KEY) must be set so the hash matches the cluster API.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hmacKey = crypto.hkdfSync(
|
||||
'sha256',
|
||||
Buffer.from(primaryKey, 'hex'),
|
||||
Buffer.alloc(0),
|
||||
Buffer.from('goodgo-field-hash', 'utf8'),
|
||||
32,
|
||||
) as unknown as Buffer;
|
||||
|
||||
function hash(value: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', hmacKey)
|
||||
.update(value.toLowerCase().trim())
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Re-hash ALL users — earlier backfill used plain SHA-256 instead of
|
||||
// HMAC-SHA256, so even rows that already have a hash need to be
|
||||
// recomputed before they match the runtime API's lookup.
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, phone: true, email: true, phoneHash: true, emailHash: true },
|
||||
});
|
||||
|
||||
console.log(`🔍 Found ${users.length} users missing phoneHash or emailHash:`);
|
||||
for (const u of users) {
|
||||
console.log(
|
||||
` ${u.id} phone=${u.phone} email=${u.email ?? '—'} phoneHash=${
|
||||
u.phoneHash ? 'set' : 'MISSING'
|
||||
} emailHash=${u.emailHash ? 'set' : 'MISSING'}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('💡 --dry-run: no writes performed.');
|
||||
return;
|
||||
}
|
||||
if (users.length === 0) {
|
||||
console.log('✓ Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const u of users) {
|
||||
const data: { phoneHash?: string; emailHash?: string | null } = {};
|
||||
if (u.phone) {
|
||||
const newPhoneHash = hash(u.phone);
|
||||
if (newPhoneHash !== u.phoneHash) data.phoneHash = newPhoneHash;
|
||||
}
|
||||
if (u.email) {
|
||||
const newEmailHash = hash(u.email);
|
||||
if (newEmailHash !== u.emailHash) data.emailHash = newEmailHash;
|
||||
} else if (u.emailHash) {
|
||||
// user has hash but no email — clear stale hash
|
||||
data.emailHash = null;
|
||||
}
|
||||
if (Object.keys(data).length === 0) continue;
|
||||
await prisma.user.update({ where: { id: u.id }, data });
|
||||
updated += 1;
|
||||
}
|
||||
console.log(`✓ Updated ${updated} users.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
});
|
||||
Reference in New Issue
Block a user