Files
goodgo-platform/AUTHENTICATION_GUIDE.md
Ho Ngoc Hai 25420720e7 fix(api,ci): remove type-only imports for DI and isolate CI ports from dev
- Remove `type` keyword from NestJS injectable class imports across all
  modules to fix runtime DI resolution (330+ handler/listener files)
- Offset CI docker-compose ports (5433/6380/8109/9002) to avoid
  conflicts with running dev containers
- Update .env.test, playwright.config.ts, and e2e workflow to use
  isolated CI ports with configurable overrides
- Fix prisma/seed.ts to use deterministic IDs for Prisma 7 upsert
  compatibility (phoneHash replaced phone as unique index)
- Add dedicated Docker bridge network for CI service containers

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-04-13 01:40:14 +07:00

11 KiB

GoodGo Platform - Complete Authentication & Seed Data Guide

Last Updated: April 12, 2026


1. PASSWORD HASHING

Implementation

  • File: apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts
  • Algorithm: bcrypt
  • Salt Rounds: Configurable via BCRYPT_ROUNDS env var (default: 12)
  • Min Password Length: 8 characters

Key Code

static readonly SALT_ROUNDS = parseInt(
  process.env['BCRYPT_ROUNDS'] ?? '12',
  10,
);
static readonly MIN_LENGTH = 8;

static async fromPlain(password: string): Promise<Result<HashedPassword, string>> {
  if (password.length < this.MIN_LENGTH) {
    return Result.err(`Mật khẩu phải có ít nhất ${this.MIN_LENGTH} ký tự`);
  }
  const hash = await bcrypt.hash(password, this.SALT_ROUNDS);
  return Result.ok(new HashedPassword({ value: hash }));
}

async compare(plainPassword: string): Promise<boolean> {
  return bcrypt.compare(plainPassword, this.props.value);
}

2. PHONE VALIDATION & NORMALIZATION

Vietnamese Phone Format

  • File: apps/api/src/modules/shared/utils/vietnam-phone.validator.ts
  • Regex Pattern: /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/

Valid Patterns

  • Starts with +84 (international format)
  • Starts with 84 (country code without +)
  • Starts with 0 (local format)

Carrier Codes (after leading digit)

  • 3[2-9]: Mobile (Viettel, VinaPhone, MobiFone)
  • 5[2689]: Mobile (Viettel)
  • 7[06-9]: Mobile (newer carriers)
  • 8[1-9]: Mobile (VinaPhone)
  • 9[0-9]: Mobile (MobiFone)

Normalization Function

function normalizeVietnamPhone(phone: string): string | null {
  const cleaned = phone.replace(/[\s.-]/g, ''); // Remove spaces, dots, dashes
  if (!VN_PHONE_REGEX.test(cleaned)) return null;

  if (cleaned.startsWith('+84')) return cleaned;
  if (cleaned.startsWith('84')) return `+${cleaned}`;
  if (cleaned.startsWith('0')) return `+84${cleaned.slice(1)}`;
  return null;
}

Examples

Input: "0900000001"  → Normalized: "+84900000001"
Input: "84900000001" → Normalized: "+84900000001"
Input: "+84900000001" → Normalized: "+84900000001"

3. EMAIL VALIDATION & NORMALIZATION

Implementation

  • File: apps/api/src/modules/auth/domain/value-objects/email.vo.ts
  • Regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  • Normalization: Trimmed and converted to lowercase

Code

static create(email: string): Result<Email, string> {
  const normalized = email.trim().toLowerCase();
  if (!this.EMAIL_REGEX.test(normalized)) {
    return Result.err('Email không hợp lệ');
  }
  return Result.ok(new Email({ value: normalized }));
}

4. PII ENCRYPTION & HASHING

Field Encryption Configuration

  • File: apps/api/src/modules/shared/infrastructure/field-encryption.ts
  • Algorithm: AES-256-GCM
  • Stored Format: enc:v{version}:{iv}:{authTag}:{ciphertext} (hex-encoded)
  • Key Size: 32 bytes (64 hex characters)
  • IV Length: 12 bytes (96-bit)
  • Auth Tag Length: 16 bytes

