chore: organize docs — move 37 files from root into docs/ subfolders
Root now contains only essential files: README.md, CLAUDE.md, CHANGELOG.md, CONTRIBUTING.md Reorganized into: docs/audits/ — all audit reports & checklists (71 files) docs/architecture/ — codebase overview, implementation plan docs/guides/ — auth guide, implementation checklist docs/load-testing/ — k6 load test guides & endpoints docs/security/ — payment & security reviews Also removed 5 untracked debug/investigation files and cleaned up playwright-report/ & test-results/ artifacts. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
381
docs/guides/AUTHENTICATION_GUIDE.md
Normal file
381
docs/guides/AUTHENTICATION_GUIDE.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# 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
|
||||
```typescript
|
||||
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
|
||||
```typescript
|
||||
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
|
||||
```typescript
|
||||
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
|
||||
```typescript
|
||||
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)
|
||||
```json
|
||||
{
|
||||
"requiresMfa": false,
|
||||
"tokens": {
|
||||
"accessToken": "eyJhbGc...",
|
||||
"refreshToken": "eyJhbGc...",
|
||||
"expiresIn": 3600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Login Response (MFA Required)
|
||||
```json
|
||||
{
|
||||
"requiresMfa": true,
|
||||
"challengeId": "challenge-id-here"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. USER ROLES
|
||||
|
||||
### UserRole Enum
|
||||
```prisma
|
||||
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
|
||||
```typescript
|
||||
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
|
||||
```bash
|
||||
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
|
||||
```typescript
|
||||
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
|
||||
```typescript
|
||||
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
|
||||
347
docs/guides/AUTH_IMPLEMENTATION_CHECKLIST.md
Normal file
347
docs/guides/AUTH_IMPLEMENTATION_CHECKLIST.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# GoodGo Platform - Authentication Implementation Checklist
|
||||
|
||||
**Date:** April 12, 2026
|
||||
**Status:** ✅ Complete Analysis
|
||||
|
||||
---
|
||||
|
||||
## 📋 Authentication System Components
|
||||
|
||||
### ✅ 1. Password Hashing
|
||||
- **Algorithm:** bcrypt
|
||||
- **Salt Rounds:** 12 (configurable via `BCRYPT_ROUNDS` env var)
|
||||
- **Min Password:** 8 characters
|
||||
- **Location:** `apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts`
|
||||
- **Method Used:** `HashedPassword.fromPlain(password)` → async bcrypt.hash()
|
||||
- **Comparison:** `passwordHash.compare(plainPassword)` → bcrypt.compare()
|
||||
|
||||
### ✅ 2. Phone Validation & Normalization
|
||||
- **File:** `apps/api/src/modules/shared/utils/vietnam-phone.validator.ts`
|
||||
- **Regex:** `/^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/`
|
||||
- **Accepted Formats:**
|
||||
- `0900000001` (local)
|
||||
- `84900000001` (country code, no +)
|
||||
- `+84900000001` (international)
|
||||
- **Normalized Format:** Always `+84XXX...` (country code prefix)
|
||||
- **Carriers:** Mobile only (no landlines)
|
||||
- 32-39: Viettel/VinaPhone/MobiFone
|
||||
- 52, 56, 58, 59: Viettel
|
||||
- 70, 76-79: Newer carriers
|
||||
- 81-89: VinaPhone
|
||||
- 90-99: MobiFone
|
||||
|
||||
### ✅ 3. Email Validation & Normalization
|
||||
- **File:** `apps/api/src/modules/auth/domain/value-objects/email.vo.ts`
|
||||
- **Regex:** `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` (basic validation)
|
||||
- **Normalization:** lowercase + trimmed
|
||||
- **Example:** `ADMIN@GOODGO.VN` → stored as `admin@goodgo.vn`
|
||||
|
||||
### ✅ 4. PII Encryption & Hashing
|
||||
- **File:** `apps/api/src/modules/shared/infrastructure/field-encryption.ts`
|
||||
- **Encryption Algorithm:** AES-256-GCM
|
||||
- **Key Size:** 32 bytes (64 hex characters)
|
||||
- **IV:** 12 bytes (random)
|
||||
- **Auth Tag:** 16 bytes
|
||||
- **Storage Format:** `enc:v{version}:{iv}:{authTag}:{ciphertext}` (hex)
|
||||
- **Encrypted Fields:**
|
||||
- `email` → stored encrypted + hash in `emailHash`
|
||||
- `phone` → stored encrypted + hash in `phoneHash`
|
||||
- `kycData` → stored encrypted (no separate hash)
|
||||
- **Hash Function:** HMAC-SHA256 (derived from encryption key via HKDF-SHA256)
|
||||
- **Hash Normalization:** lowercase + trimmed
|
||||
- **Env Vars:**
|
||||
- `FIELD_ENCRYPTION_KEY` (required) - hex string, 64 chars
|
||||
- `FIELD_ENCRYPTION_KEY_VERSION` (optional, default: 1)
|
||||
- Fallback: `KYC_ENCRYPTION_KEY` / `KYC_ENCRYPTION_KEY_VERSION`
|
||||
|
||||
### ✅ 5. Login Flow
|
||||
- **File:** `apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts`
|
||||
- **Username Field:** `phone` (Vietnamese format)
|
||||
- **Password Field:** `password` (plaintext)
|
||||
- **User Lookup:** By `phoneHash` (unique index)
|
||||
- **Steps:**
|
||||
1. Normalize phone
|
||||
2. Find user by phoneHash
|
||||
3. Check `isActive` = true
|
||||
4. Compare password (bcrypt)
|
||||
5. Check `totpEnabled`
|
||||
6. Issue JWT tokens or MFA challenge
|
||||
- **MFA Response (if enabled):** `challengeId` + 5-minute TTL
|
||||
- **No MFA Response:** `accessToken` + `refreshToken` + expiry
|
||||
|
||||
### ✅ 6. User Roles
|
||||
- **Enum:** `UserRole` (Prisma)
|
||||
- **Values:**
|
||||
- `BUYER` (default) - Can search, inquire, make offers
|
||||
- `SELLER` - Can create listings
|
||||
- `AGENT` - Professional agent with verified profile
|
||||
- `ADMIN` - Full platform access
|
||||
- **Default Role:** `BUYER`
|
||||
- **Admin Role:** Created explicitly with `role: 'ADMIN'`
|
||||
|
||||
### ✅ 7. MFA (Multi-Factor Authentication)
|
||||
- **TOTP:**
|
||||
- Generator: otplib (RFC 6238)
|
||||
- Period: 30 seconds
|
||||
- Digits: 6
|
||||
- Clock Skew: ±30 seconds
|
||||
- **Backup Codes:**
|
||||
- Count: 10
|
||||
- Length: 8 characters each
|
||||
- Charset: A-Z (no O, I), 2-9 (no 0, 1)
|
||||
- Hashing: HMAC-SHA256 (not bcrypt)
|
||||
- Secret Key: `MFA_BACKUP_CODE_SECRET` or fallback to `JWT_SECRET`
|
||||
- **TOTP Secret Storage:** Encrypted with AES-256-GCM
|
||||
|
||||
### ✅ 8. User Model Fields (Required for Login)
|
||||
```typescript
|
||||
User {
|
||||
id: string // CUID
|
||||
phone: string // Normalized: +84XXX...
|
||||
phoneHash: string // HMAC-SHA256 (unique index)
|
||||
email?: string // Lowercase, trimmed (encrypted)
|
||||
emailHash?: string // HMAC-SHA256 (unique index)
|
||||
passwordHash?: string // bcrypt hash (nullable for OAuth)
|
||||
fullName: string
|
||||
role: UserRole // BUYER | SELLER | AGENT | ADMIN
|
||||
isActive: boolean // true = can login
|
||||
kycStatus: KYCStatus // NONE | PENDING | VERIFIED | REJECTED
|
||||
totpEnabled: boolean // MFA enabled
|
||||
totpSecret?: string // Encrypted
|
||||
totpBackupCodes: string[] // HMAC-SHA256 hashed codes
|
||||
createdAt: DateTime
|
||||
updatedAt: DateTime
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Creating Login-Capable Seed Users
|
||||
|
||||
### Requirements Checklist
|
||||
- [ ] Password ≥ 8 characters
|
||||
- [ ] Phone matches Vietnamese regex
|
||||
- [ ] Phone normalized to `+84...` format
|
||||
- [ ] Email matches basic regex `^[^\s@]+@[^\s@]+\.[^\s@]+$`
|
||||
- [ ] Email lowercased
|
||||
- [ ] Password hashed with bcrypt (≥12 rounds)
|
||||
- [ ] `phoneHash` computed (HMAC-SHA256)
|
||||
- [ ] `emailHash` computed (HMAC-SHA256)
|
||||
- [ ] `isActive: true`
|
||||
- [ ] `totpEnabled: false` (for seed users)
|
||||
- [ ] `totpBackupCodes: []`
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
**Step 1: Normalize Phone**
|
||||
```typescript
|
||||
const phone = '0900000001';
|
||||
const normalized = `+84${phone.slice(1)}`; // '+84900000001'
|
||||
```
|
||||
|
||||
**Step 2: Derive HMAC Key**
|
||||
```typescript
|
||||
const encryptionKey = process.env['FIELD_ENCRYPTION_KEY']; // hex string
|
||||
const hmacKey = crypto.hkdfSync(
|
||||
'sha256',
|
||||
Buffer.from(encryptionKey, 'hex'),
|
||||
Buffer.alloc(0),
|
||||
Buffer.from('goodgo-field-hash', 'utf8'),
|
||||
32,
|
||||
);
|
||||
```
|
||||
|
||||
**Step 3: Compute Hashes**
|
||||
```typescript
|
||||
const phoneHash = crypto
|
||||
.createHmac('sha256', hmacKey)
|
||||
.update(normalized.toLowerCase())
|
||||
.digest('hex');
|
||||
|
||||
const emailHash = crypto
|
||||
.createHmac('sha256', hmacKey)
|
||||
.update(email.toLowerCase())
|
||||
.digest('hex');
|
||||
```
|
||||
|
||||
**Step 4: Hash Password**
|
||||
```typescript
|
||||
const passwordHash = await bcrypt.hash('AdminPassword123', 12);
|
||||
```
|
||||
|
||||
**Step 5: Create User**
|
||||
```typescript
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: 'admin-seed-001',
|
||||
phone: normalized, // +84900000001
|
||||
phoneHash,
|
||||
email,
|
||||
emailHash,
|
||||
passwordHash,
|
||||
fullName: 'Admin GoodGo',
|
||||
role: 'ADMIN',
|
||||
kycStatus: 'VERIFIED',
|
||||
isActive: true,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Login
|
||||
|
||||
### Prerequisites
|
||||
- User exists in database
|
||||
- `passwordHash` is set (not null)
|
||||
- `isActive: true`
|
||||
- No MFA enabled (or have MFA code ready)
|
||||
|
||||
### Test Request
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"phone": "0900000001",
|
||||
"password": "AdminPassword123"
|
||||
}'
|
||||
```
|
||||
|
||||
### Expected Response (Success)
|
||||
```json
|
||||
{
|
||||
"requiresMfa": false,
|
||||
"tokens": {
|
||||
"accessToken": "eyJhbGc...",
|
||||
"refreshToken": "eyJhbGc...",
|
||||
"expiresIn": 3600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Cases
|
||||
- **Invalid phone format:** "Số điện thoại không hợp lệ"
|
||||
- **User not found:** "Số điện thoại hoặc mật khẩu không đúng"
|
||||
- **User inactive:** "Tài khoản đã bị vô hiệu hóa"
|
||||
- **Wrong password:** "Số điện thoại hoặc mật khẩu không đúng"
|
||||
- **MFA required:** `{ "requiresMfa": true, "challengeId": "..." }`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Key Files Reference
|
||||
|
||||
| File | Purpose | Key Functions |
|
||||
|------|---------|---------------|
|
||||
| `hashed-password.vo.ts` | Password hashing | `fromPlain()`, `compare()` |
|
||||
| `phone.vo.ts` | Phone validation | `create()` |
|
||||
| `email.vo.ts` | Email validation | `create()` |
|
||||
| `vietnam-phone.validator.ts` | Phone regex/normalize | `isValidVietnamPhone()`, `normalizeVietnamPhone()` |
|
||||
| `field-encryption.ts` | PII encryption/hashing | `encryptField()`, `decryptField()`, `computeHash()` |
|
||||
| `local.strategy.ts` | Login flow | `validate()` |
|
||||
| `mfa.service.ts` | TOTP/backup codes | `generateSetup()`, `verifyTotp()`, `generateBackupCodes()` |
|
||||
| `user.entity.ts` | User domain model | `createNew()` |
|
||||
| `prisma-user.repository.ts` | User persistence | `findByPhone()`, `save()` |
|
||||
| `encrypt-pii-fields.ts` | Backfill encryption | Batch encryption migration |
|
||||
| `schema.prisma` | Database schema | User model, enums |
|
||||
| `seed.ts` | Seed data | Current seeds (no passwords) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Checklist
|
||||
|
||||
### Environment Variables Required
|
||||
- [ ] `BCRYPT_ROUNDS` (optional, default: 12)
|
||||
- [ ] `FIELD_ENCRYPTION_KEY` (required for PII, hex string 64 chars)
|
||||
- [ ] `FIELD_ENCRYPTION_KEY_VERSION` (optional, default: 1)
|
||||
- [ ] `MFA_BACKUP_CODE_SECRET` (optional, fallback to JWT_SECRET)
|
||||
- [ ] `JWT_SECRET` (required for tokens)
|
||||
|
||||
### Database Setup
|
||||
- [ ] Run migrations (including `add_mfa_totp_support`)
|
||||
- [ ] Seed users with passwords (use provided script)
|
||||
- [ ] Test login functionality
|
||||
- [ ] Verify PII encryption working
|
||||
|
||||
### Testing
|
||||
- [ ] Test login with various phone formats (0900..., 84900..., +84900...)
|
||||
- [ ] Test invalid phone numbers (rejected)
|
||||
- [ ] Test password validation (min 8 chars)
|
||||
- [ ] Test email validation
|
||||
- [ ] Test MFA setup and verification
|
||||
- [ ] Test backup code generation/usage
|
||||
- [ ] Verify hashes computed correctly
|
||||
|
||||
---
|
||||
|
||||
## 📝 Current Seed Data Status
|
||||
|
||||
### Existing Seed (prisma/seed.ts)
|
||||
**Status:** ❌ **NOT login-capable** (no passwords)
|
||||
|
||||
```typescript
|
||||
// Current seed - users created without passwords
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { id: 'seed-user-admin' },
|
||||
create: {
|
||||
id: 'seed-user-admin',
|
||||
phone: '0900000001', // NOT normalized/hashed
|
||||
email: 'admin@goodgo.vn',
|
||||
fullName: 'Admin GoodGo',
|
||||
role: UserRole.ADMIN,
|
||||
// passwordHash: null ← Cannot login!
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Recommended Enhancement
|
||||
Use `SEED_GENERATION_SCRIPT.ts` to create users with full auth capability.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### User Can't Login
|
||||
1. Verify `passwordHash` is NOT null: `SELECT id, passwordHash FROM "User" WHERE id = 'user-id';`
|
||||
2. Check `isActive = true`
|
||||
3. Verify phone is normalized to `+84...` format
|
||||
4. Test phone normalization function directly
|
||||
|
||||
### Phone Hash Mismatch
|
||||
1. Verify `FIELD_ENCRYPTION_KEY` is same across deployments
|
||||
2. Check hash computation: `HMAC-SHA256(lowercased_phone, hmacKey)`
|
||||
3. HKDF derivation must use exact string: `"goodgo-field-hash"`
|
||||
|
||||
### MFA Not Working
|
||||
1. Verify `MFA_BACKUP_CODE_SECRET` is set
|
||||
2. Check TOTP secret is encrypted properly
|
||||
3. Test clock skew (±30s tolerance)
|
||||
|
||||
### Encryption/Decryption Issues
|
||||
1. Verify key is exactly 32 bytes (64 hex chars)
|
||||
2. Check IV length (12 bytes)
|
||||
3. Verify auth tag (16 bytes)
|
||||
4. Ensure `enc:` prefix detection working
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
### External Documentation
|
||||
- **bcrypt:** https://github.com/kelektiv/node.bcrypt.js
|
||||
- **otplib:** https://github.com/yeojz/otplib
|
||||
- **Prisma:** https://www.prisma.io/docs
|
||||
- **NestJS:** https://docs.nestjs.com
|
||||
|
||||
### Related Files
|
||||
- Registration flow: `register-user.handler.ts`
|
||||
- Token generation: `token.service.ts`
|
||||
- JWT strategy: `jwt.strategy.ts`
|
||||
- Refresh token: `refresh-token.handler.ts`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** April 12, 2026
|
||||
**Platform:** GoodGo Real Estate Platform
|
||||
**Status:** ✅ Production-Ready
|
||||
Reference in New Issue
Block a user