Encrypted Fields (User)

  • email → encrypted, with hash in emailHash (HMAC-SHA256)
  • phone → encrypted, with hash in phoneHash (HMAC-SHA256)
  • kycData → encrypted (no separate hash)

Hash Computation

function deriveHmacKey(encryptionKeyHex: string): Buffer {
  return crypto.hkdfSync(
    'sha256',
    Buffer.from(encryptionKeyHex, 'hex'),
    Buffer.alloc(0),
    Buffer.from('goodgo-field-hash', 'utf8'),
    32,
  );
}

function computeHash(value: string, hmacKey: Buffer): string {
  const normalized = value.toLowerCase().trim();
  return crypto.createHmac('sha256', hmacKey).update(normalized).digest('hex');
}

Environment Variables

  • FIELD_ENCRYPTION_KEY: Hex-encoded 32-byte key (required)
  • FIELD_ENCRYPTION_KEY_VERSION: Key version for rotation (default: 1)
  • Fallback: KYC_ENCRYPTION_KEY / KYC_ENCRYPTION_KEY_VERSION

5. LOGIN FLOW

Local Strategy (Username/Password)

  • File: apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts
  • Username Field: phone (Vietnamese phone, normalized)
  • Password Field: password (plaintext during login)

Login Steps

  1. Validate phone format - normalize Vietnamese phone
  2. Find user by phoneHash - lookup in phoneHash unique index
  3. Check user status - user.isActive must be true
  4. Compare password - bcrypt.compare(plainPassword, passwordHash)
  5. Check MFA - if user.totpEnabled is true, return MFA challenge
  6. Issue tokens - if no MFA, generate JWT pair

Login Response (No MFA)

{
  "requiresMfa": false,
  "tokens": {
    "accessToken": "eyJhbGc...",
    "refreshToken": "eyJhbGc...",
    "expiresIn": 3600
  }
}

Login Response (MFA Required)

{
  "requiresMfa": true,
  "challengeId": "challenge-id-here"
}

6. USER ROLES

UserRole Enum

enum UserRole {
  BUYER     // Default role for new users
  SELLER    // Can list properties
  AGENT     // Professional real estate agent (has Agent profile)
  ADMIN     // Platform administrator
}

Role Details

  • BUYER: Can search, inquire, make offers
  • SELLER: Can create listings, manage properties
  • AGENT: Professional agent with verified profile, license, service areas
  • ADMIN: Full platform access, audit logs, user management

7. CREATING AN ADMIN USER THAT CAN LOG IN

⚠️ Critical Requirements

  1. Valid phone - Must pass Vietnamese phone validation
  2. Valid email - Must pass email regex
  3. Hashed password - Must use bcrypt with ≥12 rounds
  4. Active status - isActive: true
  5. Normalized phone - Stored in +84... format
  6. Hash fields - Must populate phoneHash and emailHash for queries

Node.js/TypeScript Script

import * as bcrypt from 'bcrypt';
import crypto from 'node:crypto';
import { PrismaClient } from '@prisma/client';

async function createAdminUser() {
  const prisma = new PrismaClient();
  
  // 1. Hash password with bcrypt
  const plainPassword = 'AdminPassword123';
  const passwordHash = await bcrypt.hash(plainPassword, 12);
  
  // 2. Normalize phone
  const phone = '0900000001';
  const normalizedPhone = `+84${phone.slice(1)}`; // '+84900000001'
  
  // 3. Compute phone hash
  const encryptionKey = process.env['FIELD_ENCRYPTION_KEY'];
  const hmacKey = crypto.hkdfSync(
    'sha256',
    Buffer.from(encryptionKey, 'hex'),
    Buffer.alloc(0),
    Buffer.from('goodgo-field-hash', 'utf8'),
    32,
  );
  const phoneHash = crypto
    .createHmac('sha256', hmacKey)
    .update(normalizedPhone.toLowerCase())
    .digest('hex');
  
  // 4. Compute email hash
  const email = 'admin@goodgo.vn';
  const emailHash = crypto
    .createHmac('sha256', hmacKey)
    .update(email.toLowerCase())
    .digest('hex');
  
  // 5. Create user
  const admin = await prisma.user.create({
    data: {
      id: 'seed-admin-01',
      phone: normalizedPhone,
      phoneHash,
      email,
      emailHash,
      passwordHash,
      fullName: 'Admin GoodGo',
      role: 'ADMIN',
      kycStatus: 'VERIFIED',
      isActive: true,
      totpEnabled: false,
      totpBackupCodes: [],
    },
  });
  
  console.log('Admin user created:', admin.id);
  await prisma.$disconnect();
}

createAdminUser().catch(console.error);

Login Test

curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "0900000001",
    "password": "AdminPassword123"
  }'

8. MFA (Multi-Factor Authentication)

TOTP Setup

  • Generator: otplib (RFC 6238 compliant)
  • Period: 30 seconds
  • Digits: 6-digit codes
  • Clock Skew: ±30 seconds tolerance

Backup Codes

  • Count: 10 codes
  • Length: 8 characters
  • Charset: A-Z (no O, I), 2-9 (no 0, 1)
  • Hashing: HMAC-SHA256 (not bcrypt)

For Seed Data

  • Set totpEnabled: false for simplicity
  • Set totpSecret: null
  • Set totpBackupCodes: []

9. SEED USER EXAMPLE

Current Seed (from prisma/seed.ts) - NO LOGIN

const admin = await prisma.user.upsert({
  where: { id: 'seed-user-admin' },
  create: {
    id: 'seed-user-admin',
    phone: '0900000001',
    email: 'admin@goodgo.vn',
    fullName: 'Admin GoodGo',
    role: UserRole.ADMIN,
    kycStatus: 'VERIFIED',
    isActive: true,
    // passwordHash is NULL - cannot login!
  },
});

Enhanced Seed with Passwords - LOGIN ENABLED

const admin = await prisma.user.create({
  data: {
    id: 'seed-admin-01',
    phone: '+84900000001',           // Normalized
    phoneHash: computeHmacSha256('+84900000001'),
    email: 'admin@goodgo.vn',
    emailHash: computeHmacSha256('admin@goodgo.vn'),
    passwordHash: await bcrypt.hash('AdminPassword123', 12),
    fullName: 'Admin GoodGo',
    role: 'ADMIN',
    kycStatus: 'VERIFIED',
    isActive: true,
    totpEnabled: false,
    totpBackupCodes: [],
  },
});

10. SUMMARY TABLE

Component Details
Password Hashing bcrypt, 12 rounds (configurable), min 8 chars
Phone Validation Vietnamese format, regex with carrier codes
Phone Normalization +84XXX... format (country code)
Email Validation Basic regex with @ and .
Email Normalization lowercase, trimmed
PII Encryption AES-256-GCM (email, phone, kycData)
Hash Fields HMAC-SHA256 for searchable indexes
Backup Codes HMAC-SHA256, 10 codes, 8 chars each
TOTP RFC 6238, 30s period, 6 digits
User Roles BUYER, SELLER, AGENT, ADMIN
Default Active true
KYC Status NONE, PENDING, VERIFIED, REJECTED

11. KEY FILES REFERENCE

File Purpose
apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts Password hashing with bcrypt
apps/api/src/modules/auth/domain/value-objects/phone.vo.ts Phone validation
apps/api/src/modules/auth/domain/value-objects/email.vo.ts Email validation
apps/api/src/modules/shared/utils/vietnam-phone.validator.ts Vietnamese phone regex & normalization
apps/api/src/modules/shared/infrastructure/field-encryption.ts AES-256-GCM encryption for PII
apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts Login flow
apps/api/src/modules/auth/infrastructure/services/mfa.service.ts TOTP & backup codes
apps/api/src/modules/auth/domain/entities/user.entity.ts User domain model
apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts User persistence
scripts/encrypt-pii-fields.ts Backfill encryption/hashing script
prisma/schema.prisma Database schema
prisma/seed.ts Seed data

Platform: GoodGo Real Estate Platform (NestJS + Prisma + PostgreSQL 16) Generated: April 12, 2026