From 127b09356f7338a8494b798f043dde357423759f Mon Sep 17 00:00:00 2001 From: beulah7717108-eng Date: Sat, 26 Sep 2026 23:15:20 +0000 Subject: [PATCH 1/2] feat: serve comparison shares, add TOTP trusted devices, realtime dashboard feed, and config cleanup Resolves four open backend issues together because they share the auth, database and analytics wiring: #1292 ComparisonShare - Add public GET /property-comparison/shares/:token that resolves the two compared properties without owner PII. - Enforce expiresAt and the new revokedAt, track viewCount/lastViewedAt, and add a creator-only revoke endpoint. Sharing URLs now point at the serving route, with e2e coverage for valid/expired/revoked tokens. #1291 TOTP two-factor - Add trusted-device sessions (hashed tokens, TTL via TRUSTED_DEVICE_TTL_DAYS) so remembered devices can skip the challenge. - Add recovery-code regeneration plus device listing/revocation. - Add an admin force-disable endpoint for locked-out users. - Gate high-risk account operations behind a fresh second factor. - Trusted devices are revoked on password change, 2FA disable and admin reset. #1297 Realtime dashboard feed - Add a cached aggregate snapshot service (queue depth, fraud alerts, property/transaction deltas) and an authenticated `analytics` Socket.IO namespace with heartbeat, using the existing Redis adapter. #1290 Config knobs - Wire PGBOUNCER_POOL_TIMEOUT and CACHE_WARMING_INTERVAL into real behavior, reconcile the README/.env.example documentation, and add a config-docs smoke test. Closes #1290 Closes #1291 Closes #1292 Closes #1297 --- .env.example | 18 +- README.md | 127 ++++++----- docs/MFA_ROADMAP.md | 56 +++-- .../migration.sql | 5 + .../migration.sql | 27 +++ prisma/schema.prisma | 36 +++- src/analytics/analytics.gateway.spec.ts | 119 ++++++++++ src/analytics/analytics.gateway.ts | 194 +++++++++++++++++ src/analytics/analytics.module.ts | 10 +- .../dashboard-metrics.service.spec.ts | 118 ++++++++++ src/analytics/dashboard-metrics.service.ts | 172 +++++++++++++++ src/auth/auth.controller.ts | 40 +++- src/auth/auth.module.ts | 6 + src/auth/auth.service.ts | 148 ++++++++++++- src/auth/dto/auth.dto.ts | 20 ++ .../guards/fresh-two-factor.guard.spec.ts | 128 +++++++++++ src/auth/guards/fresh-two-factor.guard.ts | 97 +++++++++ src/auth/two-factor.service.spec.ts | 171 +++++++++++++++ src/auth/two-factor.service.ts | 203 ++++++++++++++++++ src/cache/cache-warming.service.ts | 49 ++++- src/database/prisma.service.ts | 98 +++++++-- .../property-comparison.controller.ts | 25 ++- .../property-comparison.module.ts | 3 +- .../property-comparison.service.spec.ts | 117 +++++++++- .../property-comparison.service.ts | 80 +++++-- test/e2e/comparison-share.e2e.spec.ts | 173 +++++++++++++++ test/unit/config-docs.spec.ts | 133 ++++++++++++ 27 files changed, 2240 insertions(+), 133 deletions(-) create mode 100644 prisma/migrations/20260926120000_add_comparison_share_revocation_and_views/migration.sql create mode 100644 prisma/migrations/20260926130000_add_trusted_devices/migration.sql create mode 100644 src/analytics/analytics.gateway.spec.ts create mode 100644 src/analytics/analytics.gateway.ts create mode 100644 src/analytics/dashboard-metrics.service.spec.ts create mode 100644 src/analytics/dashboard-metrics.service.ts create mode 100644 src/auth/guards/fresh-two-factor.guard.spec.ts create mode 100644 src/auth/guards/fresh-two-factor.guard.ts create mode 100644 src/auth/two-factor.service.spec.ts create mode 100644 src/auth/two-factor.service.ts create mode 100644 test/e2e/comparison-share.e2e.spec.ts create mode 100644 test/unit/config-docs.spec.ts diff --git a/.env.example b/.env.example index d01279e9..8819937a 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,14 @@ JWT_REFRESH_EXPIRES_IN=7d BCRYPT_ROUNDS=12 PASSWORD_HISTORY_LIMIT=5 +# Two-factor authentication (TOTP) +# Lifetime of a "remembered" trusted device, in days. After a successful TOTP +# challenge a user may opt to trust the device; future logins from that device +# skip the challenge until this many days have elapsed or the device is revoked. +# Invalid or non-positive values fall back to 30. Disabling 2FA or changing the +# password revokes every trusted device. +TRUSTED_DEVICE_TTL_DAYS=30 + # Database Backup Management BACKUP_STORAGE_PATH=./backups PG_DUMP_PATH=pg_dump @@ -25,9 +33,11 @@ PSQL_PATH=psql # PgBouncer Connection Pooling # Set to 'true' when connecting through PgBouncer (disables Prisma's built-in pool) PGBOUNCER_ENABLED=false -# Pool size exposed to Prisma when PgBouncer is enabled (matches PgBouncer DEFAULT_POOL_SIZE) +# Prisma connection pool size (connection_limit), applied when PgBouncer is disabled. +# When PgBouncer is enabled this should match PgBouncer's DEFAULT_POOL_SIZE. PGBOUNCER_POOL_SIZE=20 -# Timeout (ms) for acquiring a connection from the pool +# Timeout (ms) for acquiring a connection from Prisma's pool. Applied when PgBouncer is +# disabled; with PgBouncer enabled the timeout is enforced server-side by PgBouncer. PGBOUNCER_POOL_TIMEOUT=10000 # N+1 Query Detection (Issue #911) @@ -40,8 +50,10 @@ PGBOUNCER_POOL_TIMEOUT=10000 # DB_N1_THRESHOLD=5 # Cache Warming -# Set to 'false' to disable startup and periodic cache warming +# Set to 'false' to disable startup and periodic cache warming (default: enabled) CACHE_WARMING_ENABLED=true +# Interval (ms) between periodic warming runs (default: 1800000 = 30 minutes) +CACHE_WARMING_INTERVAL=1800000 # Google OAuth2 GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret diff --git a/README.md b/README.md index 57ca325f..9e3acd07 100644 --- a/README.md +++ b/README.md @@ -131,57 +131,61 @@ The application uses environment variables for configuration. Copy `.env.example ### Environment Variables -| Variable | Description | Default | -| :-------------------------------- | :--------------------------------------------------------- | :---------------------------------- | -| `DATABASE_URL` | PostgreSQL connection string | Required | -| `PORT` | Server port | 3000 | -| `NODE_ENV` | Environment mode | development | -| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | -| `JWT_SECRET` | JWT signing secret | Required | -| `JWT_REFRESH_SECRET` | JWT refresh token secret | Required | -| `JWT_ACCESS_EXPIRES_IN` | Access token expiration | 15m | -| `JWT_REFRESH_EXPIRES_IN` | Refresh token expiration | 7d | -| `BCRYPT_ROUNDS` | Password hashing rounds | 12 | -| `PASSWORD_HISTORY_LIMIT` | Password history limit | 5 | -| `PASSWORD_MIN_LENGTH` | Minimum password length | 8 | -| `PASSWORD_REQUIRE_UPPERCASE` | Require uppercase in password | true | -| `PASSWORD_REQUIRE_LOWERCASE` | Require lowercase in password | true | -| `PASSWORD_REQUIRE_DIGIT` | Require digit in password | true | -| `PASSWORD_REQUIRE_SPECIAL` | Require special char in password | true | -| `PASSWORD_SPECIAL_CHARS` | Allowed special characters | !@#$%^&\*()\_+-=... | -| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | -| `RECAPTCHA_SECRET` | Google reCAPTCHA v3 private key | Required | -| `CAPTCHA_THRESHOLD` | Minimum reCAPTCHA score to pass | 0.5 | -| `BASE_URL` | Root URL of this API server | http://localhost:3000 | -| `API_URL` | Full API base URL for email links | http://localhost:3000/api | -| `AVATAR_UPLOAD_DIR` | Directory for user avatar uploads | ./uploads/avatars | -| `AVATAR_MAX_FILE_SIZE` | Max avatar file size in bytes | 5242880 | -| `CORS_ORIGINS` | Comma-separated allowed origins | http://localhost:3000 | -| `DEBUG_PII` | Enable PII debugging in auth logs | false | -| `EMAIL_VERIFICATION_EXPIRES_IN` | Email verification token TTL | 24h | -| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | — | -| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | — | -| `GOOGLE_CALLBACK_URL` | Google OAuth2 callback URL | /api/auth/google/callback | -| `BLOCKCHAIN_ENABLED` | Enable blockchain integration | true | -| `BLOCKCHAIN_NETWORK` | Ethereum network | sepolia | -| `BLOCKCHAIN_RPC_URL` | Ethereum RPC endpoint (validated at boot) | — | -| `BLOCKCHAIN_CONTRACT_ADDRESS` | Smart contract address (EIP-55 checksum validated at boot) | — | -| `BLOCKCHAIN_PRIVATE_KEY` | Wallet private key for signing (validated at boot) | — | -| `BACKUP_STORAGE_PATH` | Directory for DB backup files | ./backups | -| `PG_DUMP_PATH` | Path to pg_dump binary | pg_dump | -| `PSQL_PATH` | Path to psql binary | psql | -| `PROPERTY_IMAGES_UPLOAD_DIR` | Directory for property images | ./uploads/properties | -| `PROPERTY_IMAGE_MAX_SIZE` | Max property image size in bytes | 10485760 | -| `PROPERTY_IMAGE_MAX_PER_PROPERTY` | Max images per property | 30 | -| `GEOCODING_PROVIDER` | Geocoding provider (nominatim/google) | nominatim | -| `NOMINATIM_BASE_URL` | Nominatim API base URL | https://nominatim.openstreetmap.org | -| `GEOCODING_USER_AGENT` | User agent for geocoding requests | PropChain-Backend/1.0 | -| `GEOCODING_TIMEOUT_MS` | Geocoding request timeout (ms) | 5000 | -| `GOOGLE_GEOCODING_API_KEY` | Google Geocoding API key (optional) | — | -| `FRAUD_ALERT_RECIPIENTS` | Comma-separated fraud alert emails | — | -| `CACHE_WARMING_ENABLED` | Enable cache warming on startup | false | -| `CACHE_WARMING_INTERVAL` | Cache warming interval (ms) | — | -| `TEST_DATABASE_URL` | PostgreSQL URL for integration tests | — | +| Variable | Description | Default | +| :-------------------------------- | :----------------------------------------------------------------------- | :---------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | Required | +| `PORT` | Server port | 3000 | +| `NODE_ENV` | Environment mode | development | +| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | +| `JWT_SECRET` | JWT signing secret | Required | +| `JWT_REFRESH_SECRET` | JWT refresh token secret | Required | +| `JWT_ACCESS_EXPIRES_IN` | Access token expiration | 15m | +| `JWT_REFRESH_EXPIRES_IN` | Refresh token expiration | 7d | +| `BCRYPT_ROUNDS` | Password hashing rounds | 12 | +| `PASSWORD_HISTORY_LIMIT` | Password history limit | 5 | +| `PASSWORD_MIN_LENGTH` | Minimum password length | 8 | +| `PASSWORD_REQUIRE_UPPERCASE` | Require uppercase in password | true | +| `PASSWORD_REQUIRE_LOWERCASE` | Require lowercase in password | true | +| `PASSWORD_REQUIRE_DIGIT` | Require digit in password | true | +| `PASSWORD_REQUIRE_SPECIAL` | Require special char in password | true | +| `PASSWORD_SPECIAL_CHARS` | Allowed special characters | !@#$%^&\*()\_+-=... | +| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | +| `RECAPTCHA_SECRET` | Google reCAPTCHA v3 private key | Required | +| `CAPTCHA_THRESHOLD` | Minimum reCAPTCHA score to pass | 0.5 | +| `BASE_URL` | Root URL of this API server | http://localhost:3000 | +| `API_URL` | Full API base URL for email links | http://localhost:3000/api | +| `AVATAR_UPLOAD_DIR` | Directory for user avatar uploads | ./uploads/avatars | +| `AVATAR_MAX_FILE_SIZE` | Max avatar file size in bytes | 5242880 | +| `CORS_ORIGINS` | Comma-separated allowed origins | http://localhost:3000 | +| `DEBUG_PII` | Enable PII debugging in auth logs | false | +| `EMAIL_VERIFICATION_EXPIRES_IN` | Email verification token TTL | 24h | +| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | — | +| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | — | +| `GOOGLE_CALLBACK_URL` | Google OAuth2 callback URL | /api/auth/google/callback | +| `BLOCKCHAIN_ENABLED` | Enable blockchain integration | true | +| `BLOCKCHAIN_NETWORK` | Ethereum network | sepolia | +| `BLOCKCHAIN_RPC_URL` | Ethereum RPC endpoint (validated at boot) | — | +| `BLOCKCHAIN_CONTRACT_ADDRESS` | Smart contract address (EIP-55 checksum validated at boot) | — | +| `BLOCKCHAIN_PRIVATE_KEY` | Wallet private key for signing (validated at boot) | — | +| `BACKUP_STORAGE_PATH` | Directory for DB backup files | ./backups | +| `PG_DUMP_PATH` | Path to pg_dump binary | pg_dump | +| `PSQL_PATH` | Path to psql binary | psql | +| `PROPERTY_IMAGES_UPLOAD_DIR` | Directory for property images | ./uploads/properties | +| `PROPERTY_IMAGE_MAX_SIZE` | Max property image size in bytes | 10485760 | +| `PROPERTY_IMAGE_MAX_PER_PROPERTY` | Max images per property | 30 | +| `GEOCODING_PROVIDER` | Geocoding provider (nominatim/google) | nominatim | +| `NOMINATIM_BASE_URL` | Nominatim API base URL | https://nominatim.openstreetmap.org | +| `GEOCODING_USER_AGENT` | User agent for geocoding requests | PropChain-Backend/1.0 | +| `GEOCODING_TIMEOUT_MS` | Geocoding request timeout (ms) | 5000 | +| `GOOGLE_GEOCODING_API_KEY` | Google Geocoding API key (optional) | — | +| `FRAUD_ALERT_RECIPIENTS` | Comma-separated fraud alert emails | — | +| `TRUSTED_DEVICE_TTL_DAYS` | Lifetime of a remembered 2FA device, in days | 30 | +| `PGBOUNCER_ENABLED` | Connect through PgBouncer (disables Prisma's built-in pool) | false | +| `PGBOUNCER_POOL_SIZE` | Prisma connection pool size (`connection_limit`) | 10 | +| `PGBOUNCER_POOL_TIMEOUT` | Prisma pool timeout in ms (`pool_timeout`); PgBouncer-owned when enabled | 10000 | +| `CACHE_WARMING_ENABLED` | Enable startup and periodic cache warming | true | +| `CACHE_WARMING_INTERVAL` | Interval between cache warming cycles (ms) | 1800000 (30 min) | +| `TEST_DATABASE_URL` | PostgreSQL URL for integration tests | — | ## 🗄️ Database Setup @@ -300,7 +304,7 @@ Status: ✅ imported by `AppModule` (directly or transitively) · ⚠️ code ex | Module | Purpose | Base route(s) | Status | Docs | | --------------------- | ------------------------------------------------------------------------------- | ------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `admin` | Admin back office: users, moderation, fraud, backups, archive, API keys, queues | `/admin/*` | ✅ | [README](src/admin/README.md) | -| `analytics` | Request/usage analytics | `/analytics` | ✅ | | +| `analytics` | Request/usage analytics + realtime dashboard feed (WS `/analytics`) | `/analytics`, WS `/analytics` | ✅ | | | `archive` | Data archival strategy & restore | via `/admin/archive/*` | ✅ | | | `audit` | Daily archive + prune of history tables (365 days) | (cron only) | ✅ | [README](src/audit/README.md) | | `auth` | Login, JWT/refresh, API keys, MFA, rate limiting, RBAC | `/auth`, `/admin/rate-limits` | ✅ | [Auth & Users](docs/Auth_and_User_APIs.md), [Login rate limiting](docs/LOGIN_RATE_LIMITING.md), [RBAC matrix](docs/RBAC_Permission_Matrix.md) | @@ -344,6 +348,25 @@ Status: ✅ imported by `AppModule` (directly or transitively) · ⚠️ code ex More guides: [DEVELOPMENT.md](docs/DEVELOPMENT.md), [SECURITY.md](docs/SECURITY.md), [LOAD_TESTS.md](docs/LOAD_TESTS.md), [Rate-limit incident runbook](docs/INCIDENT_RUNBOOK_RATE_LIMIT.md), [CHANGELOG guide](docs/CHANGELOG_GUIDE.md). +### Realtime Analytics Feed + +Admins and agents can subscribe to aggregate dashboard metrics over Socket.IO +(namespace `analytics`) by passing a valid access token: + +```js +const socket = io('/analytics', { auth: { token: accessToken } }); +socket.on('analytics:snapshot', (snapshot) => { + /* queue depth, fraud alerts, property/transaction deltas */ +}); +socket.on('analytics:heartbeat', ({ timestamp }) => { + /* liveness */ +}); +``` + +Snapshots are cached for a few seconds and pushed every 5 s (with a 10 s +heartbeat), so additional subscribers do not increase database load. The +Socket.IO Redis adapter fans each emission out across replicas. + ## 🔧 Available Scripts | Command | Description | diff --git a/docs/MFA_ROADMAP.md b/docs/MFA_ROADMAP.md index 4b35c21f..7ac44c57 100644 --- a/docs/MFA_ROADMAP.md +++ b/docs/MFA_ROADMAP.md @@ -6,42 +6,70 @@ Two-factor authentication is fully implemented using TOTP (Time-based One-Time P ### Endpoints -| Endpoint | Method | Description | -| --------------------------------- | ------ | --------------------------------------------- | -| `POST /api/auth/2fa/setup` | POST | Initialize 2FA — returns secret + QR code URL | -| `POST /api/auth/2fa/verify` | POST | Verify a TOTP code and activate 2FA | -| `POST /api/auth/2fa/disable` | POST | Disable 2FA (requires current TOTP code) | -| `POST /api/auth/2fa/backup-codes` | POST | Regenerate backup codes | +| Endpoint | Method | Description | +| ---------------------------------------- | ------ | ---------------------------------------------------- | +| `POST /api/auth/2fa/setup` | POST | Initialize 2FA — returns secret + QR code URL | +| `POST /api/auth/2fa/verify` | POST | Verify a TOTP code and activate 2FA | +| `POST /api/auth/2fa/disable` | POST | Disable 2FA (requires password + fresh 2FA code) | +| `POST /api/auth/2fa/recovery-codes` | POST | Regenerate recovery codes (requires fresh 2FA code) | +| `GET /api/auth/2fa/devices` | GET | List trusted devices | +| `DELETE /api/auth/2fa/devices/:id` | DELETE | Revoke a trusted device | +| `POST /api/auth/2fa/admin/force-disable` | POST | Admin reset of a locked-out user's 2FA (admins only) | ### DTOs - **`VerifyTwoFactorDto`** — `{ code: string }` — used by `POST /api/auth/2fa/verify` - **`SetupTwoFactorResponse`** — `{ secret: string; qrCodeUrl: string; otpAuthUrl: string }` +- **`ForceDisableTwoFactorDto`** — `{ email: string }` — admin reset target ### Login Flow with 2FA 1. User submits email + password -2. If `twoFactorEnabled === true`, server returns `{ requiresTwoFactor: true, tempToken }` -3. Client calls `POST /api/auth/2fa/verify` with `tempToken` + TOTP `code` -4. Server validates code against stored `twoFactorSecret` using `verifyTotpCode()` -5. Backup codes are supported — each use invalidates the code +2. If `twoFactorEnabled === true`: + - If the request includes a valid `trustedDeviceToken` for a device the user + previously remembered, the challenge is skipped. + - Otherwise the server requires a TOTP `totpCode` or a single-use + `backupCode` in the same `POST /api/auth/2fa/verify`-style login payload. +3. Set `rememberDevice: true` alongside a successful code to receive a + `trustedDeviceToken` (and `trustedDeviceExpiresAt`) that skips future + challenges from that device. +4. Backup/recovery codes are supported — each use invalidates the code. -### Backup Codes +### Recovery Codes - 8 codes generated at setup via `generateBackupCodes()` - Stored as SHA-256 hashes in `twoFactorBackupCodes` array - Verified with timing-safe comparison via `verifyBackupCode()` - Each code can only be used once +- `POST /api/auth/2fa/recovery-codes` replaces the whole set; it requires a + fresh TOTP or recovery code so a stolen access token alone cannot rotate them. + +### Trusted Devices + +- `TrustedDevice` stores only a SHA-256 hash of the device token +- Lifetime is `TRUSTED_DEVICE_TTL_DAYS` (default 30 days) +- Devices are revoked automatically when the password changes, 2FA is disabled, + or an admin resets the account +- Users can list and revoke their own devices + +### High-Risk Operation Gating + +`FreshTwoFactorGuard` protects sensitive operations (change password, disable +2FA, regenerate recovery codes). When the user has 2FA enabled, these endpoints +require a current TOTP or unused recovery code supplied via the `x-2fa-code` +header (or a `totpCode` / `twoFactorCode` / `code` body field), so a +trusted-device session or stale access token cannot perform them alone. ### Dependencies - `src/auth/security.utils.ts` — TOTP generation/verification, backup codes, QR code URL -- `src/auth/auth.service.ts` — `setupTwoFactor()`, `verifyTwoFactor()`, `disableTwoFactor()` +- `src/auth/two-factor.service.ts` — trusted devices and recovery-code lifecycle +- `src/auth/guards/fresh-two-factor.guard.ts` — fresh 2FA enforcement +- `src/auth/auth.service.ts` — `setupTwoFactor()`, `verifyTwoFactor()`, `disableTwoFactor()`, `adminForceDisableTwoFactor()` - `src/types/prisma.types.ts` — `twoFactorEnabled`, `twoFactorSecret`, `twoFactorBackupCodes` fields ## Future Enhancements - [ ] SMS-based 2FA as fallback - [ ] Hardware key (WebAuthn/FIDO2) support -- [ ] Admin-enforced 2FA for agent/admin roles -- [ ] Trusted device management +- [ ] Admin-enforced 2FA for agent/admin roles (opt-in today) diff --git a/prisma/migrations/20260926120000_add_comparison_share_revocation_and_views/migration.sql b/prisma/migrations/20260926120000_add_comparison_share_revocation_and_views/migration.sql new file mode 100644 index 00000000..db3cdb42 --- /dev/null +++ b/prisma/migrations/20260926120000_add_comparison_share_revocation_and_views/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "comparison_shares" +ADD COLUMN "revoked_at" TIMESTAMP(3), +ADD COLUMN "view_count" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "last_viewed_at" TIMESTAMP(3); diff --git a/prisma/migrations/20260926130000_add_trusted_devices/migration.sql b/prisma/migrations/20260926130000_add_trusted_devices/migration.sql new file mode 100644 index 00000000..a1a3a7fc --- /dev/null +++ b/prisma/migrations/20260926130000_add_trusted_devices/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "trusted_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "label" TEXT, + "user_agent" TEXT, + "ip_address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "trusted_devices_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "trusted_devices_token_hash_key" ON "trusted_devices"("token_hash"); + +-- CreateIndex +CREATE INDEX "trusted_devices_user_id_idx" ON "trusted_devices"("user_id"); + +-- CreateIndex +CREATE INDEX "trusted_devices_expires_at_idx" ON "trusted_devices"("expires_at"); + +-- AddForeignKey +ALTER TABLE "trusted_devices" ADD CONSTRAINT "trusted_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 708cc868..1f1a3a90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -267,6 +267,7 @@ model User { deletedDocuments Document[] @relation("DeletedDocuments") priceChanges PropertyPriceHistory[] @relation("PriceChangeAuthor") comparisonShares ComparisonShare[] + trustedDevices TrustedDevice[] // #960 — account deletion audit trail deletionAuditEntries AccountDeletionAudit[] @relation("AccountDeletionAuditUser") @@ -912,6 +913,28 @@ model Session { @@map("sessions") } +// Trusted device records let a user skip the TOTP challenge on devices they +// have explicitly remembered during a successful two-factor login (#1291). +// Only a SHA-256 hash of the device token is stored, never the token itself. +model TrustedDevice { + id String @id @default(uuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") + label String? + userAgent String? @map("user_agent") + ipAddress String? @map("ip_address") + createdAt DateTime @default(now()) @map("created_at") + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@map("trusted_devices") +} + model FraudAlert { id String @id @default(uuid()) userId String? @map("user_id") @@ -1395,12 +1418,15 @@ model ExportJob { } model ComparisonShare { - id String @id @default(uuid()) - shareToken String @unique @map("share_token") - propertyIds String[] @map("property_ids") - createdById String? @map("created_by_id") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + shareToken String @unique @map("share_token") + propertyIds String[] @map("property_ids") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime? @map("expires_at") + revokedAt DateTime? @map("revoked_at") + viewCount Int @default(0) @map("view_count") + lastViewedAt DateTime? @map("last_viewed_at") createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) diff --git a/src/analytics/analytics.gateway.spec.ts b/src/analytics/analytics.gateway.spec.ts new file mode 100644 index 00000000..45e383dd --- /dev/null +++ b/src/analytics/analytics.gateway.spec.ts @@ -0,0 +1,119 @@ +import { AnalyticsGateway } from './analytics.gateway'; +import { DashboardMetricsService } from './dashboard-metrics.service'; +import { AuthService } from '../auth/auth.service'; + +const snapshot = { + generatedAt: new Date().toISOString(), + queue: null, + fraud: null, + properties: null, + transactions: null, +}; + +function makeSocket(token?: string) { + return { + id: 'socket-1', + data: {} as Record, + handshake: { + auth: token ? { token } : {}, + query: {}, + }, + emit: jest.fn(), + join: jest.fn(), + disconnect: jest.fn(), + } as any; +} + +describe('AnalyticsGateway (#1297)', () => { + let gateway: AnalyticsGateway; + let server: any; + + const metrics = { + getSnapshot: jest.fn().mockResolvedValue(snapshot), + }; + + const authService = { + validateAccessToken: jest.fn(), + }; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + beforeEach(() => { + jest.clearAllMocks(); + server = { + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + }; + gateway = new AnalyticsGateway( + metrics as unknown as DashboardMetricsService, + authService as unknown as AuthService, + ); + gateway.server = server; + }); + + it('rejects a connection without a token', async () => { + const socket = makeSocket(); + await gateway.handleConnection(socket); + + expect(socket.emit).toHaveBeenCalledWith('analytics:error', { message: 'Unauthorized' }); + expect(socket.disconnect).toHaveBeenCalledWith(true); + expect(authService.validateAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects an invalid token', async () => { + authService.validateAccessToken.mockRejectedValue(new Error('bad token')); + const socket = makeSocket('bad'); + + await gateway.handleConnection(socket); + + expect(socket.disconnect).toHaveBeenCalledWith(true); + expect(socket.join).not.toHaveBeenCalled(); + }); + + it('rejects non-admin/non-agent roles', async () => { + authService.validateAccessToken.mockResolvedValue({ + sub: 'user-1', + role: 'USER', + type: 'access', + }); + const socket = makeSocket('token'); + + await gateway.handleConnection(socket); + + expect(socket.emit).toHaveBeenCalledWith('analytics:error', { message: 'Forbidden' }); + expect(socket.disconnect).toHaveBeenCalledWith(true); + }); + + it('admits an admin and pushes an initial snapshot', async () => { + authService.validateAccessToken.mockResolvedValue({ + sub: 'admin-1', + role: 'ADMIN', + type: 'access', + }); + const socket = makeSocket('token'); + + await gateway.handleConnection(socket); + await flush(); + + expect(socket.join).toHaveBeenCalledWith('analytics:global'); + expect(socket.data.userId).toBe('admin-1'); + expect(socket.emit).toHaveBeenCalledWith('analytics:snapshot', snapshot); + }); + + it('emits snapshots to the analytics room', async () => { + await (gateway as any).emitSnapshot(); + + expect(server.to).toHaveBeenCalledWith('analytics:global'); + expect(server.emit).toHaveBeenCalledWith('analytics:snapshot', snapshot); + }); + + it('does not throw when the snapshot source fails', async () => { + metrics.getSnapshot.mockRejectedValueOnce(new Error('cache down')); + + await expect((gateway as any).emitSnapshot()).resolves.toBeUndefined(); + }); + + it('handleDisconnect tolerates unknown sockets', () => { + expect(() => gateway.handleDisconnect(makeSocket())).not.toThrow(); + }); +}); diff --git a/src/analytics/analytics.gateway.ts b/src/analytics/analytics.gateway.ts new file mode 100644 index 00000000..bfc9dd75 --- /dev/null +++ b/src/analytics/analytics.gateway.ts @@ -0,0 +1,194 @@ +import { Logger, OnModuleDestroy, Injectable } from '@nestjs/common'; +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { createAdapter } from '@socket.io/redis-adapter'; +import Redis from 'ioredis'; +import { getRedisConfig } from '../cache/cache.config'; +import { AuthService } from '../auth/auth.service'; +import { UserRole } from '@prisma/client'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { DashboardMetricsService, DashboardSnapshot } from './dashboard-metrics.service'; + +/** + * Realtime dashboard metrics feed (issue #1297). + * + * Pushes low-frequency aggregate snapshots (queue depth, fraud alerts, + * property/transaction deltas) to authenticated admins/agents over the + * `analytics` Socket.IO namespace. Snapshots are cached by + * {@link DashboardMetricsService} so a fleet of subscribers places no extra + * load on the database, and the Socket.IO Redis adapter fans each emission out + * to every replica. + * + * Event contract: + * - `analytics:snapshot` – {@link DashboardSnapshot}, emitted immediately on + * connect and then every {@link SNAPSHOT_INTERVAL_MS}. + * - `analytics:heartbeat` – `{ timestamp }`, emitted every + * {@link HEARTBEAT_INTERVAL_MS} so clients can detect a stalled feed. + */ +const ANALYTICS_ROOM = 'analytics:global'; +const SNAPSHOT_INTERVAL_MS = 5_000; +const HEARTBEAT_INTERVAL_MS = 10_000; +const ALLOWED_ROLES: UserRole[] = [UserRole.ADMIN, UserRole.AGENT]; + +const corsOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()) + : ['http://localhost:3000']; + +@Injectable() +@WebSocketGateway({ + cors: { + origin: corsOrigins, + credentials: true, + }, + namespace: 'analytics', +}) +export class AnalyticsGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy +{ + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(AnalyticsGateway.name); + private snapshotTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + + constructor( + private readonly metrics: DashboardMetricsService, + private readonly authService: AuthService, + ) {} + + afterInit(server: Server): void { + this.setupRedisAdapter(server); + this.startFeed(); + this.logger.log('AnalyticsGateway initialised'); + } + + onModuleDestroy(): void { + if (this.snapshotTimer) { + clearInterval(this.snapshotTimer); + this.snapshotTimer = null; + } + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + /** + * Attach the Socket.IO Redis adapter so a single emission reaches sockets + * connected to any replica, mirroring the notifications gateway strategy. + */ + private setupRedisAdapter(server: Server): void { + try { + const config = getRedisConfig(); + const pubClient = new Redis({ + host: config.host, + port: config.port, + password: config.password, + db: config.db, + retryStrategy: config.retryStrategy as any, + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: true, + }); + const subClient = pubClient.duplicate({ lazyConnect: true }); + + Promise.all([pubClient.connect(), subClient.connect()]) + .then(() => { + server.adapter(createAdapter(pubClient, subClient) as any); + this.logger.log('Analytics Redis adapter attached — cross-replica push enabled'); + }) + .catch((err) => { + this.logger.warn( + `Analytics Redis adapter setup failed — single-instance mode: ${err.message}`, + ); + }); + } catch (err) { + this.logger.warn(`Could not create analytics Redis adapter: ${err}`); + } + } + + private startFeed(): void { + this.snapshotTimer = setInterval(() => { + void this.emitSnapshot(); + }, SNAPSHOT_INTERVAL_MS); + this.snapshotTimer.unref?.(); + + this.heartbeatTimer = setInterval(() => { + this.server.to(ANALYTICS_ROOM).emit('analytics:heartbeat', { timestamp: Date.now() }); + }, HEARTBEAT_INTERVAL_MS); + this.heartbeatTimer.unref?.(); + } + + private async emitSnapshot(): Promise { + try { + const snapshot = await this.metrics.getSnapshot(); + this.server.to(ANALYTICS_ROOM).emit('analytics:snapshot', snapshot); + } catch (error) { + this.logger.warn( + `Failed to emit analytics snapshot: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + async handleConnection(client: Socket): Promise { + const token = this.extractToken(client); + let user: AuthUserPayload; + + try { + if (!token) { + throw new Error('Missing token'); + } + user = await this.authService.validateAccessToken(token); + } catch { + client.emit('analytics:error', { message: 'Unauthorized' }); + client.disconnect(true); + return; + } + + if (!ALLOWED_ROLES.includes(user.role)) { + client.emit('analytics:error', { message: 'Forbidden' }); + client.disconnect(true); + return; + } + + client.data.userId = user.sub; + client.data.role = user.role; + client.join(ANALYTICS_ROOM); + + this.logger.log(`Analytics client ${user.sub} (${user.role}) connected ${client.id}`); + + // Push the current snapshot immediately so a new client is never blank. + this.metrics + .getSnapshot() + .then((snapshot: DashboardSnapshot) => client.emit('analytics:snapshot', snapshot)) + .catch(() => undefined); + } + + handleDisconnect(client: Socket): void { + const userId = client.data?.userId; + if (userId) { + this.logger.log(`Analytics client ${userId} disconnected ${client.id}`); + } + } + + private extractToken(client: Socket): string | undefined { + const fromAuth = client.handshake.auth?.token as string | undefined; + if (fromAuth?.trim()) { + return fromAuth.trim(); + } + const fromQuery = client.handshake.query?.token; + if (typeof fromQuery === 'string' && fromQuery.trim()) { + return fromQuery.trim(); + } + return undefined; + } +} diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index b534c424..0a67d1d5 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -5,13 +5,17 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AnalyticsService } from './analytics.service'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsInterceptor } from './analytics.interceptor'; +import { AnalyticsGateway } from './analytics.gateway'; +import { DashboardMetricsService } from './dashboard-metrics.service'; import { PrismaModule } from '../database/prisma.module'; +import { AuthModule } from '../auth/auth.module'; +import { QueueModule } from '../admin/queue/queue.module'; @Global() @Module({ - imports: [PrismaModule, ScheduleModule.forRoot()], + imports: [PrismaModule, ScheduleModule.forRoot(), AuthModule, QueueModule], controllers: [AnalyticsController], - providers: [AnalyticsService, AnalyticsInterceptor], - exports: [AnalyticsService, AnalyticsInterceptor], + providers: [AnalyticsService, AnalyticsInterceptor, AnalyticsGateway, DashboardMetricsService], + exports: [AnalyticsService, AnalyticsInterceptor, DashboardMetricsService], }) export class AnalyticsModule {} diff --git a/src/analytics/dashboard-metrics.service.spec.ts b/src/analytics/dashboard-metrics.service.spec.ts new file mode 100644 index 00000000..6a9aaa5f --- /dev/null +++ b/src/analytics/dashboard-metrics.service.spec.ts @@ -0,0 +1,118 @@ +import { DashboardMetricsService } from './dashboard-metrics.service'; +import { PrismaService } from '../database/prisma.service'; +import { CacheService } from '../cache/cache.service'; +import { QueueMonitoringService } from '../admin/queue/queue.service'; + +describe('DashboardMetricsService (#1297)', () => { + let service: DashboardMetricsService; + + const prisma = { + fraudAlert: { count: jest.fn() }, + property: { count: jest.fn() }, + transaction: { count: jest.fn() }, + }; + + const fraudCount = ({ where }: any) => { + if (where.status === 'OPEN' && where.severity) return Promise.resolve(3); + if (where.status === 'OPEN') return Promise.resolve(5); + return Promise.resolve(2); + }; + + const propertyCount = ({ where }: any) => { + if (where.createdAt) return Promise.resolve(7); + if (where.status) return Promise.resolve(80); + return Promise.resolve(100); + }; + + const transactionCount = (args: any) => { + const where = args?.where; + if (where?.createdAt) return Promise.resolve(3); + if (where?.status === 'PENDING') return Promise.resolve(6); + if (where?.status === 'COMPLETED') return Promise.resolve(40); + return Promise.resolve(50); + }; + + const cacheService = { + getOrSet: jest.fn(async (_key: string, factory: () => Promise) => factory()), + }; + + const queueMonitoring = { + getQueueMetrics: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + cacheService.getOrSet.mockImplementation( + async (_key: string, factory: () => Promise) => factory(), + ); + queueMonitoring.getQueueMetrics.mockResolvedValue({ + metrics: [ + { queue: 'mail', depth: 4, waiting: 2, active: 1, failed: 1 }, + { queue: 'export', depth: 6, waiting: 6, active: 0, failed: 0 }, + ], + }); + prisma.fraudAlert.count.mockImplementation(fraudCount as any); + prisma.property.count.mockImplementation(propertyCount as any); + prisma.transaction.count.mockImplementation(transactionCount as any); + + service = new DashboardMetricsService( + prisma as unknown as PrismaService, + cacheService as unknown as CacheService, + queueMonitoring as unknown as QueueMonitoringService, + ); + }); + + it('aggregates queue, fraud, property and transaction metrics', async () => { + const snapshot = await service.buildSnapshot(); + + expect(snapshot.generatedAt).toEqual(expect.any(String)); + expect(snapshot.queue).toEqual({ + totalDepth: 10, + queues: [ + { queue: 'mail', depth: 4, waiting: 2, active: 1, failed: 1 }, + { queue: 'export', depth: 6, waiting: 6, active: 0, failed: 0 }, + ], + }); + expect(snapshot.fraud).toEqual({ open: 5, investigating: 2, highSeverityOpen: 3 }); + expect(snapshot.properties).toEqual({ total: 100, active: 80, createdLast24h: 7 }); + expect(snapshot.transactions).toEqual({ + total: 50, + pending: 6, + completed: 40, + createdLast24h: 3, + }); + }); + + it('caches the snapshot through the cache service', async () => { + await service.getSnapshot(); + + expect(cacheService.getOrSet).toHaveBeenCalledWith( + expect.any(String), + expect.any(Function), + expect.any(Number), + ); + }); + + it('degrades gracefully when the queue source fails', async () => { + queueMonitoring.getQueueMetrics.mockRejectedValue(new Error('redis down')); + + const snapshot = await service.buildSnapshot(); + expect(snapshot.queue).toBeNull(); + expect(snapshot.properties).not.toBeNull(); + }); + + it('degrades gracefully when the database source fails', async () => { + prisma.property.count.mockRejectedValue(new Error('db down')); + + const snapshot = await service.buildSnapshot(); + expect(snapshot.properties).toBeNull(); + expect(snapshot.fraud).not.toBeNull(); + }); + + it('computes directly if the cache itself throws', async () => { + cacheService.getOrSet.mockRejectedValue(new Error('cache down')); + + const snapshot = await service.getSnapshot(); + expect(snapshot.properties).toEqual({ total: 100, active: 80, createdLast24h: 7 }); + }); +}); diff --git a/src/analytics/dashboard-metrics.service.ts b/src/analytics/dashboard-metrics.service.ts new file mode 100644 index 00000000..7316ed85 --- /dev/null +++ b/src/analytics/dashboard-metrics.service.ts @@ -0,0 +1,172 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { CacheService } from '../cache/cache.service'; +import { QueueMonitoringService } from '../admin/queue/queue.service'; +import { + FraudSeverity, + FraudStatus, + PropertyStatus, + TransactionStatus, +} from '../types/prisma.types'; + +/** + * Realtime dashboard aggregate feed (issue #1297). + * + * Computes low-frequency aggregate snapshots for the admin/agent dashboard and + * caches them briefly so that the Socket.IO push loop never pressures the + * database. Each individual metric degrades gracefully — a single failing + * source (e.g. Redis or BullMQ) yields `null` for that section rather than + * failing the whole snapshot. + */ +export interface DashboardQueueMetric { + queue: string; + depth: number; + waiting: number; + active: number; + failed: number; +} + +export interface DashboardSnapshot { + generatedAt: string; + queue: { totalDepth: number; queues: DashboardQueueMetric[] } | null; + fraud: { open: number; investigating: number; highSeverityOpen: number } | null; + properties: { total: number; active: number; createdLast24h: number } | null; + transactions: { + total: number; + pending: number; + completed: number; + createdLast24h: number; + } | null; +} + +export const DASHBOARD_SNAPSHOT_KEY = 'analytics:dashboard:snapshot'; +export const DASHBOARD_SNAPSHOT_TTL_SECONDS = 5; + +@Injectable() +export class DashboardMetricsService { + private readonly logger = new Logger(DashboardMetricsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly cacheService: CacheService, + private readonly queueMonitoring: QueueMonitoringService, + ) {} + + /** + * Return the current snapshot, served from a short-lived cache so a burst of + * subscribers results in at most one computation per TTL window. + */ + async getSnapshot(): Promise { + try { + return await this.cacheService.getOrSet( + DASHBOARD_SNAPSHOT_KEY, + () => this.buildSnapshot(), + DASHBOARD_SNAPSHOT_TTL_SECONDS, + ); + } catch (error) { + // Cache unavailable — compute directly rather than dropping the feed. + this.logger.warn( + `Dashboard snapshot cache unavailable, computing directly: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return this.buildSnapshot(); + } + } + + /** Compute a fresh snapshot from the backing stores. */ + async buildSnapshot(): Promise { + const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const [queue, fraud, properties, transactions] = await Promise.all([ + this.collectQueue(), + this.collectFraud(), + this.collectProperties(since24h), + this.collectTransactions(since24h), + ]); + + return { + generatedAt: new Date().toISOString(), + queue, + fraud, + properties, + transactions, + }; + } + + private async collectQueue(): Promise { + try { + const { metrics } = await this.queueMonitoring.getQueueMetrics(); + const queues: DashboardQueueMetric[] = metrics.map((metric: any) => ({ + queue: metric.queue, + depth: metric.depth ?? 0, + waiting: metric.waiting ?? 0, + active: metric.active ?? 0, + failed: metric.failed ?? 0, + })); + return { + totalDepth: queues.reduce((sum, queue) => sum + queue.depth, 0), + queues, + }; + } catch (error) { + this.logger.warn( + `Queue metrics unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + + private async collectFraud(): Promise { + try { + const [open, investigating, highSeverityOpen] = await Promise.all([ + this.prisma.fraudAlert.count({ where: { status: FraudStatus.OPEN } }), + this.prisma.fraudAlert.count({ where: { status: FraudStatus.INVESTIGATING } }), + this.prisma.fraudAlert.count({ + where: { + status: FraudStatus.OPEN, + severity: { in: [FraudSeverity.HIGH, FraudSeverity.CRITICAL] }, + }, + }), + ]); + return { open, investigating, highSeverityOpen }; + } catch (error) { + this.logger.warn( + `Fraud metrics unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + + private async collectProperties(since24h: Date): Promise { + try { + const [total, active, createdLast24h] = await Promise.all([ + this.prisma.property.count({ where: { deleted: false } }), + this.prisma.property.count({ where: { deleted: false, status: PropertyStatus.ACTIVE } }), + this.prisma.property.count({ where: { deleted: false, createdAt: { gte: since24h } } }), + ]); + return { total, active, createdLast24h }; + } catch (error) { + this.logger.warn( + `Property metrics unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + + private async collectTransactions(since24h: Date): Promise { + try { + const [total, pending, completed, createdLast24h] = await Promise.all([ + this.prisma.transaction.count(), + this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), + this.prisma.transaction.count({ where: { createdAt: { gte: since24h } } }), + ]); + return { total, pending, completed, createdLast24h }; + } catch (error) { + this.logger.warn( + `Transaction metrics unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 219f18a4..438751a0 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { ApiKeyAnalyticsService } from './api-key-analytics.service'; @@ -6,6 +6,7 @@ import { ChangePasswordDto, CreateApiKeyDto, DisableTwoFactorDto, + ForceDisableTwoFactorDto, LoginDto, LogoutDto, RefreshTokenDto, @@ -16,6 +17,7 @@ import { VerifyTwoFactorDto, } from './dto/auth.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { FreshTwoFactorGuard } from './guards/fresh-two-factor.guard'; import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; import { GoogleAuthGuard } from './guards/google-auth.guard'; import { RolesGuard } from './guards/roles.guard'; @@ -93,7 +95,7 @@ export class AuthController { } @ApiBearerAuth('access-token') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FreshTwoFactorGuard) @Post('change-password') changePassword( @CurrentUser() user: AuthUserPayload, @@ -120,7 +122,7 @@ export class AuthController { } @ApiBearerAuth('access-token') - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FreshTwoFactorGuard) @Post('2fa/disable') disableTwoFactor( @CurrentUser() user: AuthUserPayload, @@ -129,6 +131,38 @@ export class AuthController { return this.authService.disableTwoFactor(user, disableTwoFactorDto.password); } + @ApiBearerAuth('access-token') + @UseGuards(JwtAuthGuard, FreshTwoFactorGuard) + @Post('2fa/recovery-codes') + regenerateRecoveryCodes(@CurrentUser() user: AuthUserPayload) { + return this.authService.regenerateRecoveryCodes(user); + } + + @ApiBearerAuth('access-token') + @UseGuards(JwtAuthGuard) + @Get('2fa/devices') + listTrustedDevices(@CurrentUser() user: AuthUserPayload) { + return this.authService.listTrustedDevices(user); + } + + @ApiBearerAuth('access-token') + @UseGuards(JwtAuthGuard) + @Delete('2fa/devices/:id') + revokeTrustedDevice(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { + return this.authService.revokeTrustedDevice(user, id); + } + + @ApiBearerAuth('access-token') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post('2fa/admin/force-disable') + adminForceDisableTwoFactor( + @CurrentUser() user: AuthUserPayload, + @Body() dto: ForceDisableTwoFactorDto, + ) { + return this.authService.adminForceDisableTwoFactor(user, dto.email); + } + @UseGuards(ApiKeyAuthGuard) @Get('api-keys/validate') validateApiKey(@CurrentUser() user: AuthUserPayload) { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e5e9180a..3902ba8a 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -10,7 +10,9 @@ import { ApiKeyAnalyticsService } from './api-key-analytics.service'; import { LoginRateLimitService } from './login-rate-limit.service'; import { RateLimitService } from './rate-limit.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { FreshTwoFactorGuard } from './guards/fresh-two-factor.guard'; import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; +import { TwoFactorService } from './two-factor.service'; import { GoogleStrategy } from './strategies/google.strategy'; import { RolesGuard } from './guards/roles.guard'; import { RateLimitGuard } from './guards/rate-limit.guard'; @@ -27,15 +29,19 @@ import { FraudModule } from '../fraud/fraud.module'; LoginRateLimitService, RateLimitService, JwtAuthGuard, + FreshTwoFactorGuard, ApiKeyAuthGuard, RolesGuard, RateLimitGuard, RateLimitHeadersInterceptor, GoogleStrategy, + TwoFactorService, ], exports: [ AuthService, ApiKeyAnalyticsService, + TwoFactorService, + JwtAuthGuard, RolesGuard, LoginRateLimitService, RateLimitService, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d78d7bd9..4078c663 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -26,6 +26,7 @@ import { UpdateApiKeyPermissionsDto, VerifyTwoFactorDto, } from './dto/auth.dto'; +import { TwoFactorService } from './two-factor.service'; import { buildOtpAuthUrl, buildQrCodeUrl, @@ -112,6 +113,7 @@ export class AuthService { private readonly rateLimitService: LoginRateLimitService, private readonly fraudService: FraudService, @Optional() private readonly apiKeyAnalyticsService?: ApiKeyAnalyticsService, + @Optional() private readonly twoFactorService?: TwoFactorService, ) { const jwtSecret = this.configService.get('JWT_SECRET'); if (!jwtSecret || jwtSecret.length < MIN_JWT_SECRET_LENGTH) { @@ -377,7 +379,16 @@ export class AuthService { await this.verifyCredentials(user, data.password, ipAddress, userAgent); - if (user.twoFactorEnabled) { + // #1291 — a previously trusted device may skip the second-factor challenge. + let trustedDeviceAccepted = false; + if (user.twoFactorEnabled && this.twoFactorService) { + trustedDeviceAccepted = await this.twoFactorService.isTrustedDevice( + user.id, + data.trustedDeviceToken, + ); + } + + if (user.twoFactorEnabled && !trustedDeviceAccepted) { const hasTotpCode = Boolean(data.totpCode?.trim()); const hasBackupCode = Boolean(data.backupCode?.trim()); @@ -385,10 +396,10 @@ export class AuthService { throw new UnauthorizedException('Two-factor authentication code required'); } - if (hasTotpCode && user.twoFactorSecret) { + if (hasTotpCode) { const totpCode = data.totpCode; - if (!totpCode) { - throw new UnauthorizedException('Two-factor authentication code required'); + if (!totpCode || !user.twoFactorSecret) { + throw new UnauthorizedException('Invalid two-factor authentication code'); } const validCode = verifyTotpCode({ @@ -418,6 +429,10 @@ export class AuthService { }, }, }); + } else { + // 2FA is enabled but no usable factor was supplied — never fall through + // without a successful challenge. + throw new UnauthorizedException('Two-factor authentication code required'); } } @@ -454,9 +469,31 @@ export class AuthService { } const tokens = await this.issueTokenPair(refreshedUser, undefined, ipAddress, userAgent); + + // #1291 — optionally remember this device so subsequent logins can skip the + // challenge. Only issued when the user actually proved the second factor. + let trustedDevice: { token: string; device: { expiresAt: Date } } | null = null; + if ( + data.rememberDevice && + refreshedUser.twoFactorEnabled && + !trustedDeviceAccepted && + this.twoFactorService + ) { + trustedDevice = await this.twoFactorService.rememberDevice(refreshedUser.id, { + userAgent, + ipAddress, + }); + } + return { user: sanitizeUser(refreshedUser), ...tokens, + ...(trustedDevice + ? { + trustedDeviceToken: trustedDevice.token, + trustedDeviceExpiresAt: trustedDevice.device.expiresAt, + } + : {}), }; } @@ -947,6 +984,8 @@ export class AuthService { }); await this.sessionsService.revokeAllSessions(existingUser.id); + // #1291 — a password change invalidates remembered devices too. + await this.twoFactorService?.revokeAllDevices(existingUser.id); return { message: 'Password updated successfully' }; } @@ -1051,6 +1090,9 @@ export class AuthService { }, }); + // #1291 — disabling 2FA makes any remembered device meaningless. + await this.twoFactorService?.revokeAllDevices(foundUser.id); + // Audit log 2FA disable (#886) await this.prisma.activityLog .create({ @@ -1069,6 +1111,104 @@ export class AuthService { return { message: 'Two-factor authentication disabled successfully' }; } + // ─── Two-factor management (#1291) ───────────────────────────────────── + + /** + * Issue a fresh set of recovery codes. Requires fresh 2FA at the controller + * layer (FreshTwoFactorGuard) so possession of the account is proven. + */ + async regenerateRecoveryCodes(user: AuthUserPayload) { + if (!this.twoFactorService) { + throw new ServiceUnavailableException('Two-factor service unavailable'); + } + + const foundUser = await this.prisma.user.findUnique({ where: { id: user.sub } }); + if (!foundUser) { + throw new NotFoundException('User not found'); + } + if (!foundUser.twoFactorEnabled) { + throw new BadRequestException('Two-factor authentication is not enabled'); + } + + const recoveryCodes = await this.twoFactorService.regenerateRecoveryCodes(user.sub); + + await this.prisma.activityLog + .create({ + data: { + userId: user.sub, + action: 'TWO_FACTOR_RECOVERY_CODES_REGENERATED', + entityType: 'USER', + entityId: user.sub, + description: 'User regenerated two-factor recovery codes', + }, + }) + .catch((err) => { + this.logger.error(`Failed to audit-log recovery-code regeneration: ${err}`); + }); + + return { recoveryCodes }; + } + + /** List the devices trusted for this user (#1291). */ + async listTrustedDevices(user: AuthUserPayload) { + if (!this.twoFactorService) { + return { devices: [] }; + } + const devices = await this.twoFactorService.listDevices(user.sub); + return { devices }; + } + + /** Revoke a single trusted device (#1291). */ + async revokeTrustedDevice(user: AuthUserPayload, deviceId: string) { + if (!this.twoFactorService) { + throw new ServiceUnavailableException('Two-factor service unavailable'); + } + return this.twoFactorService.revokeDevice(user.sub, deviceId); + } + + /** + * Admin-initiated reset of a user's two-factor configuration (#1291). Used + * when a user is locked out — it clears the secret, recovery codes and all + * trusted devices. + */ + async adminForceDisableTwoFactor(admin: AuthUserPayload, email: string) { + const target = await this.prisma.user.findUnique({ + where: { email: email.trim().toLowerCase() }, + }); + + if (!target) { + throw new NotFoundException('User not found'); + } + + await this.prisma.user.update({ + where: { id: target.id }, + data: { + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: { set: [] }, + }, + }); + + await this.twoFactorService?.revokeAllDevices(target.id); + + await this.prisma.activityLog + .create({ + data: { + userId: target.id, + action: 'TWO_FACTOR_ADMIN_FORCE_DISABLED', + entityType: 'USER', + entityId: target.id, + description: `Two-factor authentication force-disabled by admin ${admin.sub}`, + metadata: { adminId: admin.sub }, + }, + }) + .catch((err) => { + this.logger.error(`Failed to audit-log admin 2FA reset: ${err}`); + }); + + return { message: 'Two-factor authentication has been reset', userId: target.id }; + } + async createApiKey(user: AuthUserPayload, data: CreateApiKeyDto) { const apiKeyValue = this.generateApiKeyValue(); const permissions = this.normalizePermissions(data.permissions); diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts index 44f69cfa..84c2875c 100644 --- a/src/auth/dto/auth.dto.ts +++ b/src/auth/dto/auth.dto.ts @@ -1,5 +1,6 @@ import { IsArray, + IsBoolean, IsDateString, IsEmail, IsNotEmpty, @@ -50,6 +51,16 @@ export class LoginDto { @IsString() backupCode?: string; + /** Token issued for a previously trusted device (#1291). */ + @IsOptional() + @IsString() + trustedDeviceToken?: string; + + /** When true, remember this device after a successful 2FA challenge (#1291). */ + @IsOptional() + @IsBoolean() + rememberDevice?: boolean; + @IsOptional() @IsString() captchaToken?: string; @@ -85,6 +96,15 @@ export class DisableTwoFactorDto { password: string; } +/** + * Admin-initiated 2FA reset (#1291). Identifies the target account by email so + * a locked-out user can be recovered without exposing internal IDs. + */ +export class ForceDisableTwoFactorDto { + @IsEmail() + email: string; +} + export class CreateApiKeyDto { @IsString() @IsNotEmpty() diff --git a/src/auth/guards/fresh-two-factor.guard.spec.ts b/src/auth/guards/fresh-two-factor.guard.spec.ts new file mode 100644 index 00000000..f56a951a --- /dev/null +++ b/src/auth/guards/fresh-two-factor.guard.spec.ts @@ -0,0 +1,128 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { FreshTwoFactorGuard } from './fresh-two-factor.guard'; +import { PrismaService } from '../../database/prisma.service'; +import { createSha256, generateTotpCode } from '../security.utils'; + +const SECRET = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'; + +function contextFor(request: Record): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; +} + +describe('FreshTwoFactorGuard (#1291)', () => { + let guard: FreshTwoFactorGuard; + + const prisma = { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + guard = new FreshTwoFactorGuard(prisma as unknown as PrismaService); + }); + + it('throws when there is no authenticated user', async () => { + await expect(guard.canActivate(contextFor({}))).rejects.toThrow(ForbiddenException); + }); + + it('allows the request when 2FA is not enabled', async () => { + prisma.user.findUnique.mockResolvedValue({ + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: [], + }); + + await expect(guard.canActivate(contextFor({ authUser: { sub: 'user-1' } }))).resolves.toBe( + true, + ); + }); + + it('requires a code when 2FA is enabled', async () => { + prisma.user.findUnique.mockResolvedValue({ + twoFactorEnabled: true, + twoFactorSecret: SECRET, + twoFactorBackupCodes: [], + }); + + await expect( + guard.canActivate(contextFor({ authUser: { sub: 'user-1' }, headers: {}, body: {} })), + ).rejects.toThrow(/Fresh two-factor authentication required/); + }); + + it('accepts a current TOTP code from the header', async () => { + prisma.user.findUnique.mockResolvedValue({ + twoFactorEnabled: true, + twoFactorSecret: SECRET, + twoFactorBackupCodes: [], + }); + + await expect( + guard.canActivate( + contextFor({ + authUser: { sub: 'user-1' }, + headers: { 'x-2fa-code': generateTotpCode({ secret: SECRET }) }, + body: {}, + }), + ), + ).resolves.toBe(true); + }); + + it('accepts a TOTP code from the body as a fallback', async () => { + prisma.user.findUnique.mockResolvedValue({ + twoFactorEnabled: true, + twoFactorSecret: SECRET, + twoFactorBackupCodes: [], + }); + + await expect( + guard.canActivate( + contextFor({ + authUser: { sub: 'user-1' }, + headers: {}, + body: { totpCode: generateTotpCode({ secret: SECRET }) }, + }), + ), + ).resolves.toBe(true); + }); + + it('rejects an invalid code', async () => { + prisma.user.findUnique.mockResolvedValue({ + twoFactorEnabled: true, + twoFactorSecret: SECRET, + twoFactorBackupCodes: [], + }); + + await expect( + guard.canActivate( + contextFor({ authUser: { sub: 'user-1' }, headers: {}, body: { code: '000000' } }), + ), + ).rejects.toThrow(/Invalid two-factor authentication code/); + }); + + it('consumes a valid recovery code so it cannot be reused', async () => { + const recoveryCode = 'ABCD1234'; + const hash = createSha256(recoveryCode); + prisma.user.findUnique.mockResolvedValue({ + twoFactorEnabled: true, + twoFactorSecret: SECRET, + twoFactorBackupCodes: [hash], + }); + prisma.user.update.mockResolvedValue({}); + + await expect( + guard.canActivate( + contextFor({ authUser: { sub: 'user-1' }, headers: {}, body: { code: recoveryCode } }), + ), + ).resolves.toBe(true); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { twoFactorBackupCodes: { set: [] } }, + }); + }); +}); diff --git a/src/auth/guards/fresh-two-factor.guard.ts b/src/auth/guards/fresh-two-factor.guard.ts new file mode 100644 index 00000000..1d530206 --- /dev/null +++ b/src/auth/guards/fresh-two-factor.guard.ts @@ -0,0 +1,97 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { verifyBackupCode, verifyTotpCode } from '../security.utils'; + +/** + * Guard for high-risk operations (issue #1291). + * + * When the authenticated user has two-factor authentication enabled, the + * request must carry a fresh second factor — either a current TOTP code or an + * unused recovery code — in the `x-2fa-code` header (or a `totpCode` / + * `twoFactorCode` / `code` body field). This ensures that a trusted-device + * session or a stale access token cannot, on its own, perform sensitive + * actions such as changing the password or disabling 2FA. + * + * Users without 2FA enabled are unaffected. + * + * Must be used together with `JwtAuthGuard` and listed after it so that + * `request.authUser` is populated first. + */ +@Injectable() +export class FreshTwoFactorGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authUser = request.authUser as { sub?: string } | undefined; + + if (!authUser?.sub) { + throw new ForbiddenException('Authentication required'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: authUser.sub }, + select: { + twoFactorEnabled: true, + twoFactorSecret: true, + twoFactorBackupCodes: true, + }, + }); + + if (!user) { + throw new ForbiddenException('Authentication required'); + } + + // Nothing to enforce when the user has not enabled 2FA. + if (!user.twoFactorEnabled) { + return true; + } + + const code = this.extractCode(request); + if (!code) { + throw new ForbiddenException( + 'Fresh two-factor authentication required. Provide a current TOTP or recovery code.', + ); + } + + if (user.twoFactorSecret && verifyTotpCode({ secret: user.twoFactorSecret, code })) { + return true; + } + + const matchingRecoveryCode = verifyBackupCode(code, user.twoFactorBackupCodes ?? []); + if (matchingRecoveryCode) { + // Recovery codes are single-use — consume it before allowing the action. + await this.prisma.user.update({ + where: { id: authUser.sub }, + data: { + twoFactorBackupCodes: { + set: (user.twoFactorBackupCodes ?? []).filter((hash) => hash !== matchingRecoveryCode), + }, + }, + }); + return true; + } + + throw new ForbiddenException('Invalid two-factor authentication code'); + } + + private extractCode(request: { + headers?: Record; + body?: Record; + }): string | undefined { + const header = request.headers?.['x-2fa-code']; + if (typeof header === 'string' && header.trim()) { + return header.trim(); + } + + const body = request.body ?? {}; + for (const key of ['totpCode', 'twoFactorCode', 'code']) { + const value = body[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + + return undefined; + } +} diff --git a/src/auth/two-factor.service.spec.ts b/src/auth/two-factor.service.spec.ts new file mode 100644 index 00000000..2421f0ac --- /dev/null +++ b/src/auth/two-factor.service.spec.ts @@ -0,0 +1,171 @@ +import { NotFoundException } from '@nestjs/common'; +import { TwoFactorService } from './two-factor.service'; +import { PrismaService } from '../database/prisma.service'; +import { createSha256 } from './security.utils'; + +describe('TwoFactorService (#1291)', () => { + let service: TwoFactorService; + + const prisma = { + trustedDevice: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + findMany: jest.fn(), + }, + user: { + update: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TwoFactorService(prisma as unknown as PrismaService); + }); + + describe('isTrustedDevice', () => { + const baseDevice = { + id: 'device-1', + userId: 'user-1', + tokenHash: createSha256('device-token'), + label: null, + userAgent: null, + ipAddress: null, + createdAt: new Date(), + lastUsedAt: null, + expiresAt: new Date(Date.now() + 60_000), + revokedAt: null as Date | null, + }; + + it('returns false for missing or blank tokens without querying', async () => { + expect(await service.isTrustedDevice('user-1', undefined)).toBe(false); + expect(await service.isTrustedDevice('user-1', ' ')).toBe(false); + expect(prisma.trustedDevice.findUnique).not.toHaveBeenCalled(); + }); + + it('accepts a valid device token and refreshes lastUsedAt', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue(baseDevice); + prisma.trustedDevice.update.mockResolvedValue(baseDevice); + + expect(await service.isTrustedDevice('user-1', 'device-token')).toBe(true); + expect(prisma.trustedDevice.findUnique).toHaveBeenCalledWith({ + where: { tokenHash: createSha256('device-token') }, + }); + expect(prisma.trustedDevice.update).toHaveBeenCalledWith({ + where: { id: 'device-1' }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + it('rejects a token that belongs to a different user', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue({ ...baseDevice, userId: 'someone-else' }); + + expect(await service.isTrustedDevice('user-1', 'device-token')).toBe(false); + }); + + it('rejects revoked and expired devices', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue({ ...baseDevice, revokedAt: new Date() }); + expect(await service.isTrustedDevice('user-1', 'device-token')).toBe(false); + + prisma.trustedDevice.findUnique.mockResolvedValue({ + ...baseDevice, + expiresAt: new Date(Date.now() - 1000), + }); + expect(await service.isTrustedDevice('user-1', 'device-token')).toBe(false); + }); + }); + + describe('rememberDevice', () => { + it('stores only a hash and returns the one-time token', async () => { + prisma.trustedDevice.create.mockImplementation(async ({ data }: any) => ({ + id: 'device-2', + ...data, + })); + + const { token, device } = await service.rememberDevice('user-1', { + userAgent: 'jest', + ipAddress: '127.0.0.1', + }); + + expect(token).toEqual(expect.any(String)); + expect(token.length).toBeGreaterThan(20); + + const createArgs = prisma.trustedDevice.create.mock.calls[0][0]; + expect(createArgs.data.tokenHash).toBe(createSha256(token)); + expect(createArgs.data.userId).toBe('user-1'); + expect(createArgs.data.expiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(device.id).toBe('device-2'); + expect(device).not.toHaveProperty('tokenHash'); + }); + }); + + describe('revokeDevice', () => { + it('throws for an unknown device', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue(null); + await expect(service.revokeDevice('user-1', 'missing')).rejects.toThrow(NotFoundException); + }); + + it('forbids revoking another user\u2019s device', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue({ id: 'd', userId: 'owner' }); + await expect(service.revokeDevice('intruder', 'd')).rejects.toThrow(NotFoundException); + }); + + it('revokes an active device exactly once', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue({ + id: 'd', + userId: 'user-1', + revokedAt: null, + }); + prisma.trustedDevice.update.mockResolvedValue({}); + + const result = await service.revokeDevice('user-1', 'd'); + expect(result).toEqual({ id: 'd', alreadyRevoked: false }); + expect(prisma.trustedDevice.update).toHaveBeenCalledWith({ + where: { id: 'd' }, + data: { revokedAt: expect.any(Date) }, + }); + }); + + it('is idempotent for an already-revoked device', async () => { + prisma.trustedDevice.findUnique.mockResolvedValue({ + id: 'd', + userId: 'user-1', + revokedAt: new Date(), + }); + + const result = await service.revokeDevice('user-1', 'd'); + expect(result.alreadyRevoked).toBe(true); + expect(prisma.trustedDevice.update).not.toHaveBeenCalled(); + }); + }); + + it('revokeAllDevices revokes only active devices and returns the count', async () => { + prisma.trustedDevice.updateMany.mockResolvedValue({ count: 3 }); + + expect(await service.revokeAllDevices('user-1')).toBe(3); + expect(prisma.trustedDevice.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + }); + + it('regenerateRecoveryCodes stores hashes and returns plaintext codes', async () => { + prisma.user.update.mockResolvedValue({}); + + const codes = await service.regenerateRecoveryCodes('user-1'); + + expect(codes).toHaveLength(8); + const updateArgs = prisma.user.update.mock.calls[0][0]; + expect(updateArgs.data.twoFactorBackupCodes.set).toHaveLength(8); + expect(updateArgs.data.twoFactorBackupCodes.set[0]).toBe(createSha256(codes[0])); + }); + + it('matches a recovery code against stored hashes', () => { + const codes = ['ABCD1234', 'EFGH5678']; + const hashes = codes.map(createSha256); + + expect(service.matchRecoveryCode('abcd1234', hashes)).toBe(hashes[0]); + expect(service.matchRecoveryCode('nope', hashes)).toBeUndefined(); + }); +}); diff --git a/src/auth/two-factor.service.ts b/src/auth/two-factor.service.ts new file mode 100644 index 00000000..280d4844 --- /dev/null +++ b/src/auth/two-factor.service.ts @@ -0,0 +1,203 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { + createSha256, + generateBackupCodes, + randomToken, + verifyBackupCode, + verifyTotpCode, +} from './security.utils'; + +/** + * Trusted-device and recovery-code handling for TOTP two-factor auth (#1291). + * + * Trusted devices let a user skip the TOTP challenge on hardware they have + * explicitly remembered after a successful second-factor check. Only a + * SHA-256 hash of the device token is persisted; the raw token is returned to + * the client exactly once. + */ +export interface TrustedDeviceSummary { + id: string; + label: string | null; + userAgent: string | null; + ipAddress: string | null; + createdAt: Date; + lastUsedAt: Date | null; + expiresAt: Date; +} + +export interface RememberDeviceOptions { + label?: string; + userAgent?: string; + ipAddress?: string; +} + +export const DEFAULT_TRUSTED_DEVICE_TTL_DAYS = 30; + +@Injectable() +export class TwoFactorService { + private readonly logger = new Logger(TwoFactorService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** Effective trusted-device lifetime in milliseconds. */ + get trustedDeviceTtlMs(): number { + const parsed = parseInt(process.env.TRUSTED_DEVICE_TTL_DAYS ?? '', 10); + const days = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TRUSTED_DEVICE_TTL_DAYS; + return days * 24 * 60 * 60 * 1000; + } + + /** + * Return true when the supplied device token belongs to the user, has not + * been revoked and has not expired. Usage refreshes `lastUsedAt`. + */ + async isTrustedDevice(userId: string, token?: string | null): Promise { + const normalized = token?.trim(); + if (!normalized) { + return false; + } + + const device = await this.prisma.trustedDevice.findUnique({ + where: { tokenHash: createSha256(normalized) }, + }); + + if (!device || device.userId !== userId || device.revokedAt) { + return false; + } + + if (device.expiresAt <= new Date()) { + return false; + } + + await this.prisma.trustedDevice + .update({ where: { id: device.id }, data: { lastUsedAt: new Date() } }) + .catch((error: unknown) => { + this.logger.warn( + `Failed to touch trusted device ${device.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + + return true; + } + + /** + * Persist a new trusted device and return the one-time plaintext token plus + * the device record. + */ + async rememberDevice( + userId: string, + options: RememberDeviceOptions = {}, + ): Promise<{ token: string; device: TrustedDeviceSummary }> { + const token = randomToken(32); + const expiresAt = new Date(Date.now() + this.trustedDeviceTtlMs); + + const device = await this.prisma.trustedDevice.create({ + data: { + userId, + tokenHash: createSha256(token), + label: options.label ?? null, + userAgent: options.userAgent ?? null, + ipAddress: options.ipAddress ?? null, + expiresAt, + }, + }); + + return { token, device: this.toSummary(device) }; + } + + /** List the user's active (unrevoked, unexpired) trusted devices. */ + async listDevices(userId: string): Promise { + const devices = await this.prisma.trustedDevice.findMany({ + where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'desc' }, + }); + + return devices.map((device) => this.toSummary(device)); + } + + /** Revoke a single trusted device. Idempotent for already-revoked devices. */ + async revokeDevice( + userId: string, + deviceId: string, + ): Promise<{ id: string; alreadyRevoked: boolean }> { + const device = await this.prisma.trustedDevice.findUnique({ where: { id: deviceId } }); + + if (!device || device.userId !== userId) { + throw new NotFoundException('Trusted device not found'); + } + + if (device.revokedAt) { + return { id: device.id, alreadyRevoked: true }; + } + + await this.prisma.trustedDevice.update({ + where: { id: device.id }, + data: { revokedAt: new Date() }, + }); + + return { id: device.id, alreadyRevoked: false }; + } + + /** + * Revoke every active trusted device for a user. Called whenever the second + * factor changes (password change, 2FA disable/reset) so previously trusted + * hardware cannot bypass the new state. + */ + async revokeAllDevices(userId: string): Promise { + const result = await this.prisma.trustedDevice.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + return result.count; + } + + /** + * Issue a fresh set of recovery codes, replacing any existing ones. Returns + * the plaintext codes once — they are stored only as hashes. + */ + async regenerateRecoveryCodes(userId: string): Promise { + const codes = generateBackupCodes(); + await this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorBackupCodes: { set: codes.map((code) => createSha256(code)) } }, + }); + return codes; + } + + /** + * Verify a fresh TOTP code against the user's secret. Recovery codes are not + * consumed here — callers that require single-use semantics should consume + * them explicitly. + */ + verifyTotp(secret: string, code: string): boolean { + return verifyTotpCode({ secret, code }); + } + + /** Find a matching recovery code hash, or undefined. */ + matchRecoveryCode(code: string, hashes: string[] | null | undefined): string | undefined { + return verifyBackupCode(code, hashes ?? []); + } + + private toSummary(device: { + id: string; + label: string | null; + userAgent: string | null; + ipAddress: string | null; + createdAt: Date; + lastUsedAt: Date | null; + expiresAt: Date; + }): TrustedDeviceSummary { + return { + id: device.id, + label: device.label, + userAgent: device.userAgent, + ipAddress: device.ipAddress, + createdAt: device.createdAt, + lastUsedAt: device.lastUsedAt, + expiresAt: device.expiresAt, + }; + } +} diff --git a/src/cache/cache-warming.service.ts b/src/cache/cache-warming.service.ts index bc7889d8..6e9d2bf3 100644 --- a/src/cache/cache-warming.service.ts +++ b/src/cache/cache-warming.service.ts @@ -4,8 +4,9 @@ * Strategy: * 1. **Startup warming** – OnModuleInit loads the hottest data into Redis * immediately so the first users after a deploy don't see cold-cache latency. - * 2. **Periodic refresh** – A @Cron job re-warms data every 30 minutes to - * keep it fresh without waiting for natural expiry. + * 2. **Periodic refresh** – A timer re-warms data on the interval configured + * by CACHE_WARMING_INTERVAL (default 30 minutes) to keep it fresh without + * waiting for natural expiry. * 3. **Predictive warming** – Analyses access patterns (recent hit-rate trends * stored in Redis) to proactively warm keys that are about to become hot. * 4. **Hit-rate monitoring** – Each warming cycle logs the cache hit-rate before @@ -13,12 +14,28 @@ */ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; import { CacheService } from './cache.service'; import { CacheMonitoringService } from './cache-monitoring.service'; import { CACHE_KEYS, CACHE_TTL } from './cache.config'; import { PrismaService } from '../database/prisma.service'; +/** + * Default warming interval (30 minutes) used when CACHE_WARMING_INTERVAL is + * not set or is not a positive integer. + */ +export const DEFAULT_WARMING_INTERVAL_MS = 30 * 60 * 1000; + +/** + * Resolve the configured warming interval in milliseconds. + * + * Issue #1290 – reconcile the documented `CACHE_WARMING_INTERVAL` knob with the + * scheduling truth instead of hard-coding a 30-minute cron. + */ +export function resolveWarmingIntervalMs(raw = process.env.CACHE_WARMING_INTERVAL): number { + const parsed = parseInt(raw ?? '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_WARMING_INTERVAL_MS; +} + @Injectable() export class CacheWarmingService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(CacheWarmingService.name); @@ -33,10 +50,11 @@ export class CacheWarmingService implements OnModuleInit, OnModuleDestroy { // ─── Lifecycle ──────────────────────────────────────────────────────── async onModuleInit(): Promise { - if (process.env.CACHE_WARMING_ENABLED !== 'false') { - this.logger.log('Starting initial cache warming…'); - await this.warmCache(); - } + if (process.env.CACHE_WARMING_ENABLED === 'false') return; + + this.logger.log('Starting initial cache warming…'); + await this.warmCache(); + this.schedulePeriodicWarming(); } onModuleDestroy(): void { @@ -47,9 +65,22 @@ export class CacheWarmingService implements OnModuleInit, OnModuleDestroy { } } - // ─── Periodic warming (every 30 min) ───────────────────────────────── + // ─── Periodic warming ──────────────────────────────────────────────── + + /** + * Start the periodic warming timer. The interval is configurable via + * CACHE_WARMING_INTERVAL (ms) and defaults to 30 minutes. + */ + private schedulePeriodicWarming(): void { + const intervalMs = resolveWarmingIntervalMs(); + this.warmingInterval = setInterval(() => { + void this.handlePeriodicWarming(); + }, intervalMs); + // Do not keep the process alive solely for cache warming. + this.warmingInterval.unref?.(); + this.logger.log(`Periodic cache warming scheduled every ${intervalMs}ms`); + } - @Cron(CronExpression.EVERY_30_MINUTES) async handlePeriodicWarming(): Promise { if (process.env.CACHE_WARMING_ENABLED === 'false') return; this.logger.log('Periodic cache warming triggered'); diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts index b3c3c6af..32a19712 100644 --- a/src/database/prisma.service.ts +++ b/src/database/prisma.service.ts @@ -17,8 +17,48 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/commo import { PrismaClient } from '@prisma/client'; import { N1Detector, isN1DetectionEnabled, n1OptionsFromEnv } from './n1-detector'; -const POOL_SIZE_DEFAULT = 10; -const POOL_TIMEOUT_MS = 10_000; +export const POOL_SIZE_DEFAULT = 10; +export const POOL_TIMEOUT_MS_DEFAULT = 10_000; + +/** + * Build the effective PostgreSQL datasource URL, folding the pool tuning knobs + * (`PGBOUNCER_POOL_SIZE` / `PGBOUNCER_POOL_TIMEOUT`) into the connection string + * parameters understood by Prisma's Rust query engine (`connection_limit` and + * `pool_timeout`, the latter in seconds). + * + * When PgBouncer is enabled, Prisma's built-in pool is disabled and the + * timeout is enforced server-side by PgBouncer, so the URL is returned + * unchanged. + * + * Issue #1290 – declared-but-unused pool timeout config. + */ +export function buildDatasourceUrl(options: { + databaseUrl?: string; + pgbouncerEnabled: boolean; + poolSize: number; + poolTimeoutMs: number; +}): string | undefined { + const { databaseUrl, pgbouncerEnabled, poolSize, poolTimeoutMs } = options; + if (!databaseUrl) return databaseUrl; + + // PgBouncer owns the pool (and therefore the timeout) when enabled. + if (pgbouncerEnabled) return databaseUrl; + + try { + const url = new URL(databaseUrl); + if (!url.searchParams.has('connection_limit')) { + url.searchParams.set('connection_limit', String(poolSize)); + } + if (!url.searchParams.has('pool_timeout')) { + // Prisma expects pool_timeout in seconds. + url.searchParams.set('pool_timeout', String(Math.max(1, Math.round(poolTimeoutMs / 1000)))); + } + return url.toString(); + } catch { + // Non-standard / non-URL connection strings are passed through untouched. + return databaseUrl; + } +} /** Slow-query thresholds (ms). Queries exceeding these trigger a warning log. */ const SLOW_QUERY_THRESHOLD_DEV = 100; @@ -43,19 +83,21 @@ const RETRYABLE_ERROR_CODES = new Set([ */ export function scrubQuery(query: string): string { if (!query) return ''; - return query - // Replace $n parameter placeholders ($1, $2, etc.) - .replace(/\$\d+/g, '?') - // Replace single-quoted string literals: '...' - .replace(/'(?:[^'\\]|\\.)*'/g, '?') - // Replace emails (e.g. user@example.com) - .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '?') - // Replace IPv4 addresses (e.g. 192.168.1.1) - .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '?') - // Replace IPv6 addresses (e.g. 2001:0db8:85a3::8a2e:0370:7334) - .replace(/\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b/g, '?') - // Replace phone numbers (international/local formats, at least 7 digits) - .replace(/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}\b/g, '?'); + return ( + query + // Replace $n parameter placeholders ($1, $2, etc.) + .replace(/\$\d+/g, '?') + // Replace single-quoted string literals: '...' + .replace(/'(?:[^'\\]|\\.)*'/g, '?') + // Replace emails (e.g. user@example.com) + .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '?') + // Replace IPv4 addresses (e.g. 192.168.1.1) + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '?') + // Replace IPv6 addresses (e.g. 2001:0db8:85a3::8a2e:0370:7334) + .replace(/\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b/g, '?') + // Replace phone numbers (international/local formats, at least 7 digits) + .replace(/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}\b/g, '?') + ); } /** @@ -101,8 +143,10 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul (process.env.DATABASE_URL ?? '').includes('pgbouncer=true'); const poolSize = parseInt(process.env.PGBOUNCER_POOL_SIZE ?? String(POOL_SIZE_DEFAULT), 10); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const poolTimeout = parseInt(process.env.PGBOUNCER_POOL_TIMEOUT ?? String(POOL_TIMEOUT_MS), 10); + const poolTimeoutMs = parseInt( + process.env.PGBOUNCER_POOL_TIMEOUT ?? String(POOL_TIMEOUT_MS_DEFAULT), + 10, + ); const isProduction = process.env.NODE_ENV === 'production'; @@ -118,6 +162,16 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul : (['error', 'warn'] as const) : (['error', 'warn', 'info', 'query'] as const); + // Issue #1290 – make the pool tuning knobs effective. Prisma reads + // `connection_limit` / `pool_timeout` from the datasource URL; when + // PgBouncer is enabled the pool (and its timeout) is owned by PgBouncer. + const datasourceUrl = buildDatasourceUrl({ + databaseUrl: process.env.DATABASE_URL, + pgbouncerEnabled: isPgbouncerEnabled, + poolSize, + poolTimeoutMs, + }); + super({ log: logLevels.map((level) => ({ level, emit: 'event' })), ...(isPgbouncerEnabled @@ -128,11 +182,14 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul }, }, } - : {}), + : datasourceUrl + ? { datasourceUrl } + : {}), }); this.logger.log( - `PrismaService initialised – PgBouncer: ${isPgbouncerEnabled}, pool size: ${poolSize}`, + `PrismaService initialised – PgBouncer: ${isPgbouncerEnabled}, pool size: ${poolSize}, ` + + `pool timeout: ${poolTimeoutMs}ms`, ); // ── Query event logging & slow query detection (#917) ───────────────── @@ -145,8 +202,7 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul // Issue #1252 – Verbose query logging is gated to non-production with explicit opt-in const isVerboseQueryLoggingEnabled = !isProduction && - (process.env.VERBOSE_QUERY_LOGGING === 'true' || - process.env.ENABLE_QUERY_LOGGING === 'true'); + (process.env.VERBOSE_QUERY_LOGGING === 'true' || process.env.ENABLE_QUERY_LOGGING === 'true'); // eslint-disable-next-line @typescript-eslint/no-explicit-any (this as any).$on('query', (event: { query: string; params: string; duration: number }) => { diff --git a/src/property-comparison/property-comparison.controller.ts b/src/property-comparison/property-comparison.controller.ts index edf558d0..769d43c4 100644 --- a/src/property-comparison/property-comparison.controller.ts +++ b/src/property-comparison/property-comparison.controller.ts @@ -1,8 +1,11 @@ // @ts-nocheck -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { PropertyComparisonService } from './property-comparison.service'; import { CompareBodyDto, CompareQueryDto } from './dto/comparison.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; @Controller('property-comparison') export class PropertyComparisonController { @@ -28,11 +31,29 @@ export class PropertyComparisonController { return this.comparisonService.createShareableLink(body.propertyIds, body.userId); } + /** + * Public share resolution endpoint (issue #1292). Returns the compared + * properties without owner PII and enforces expiry/revocation. + */ + @Get('shares/:token') + getSharedComparison(@Param('token') token: string) { + return this.comparisonService.getSharedComparison(token); + } + + /** + * Backwards-compatible alias for the original share route. + */ @Get('shared/:shareToken') - getSharedComparison(@Param('shareToken') shareToken: string) { + getSharedComparisonLegacy(@Param('shareToken') shareToken: string) { return this.comparisonService.getSharedComparison(shareToken); } + @UseGuards(JwtAuthGuard) + @Post('shares/:token/revoke') + revokeShare(@CurrentUser() user: AuthUserPayload, @Param('token') token: string) { + return this.comparisonService.revokeShare(token, user.sub); + } + @Post('export') exportComparison(@Body() body: { propertyIds: string[] }) { return this.comparisonService.exportComparison(body.propertyIds); diff --git a/src/property-comparison/property-comparison.module.ts b/src/property-comparison/property-comparison.module.ts index 1e61f089..bb6f2ff1 100644 --- a/src/property-comparison/property-comparison.module.ts +++ b/src/property-comparison/property-comparison.module.ts @@ -4,9 +4,10 @@ import { Module } from '@nestjs/common'; import { PropertyComparisonController } from './property-comparison.controller'; import { PropertyComparisonService } from './property-comparison.service'; import { PrismaModule } from '../database/prisma.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, AuthModule], controllers: [PropertyComparisonController], providers: [PropertyComparisonService], exports: [PropertyComparisonService], diff --git a/src/property-comparison/property-comparison.service.spec.ts b/src/property-comparison/property-comparison.service.spec.ts index 53050350..893232c7 100644 --- a/src/property-comparison/property-comparison.service.spec.ts +++ b/src/property-comparison/property-comparison.service.spec.ts @@ -1,10 +1,10 @@ import { PropertyComparisonService } from './property-comparison.service'; import { PrismaService } from '../database/prisma.service'; -import { NotFoundException } from '@nestjs/common'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; describe('PropertyComparisonService', () => { let service: PropertyComparisonService; - let prisma: jest.Mocked>; + let prisma: any; beforeEach(() => { prisma = { @@ -14,6 +14,7 @@ describe('PropertyComparisonService', () => { comparisonShare: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn(), + update: jest.fn(), } as any, }; service = new PropertyComparisonService(prisma as unknown as PrismaService); @@ -30,4 +31,116 @@ describe('PropertyComparisonService', () => { it('getSharedComparison throws NotFoundException when token not found', async () => { await expect(service.getSharedComparison('bad-token')).rejects.toThrow(NotFoundException); }); + + describe('getSharedComparison (#1292)', () => { + const baseShare = { + shareToken: 'token-1', + propertyIds: ['p1', 'p2'], + createdById: 'user-1', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + revokedAt: null as Date | null, + viewCount: 2, + lastViewedAt: null as Date | null, + }; + + it('rejects an expired share', async () => { + prisma.comparisonShare.findUnique = jest.fn().mockResolvedValue({ + ...baseShare, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect(service.getSharedComparison('token-1')).rejects.toThrow(/expired/i); + }); + + it('rejects a revoked share', async () => { + prisma.comparisonShare.findUnique = jest.fn().mockResolvedValue({ + ...baseShare, + revokedAt: new Date(), + }); + + await expect(service.getSharedComparison('token-1')).rejects.toThrow(/revoked/i); + }); + + it('serves a valid share without owner PII and records the view', async () => { + prisma.comparisonShare.findUnique = jest.fn().mockResolvedValue(baseShare); + prisma.comparisonShare.update = jest.fn().mockResolvedValue(baseShare); + prisma.property.findMany = jest.fn().mockResolvedValue([ + { id: 'p1', title: 'One', features: [] }, + { id: 'p2', title: 'Two', features: [] }, + ]); + + const result = await service.getSharedComparison('token-1'); + + expect(result.shareToken).toBe('token-1'); + expect(result.viewCount).toBe(3); + expect(result.properties).toHaveLength(2); + expect(result.properties[0]).not.toHaveProperty('owner'); + + // The owner relation must not be requested for public shares. + const findManyArgs = (prisma.property.findMany as jest.Mock).mock.calls[0][0]; + expect(findManyArgs.include).toBeUndefined(); + + expect(prisma.comparisonShare.update).toHaveBeenCalledWith({ + where: { shareToken: 'token-1' }, + data: { viewCount: { increment: 1 }, lastViewedAt: expect.any(Date) }, + }); + }); + + it('still serves the share when view tracking fails', async () => { + prisma.comparisonShare.findUnique = jest.fn().mockResolvedValue(baseShare); + prisma.comparisonShare.update = jest.fn().mockRejectedValue(new Error('db down')); + prisma.property.findMany = jest.fn().mockResolvedValue([ + { id: 'p1', title: 'One', features: [] }, + { id: 'p2', title: 'Two', features: [] }, + ]); + + const result = await service.getSharedComparison('token-1'); + expect(result.properties).toHaveLength(2); + }); + }); + + describe('revokeShare (#1292)', () => { + it('throws NotFoundException for an unknown token', async () => { + await expect(service.revokeShare('missing', 'user-1')).rejects.toThrow(NotFoundException); + }); + + it("forbids revoking another user's share", async () => { + prisma.comparisonShare.findUnique = jest + .fn() + .mockResolvedValue({ shareToken: 't', createdById: 'owner', revokedAt: null }); + + await expect(service.revokeShare('t', 'intruder')).rejects.toThrow(ForbiddenException); + }); + + it('marks the share as revoked', async () => { + prisma.comparisonShare.findUnique = jest + .fn() + .mockResolvedValue({ shareToken: 't', createdById: 'owner', revokedAt: null }); + prisma.comparisonShare.update = jest + .fn() + .mockResolvedValue({ shareToken: 't', revokedAt: new Date() }); + + const result = await service.revokeShare('t', 'owner'); + + expect(result.alreadyRevoked).toBe(false); + expect(result.revokedAt).toBeInstanceOf(Date); + expect(prisma.comparisonShare.update).toHaveBeenCalledWith({ + where: { shareToken: 't' }, + data: { revokedAt: expect.any(Date) }, + }); + }); + + it('is idempotent when the share is already revoked', async () => { + const revokedAt = new Date(); + prisma.comparisonShare.findUnique = jest + .fn() + .mockResolvedValue({ shareToken: 't', createdById: 'owner', revokedAt }); + + const result = await service.revokeShare('t', 'owner'); + + expect(result.alreadyRevoked).toBe(true); + expect(prisma.comparisonShare.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/property-comparison/property-comparison.service.ts b/src/property-comparison/property-comparison.service.ts index 0e82f7f5..027c9f2e 100644 --- a/src/property-comparison/property-comparison.service.ts +++ b/src/property-comparison/property-comparison.service.ts @@ -1,6 +1,6 @@ // @ts-nocheck -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../database/prisma.service'; import { v4 as uuidv4 } from 'uuid'; @@ -57,19 +57,24 @@ export interface FieldRow { export class PropertyComparisonService { constructor(private readonly prisma: PrismaService) {} - async compare(ids: string[]) { + async compare(ids: string[], options: { includeOwner?: boolean } = {}) { + const { includeOwner = true } = options; const properties = await this.prisma.property.findMany({ where: { id: { in: ids } }, - include: { - owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, - }, - }, + // Public share links must never expose owner PII, so the owner relation is + // opt-out (issue #1292). + include: includeOwner + ? { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + } + : undefined, }); if (properties.length !== ids.length) { @@ -183,7 +188,8 @@ export class PropertyComparisonService { shareToken: share.shareToken, propertyIds: share.propertyIds, expiresAt: share.expiresAt, - url: `/property-comparison/shared/${share.shareToken}`, + // Points at the public serving endpoint (issue #1292). + url: `/property-comparison/shares/${share.shareToken}`, }; } @@ -196,20 +202,66 @@ export class PropertyComparisonService { throw new NotFoundException('Shared comparison not found'); } + if (share.revokedAt) { + throw new NotFoundException('This shared comparison link has been revoked'); + } + if (share.expiresAt && share.expiresAt < new Date()) { throw new NotFoundException('This shared comparison link has expired'); } - const comparison = await this.compare(share.propertyIds); + // Best-effort view tracking — a failed counter update must not prevent the + // public share from being served (issue #1292). + try { + await this.prisma.comparisonShare.update({ + where: { shareToken }, + data: { viewCount: { increment: 1 }, lastViewedAt: new Date() }, + }); + } catch { + // Non-fatal: view analytics are advisory only. + } + + const comparison = await this.compare(share.propertyIds, { includeOwner: false }); return { shareToken: share.shareToken, createdAt: share.createdAt, expiresAt: share.expiresAt, + viewCount: share.viewCount + 1, + lastViewedAt: new Date(), ...comparison, }; } + /** + * Revoke a comparison share link so it can no longer be resolved. + * Only the creator may revoke their own link when ownership is recorded. + */ + async revokeShare(shareToken: string, userId?: string) { + const share = await this.prisma.comparisonShare.findUnique({ + where: { shareToken }, + }); + + if (!share) { + throw new NotFoundException('Shared comparison not found'); + } + + if (share.createdById && userId && share.createdById !== userId) { + throw new ForbiddenException('You can only revoke your own shared comparisons'); + } + + if (share.revokedAt) { + return { shareToken: share.shareToken, revokedAt: share.revokedAt, alreadyRevoked: true }; + } + + const updated = await this.prisma.comparisonShare.update({ + where: { shareToken }, + data: { revokedAt: new Date() }, + }); + + return { shareToken: updated.shareToken, revokedAt: updated.revokedAt, alreadyRevoked: false }; + } + async exportComparison(propertyIds: string[]) { const result = await this.compare(propertyIds); const scoreResult = this.calculateScore(result.properties); diff --git a/test/e2e/comparison-share.e2e.spec.ts b/test/e2e/comparison-share.e2e.spec.ts new file mode 100644 index 00000000..560246f0 --- /dev/null +++ b/test/e2e/comparison-share.e2e.spec.ts @@ -0,0 +1,173 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import * as crypto from 'crypto'; +import { PrismaService } from '../../src/database/prisma.service'; +import { PropertyComparisonController } from '../../src/property-comparison/property-comparison.controller'; +import { PropertyComparisonService } from '../../src/property-comparison/property-comparison.service'; +import { AuthService } from '../../src/auth/auth.service'; + +class FakePrismaService { + properties = new Map(); + shares = new Map(); + + async $connect() {} + async $disconnect() {} + + property = { + findMany: async ({ where }: any) => { + const ids: string[] = where?.id?.in ?? []; + return ids.map((id) => this.properties.get(id)).filter(Boolean); + }, + } as any; + + comparisonShare = { + // Return a copy to mirror Prisma's snapshot semantics (callers must not + // observe later mutations). + findUnique: async ({ where }: any) => { + const share = this.shares.get(where.shareToken); + return share ? { ...share } : null; + }, + create: async ({ data }: any) => { + const record = { + id: crypto.randomUUID(), + createdAt: new Date(), + revokedAt: null, + viewCount: 0, + lastViewedAt: null, + ...data, + }; + this.shares.set(record.shareToken, record); + return record; + }, + update: async ({ where, data }: any) => { + const share = this.shares.get(where.shareToken); + if (!share) throw new Error('Share not found'); + if (data.viewCount?.increment) share.viewCount += data.viewCount.increment; + if (data.lastViewedAt) share.lastViewedAt = data.lastViewedAt; + if (data.revokedAt) share.revokedAt = data.revokedAt; + return share; + }, + } as any; +} + +describe('Property comparison share e2e (#1292)', () => { + let app: INestApplication; + let fakePrisma: FakePrismaService; + let propertyIds: string[]; + + const seedProperties = () => { + propertyIds = [crypto.randomUUID(), crypto.randomUUID()]; + propertyIds.forEach((id, index) => { + fakePrisma.properties.set(id, { + id, + title: `Property ${index + 1}`, + address: `${index + 1} Main St`, + city: 'Testville', + state: 'TS', + zipCode: '12345', + country: 'US', + price: 100000 + index * 50000, + propertyType: 'HOUSE', + bedrooms: 2 + index, + bathrooms: 1 + index, + squareFeet: 1000 + index * 200, + lotSize: 0.2, + yearBuilt: 2000 + index, + status: 'ACTIVE', + features: ['garage'], + latitude: 40.7, + longitude: -74.0, + }); + }); + }; + + beforeAll(async () => { + fakePrisma = new FakePrismaService(); + + const moduleRef = await Test.createTestingModule({ + controllers: [PropertyComparisonController], + providers: [ + PropertyComparisonService, + { provide: PrismaService, useValue: fakePrisma as any }, + { + provide: AuthService, + useValue: { + validateAccessToken: async () => ({ + sub: 'share-owner', + email: 'owner@example.com', + role: 'USER' as any, + type: 'access', + }), + } as any, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false })); + await app.init(); + }, 20000); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + fakePrisma.properties.clear(); + fakePrisma.shares.clear(); + seedProperties(); + }); + + const createShare = async () => { + const res = await request(app.getHttpServer()) + .post('/property-comparison/share') + .send({ propertyIds, userId: 'share-owner' }) + .expect(201); + return res.body; + }; + + it('returns a sharing URL that points at the public serving endpoint', async () => { + const body = await createShare(); + expect(body.shareToken).toBeDefined(); + expect(body.url).toBe(`/property-comparison/shares/${body.shareToken}`); + }); + + it('serves a valid share without owner PII and tracks the view', async () => { + const { shareToken } = await createShare(); + + const res = await request(app.getHttpServer()) + .get(`/property-comparison/shares/${shareToken}`) + .expect(200); + + expect(res.body.shareToken).toBe(shareToken); + expect(res.body.properties).toHaveLength(2); + expect(res.body.viewCount).toBe(1); + expect(res.body.properties[0]).not.toHaveProperty('owner'); + }); + + it('returns 404 for an unknown token', async () => { + await request(app.getHttpServer()) + .get('/property-comparison/shares/does-not-exist') + .expect(404); + }); + + it('returns 404 for an expired share', async () => { + const { shareToken } = await createShare(); + const share = fakePrisma.shares.get(shareToken); + share.expiresAt = new Date(Date.now() - 1000); + + await request(app.getHttpServer()).get(`/property-comparison/shares/${shareToken}`).expect(404); + }); + + it('returns 404 for a revoked share', async () => { + const { shareToken } = await createShare(); + + await request(app.getHttpServer()) + .post(`/property-comparison/shares/${shareToken}/revoke`) + .set('Authorization', 'Bearer test') + .expect(201); + + await request(app.getHttpServer()).get(`/property-comparison/shares/${shareToken}`).expect(404); + }); +}); diff --git a/test/unit/config-docs.spec.ts b/test/unit/config-docs.spec.ts new file mode 100644 index 00000000..d460f8d8 --- /dev/null +++ b/test/unit/config-docs.spec.ts @@ -0,0 +1,133 @@ +/** + * Issue #1290 – config documentation smoke test. + * + * Config that is documented but silently does nothing creates false + * operational assumptions. This spec asserts that the connection-pool and + * cache-warming knobs are both (a) documented and (b) actually implemented, + * and that the parsing helpers behave as documented. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { buildDatasourceUrl } from '../../src/database/prisma.service'; +import { resolveWarmingIntervalMs } from '../../src/cache/cache-warming.service'; + +const projectRoot = join(__dirname, '..', '..'); + +function read(relativePath: string): string { + return readFileSync(join(projectRoot, relativePath), 'utf8'); +} + +describe('Config documentation (#1290)', () => { + const readme = read('README.md'); + const envExample = read('.env.example'); + + const documentedKnobs = [ + 'PGBOUNCER_POOL_SIZE', + 'PGBOUNCER_POOL_TIMEOUT', + 'CACHE_WARMING_ENABLED', + 'CACHE_WARMING_INTERVAL', + ]; + + it('documents every supported pool/cache knob in README and .env.example', () => { + for (const knob of documentedKnobs) { + expect(readme).toContain(knob); + expect(envExample).toContain(knob); + } + }); + + it('implements every documented pool/cache knob in source', () => { + const prismaSource = read('src/database/prisma.service.ts'); + const cacheWarmingSource = read('src/cache/cache-warming.service.ts'); + + expect(prismaSource).toContain('PGBOUNCER_POOL_SIZE'); + expect(prismaSource).toContain('PGBOUNCER_POOL_TIMEOUT'); + expect(cacheWarmingSource).toContain('CACHE_WARMING_ENABLED'); + expect(cacheWarmingSource).toContain('CACHE_WARMING_INTERVAL'); + }); +}); + +describe('buildDatasourceUrl (#1290)', () => { + it('folds the pool size and timeout into the Prisma datasource URL', () => { + const url = buildDatasourceUrl({ + databaseUrl: 'postgresql://user:pass@localhost:5432/propchain', + pgbouncerEnabled: false, + poolSize: 20, + poolTimeoutMs: 10_000, + }); + + expect(url).toBeDefined(); + const parsed = new URL(url as string); + expect(parsed.searchParams.get('connection_limit')).toBe('20'); + // Prisma expects pool_timeout in seconds. + expect(parsed.searchParams.get('pool_timeout')).toBe('10'); + }); + + it('rounds sub-second timeouts up to at least one second', () => { + const url = buildDatasourceUrl({ + databaseUrl: 'postgresql://localhost:5432/propchain', + pgbouncerEnabled: false, + poolSize: 5, + poolTimeoutMs: 250, + }); + + expect(new URL(url as string).searchParams.get('pool_timeout')).toBe('1'); + }); + + it('does not override pool parameters already present in the URL', () => { + const url = buildDatasourceUrl({ + databaseUrl: 'postgresql://localhost:5432/propchain?connection_limit=7&pool_timeout=3', + pgbouncerEnabled: false, + poolSize: 20, + poolTimeoutMs: 10_000, + }); + + const parsed = new URL(url as string); + expect(parsed.searchParams.get('connection_limit')).toBe('7'); + expect(parsed.searchParams.get('pool_timeout')).toBe('3'); + }); + + it('leaves the URL untouched when PgBouncer owns the pool', () => { + const databaseUrl = 'postgresql://localhost:5432/propchain?pgbouncer=true'; + const url = buildDatasourceUrl({ + databaseUrl, + pgbouncerEnabled: true, + poolSize: 20, + poolTimeoutMs: 10_000, + }); + + expect(url).toBe(databaseUrl); + }); + + it('passes through non-URL connection strings and missing URLs', () => { + expect( + buildDatasourceUrl({ + databaseUrl: 'not-a-url', + pgbouncerEnabled: false, + poolSize: 20, + poolTimeoutMs: 10_000, + }), + ).toBe('not-a-url'); + + expect( + buildDatasourceUrl({ pgbouncerEnabled: false, poolSize: 20, poolTimeoutMs: 10_000 }), + ).toBeUndefined(); + }); +}); + +describe('resolveWarmingIntervalMs (#1290)', () => { + const DEFAULT = 30 * 60 * 1000; + + it('defaults to 30 minutes when unset', () => { + expect(resolveWarmingIntervalMs(undefined)).toBe(DEFAULT); + }); + + it('uses a positive configured interval', () => { + expect(resolveWarmingIntervalMs('60000')).toBe(60_000); + }); + + it('falls back to the default for invalid or non-positive values', () => { + expect(resolveWarmingIntervalMs('not-a-number')).toBe(DEFAULT); + expect(resolveWarmingIntervalMs('0')).toBe(DEFAULT); + expect(resolveWarmingIntervalMs('-5')).toBe(DEFAULT); + }); +}); From d7609a92c791b3b97c54228133c6288b92ee80d9 Mon Sep 17 00:00:00 2001 From: beulah7717108-eng Date: Sun, 27 Sep 2026 06:48:21 +0000 Subject: [PATCH 2/2] test: cover TOTP enrollment, login challenge and trusted-device flows Adds integration coverage for the #1291 two-factor lifecycle: enrollment via setup/verify, the login challenge across TOTP and recovery codes, trusted-device bypass, and one-time device token issuance. --- src/auth/login-two-factor.spec.ts | 289 ++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/auth/login-two-factor.spec.ts diff --git a/src/auth/login-two-factor.spec.ts b/src/auth/login-two-factor.spec.ts new file mode 100644 index 00000000..25456cc7 --- /dev/null +++ b/src/auth/login-two-factor.spec.ts @@ -0,0 +1,289 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import { AuthService } from './auth.service'; +import { TwoFactorService } from './two-factor.service'; +import { PrismaService } from '../database/prisma.service'; +import { UsersService } from '../users/users.service'; +import { SessionsService } from '../sessions/sessions.service'; +import { EmailService } from '../email/email.service'; +import { LoginRateLimitService } from './login-rate-limit.service'; +import { FraudService } from '../fraud/fraud.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; +import { LoginDto } from './dto/auth.dto'; +import { createSha256, generateTotpCode, hashPassword } from './security.utils'; + +/** + * End-to-end style coverage for the two-factor login lifecycle added by #1291: + * enrollment, the login challenge (TOTP, recovery codes and trusted devices), + * and remembering a device after a successful challenge. + */ +describe('AuthService - two-factor login lifecycle (#1291)', () => { + let service: AuthService; + let passwordHash: string; + + const confirmedPassword = 'CorrectHorse1!'; + + const prisma = { + user: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) }, + blacklistedToken: { findUnique: jest.fn().mockResolvedValue(null) }, + loginHistory: { create: jest.fn().mockResolvedValue(undefined) }, + activityLog: { create: jest.fn().mockResolvedValue(undefined) }, + }; + + const usersService = { + findByEmail: jest.fn(), + }; + + const sessionsService = { + createSession: jest.fn().mockResolvedValue(undefined), + }; + + const rateLimitService = { + isAccountLocked: jest.fn().mockResolvedValue(false), + getLockoutInfo: jest.fn().mockResolvedValue(null), + getFailedAttemptsCount: jest.fn().mockResolvedValue(0), + recordFailedAttempt: jest.fn().mockResolvedValue(false), + recordSuccessfulAttempt: jest.fn().mockResolvedValue(undefined), + }; + + const fraudService = { + evaluateSuccessfulLogin: jest.fn().mockResolvedValue(undefined), + evaluateFailedLogin: jest.fn().mockResolvedValue(undefined), + recordLoginContext: jest.fn().mockResolvedValue(undefined), + }; + + const emailService = { + sendAccountLockedEmail: jest.fn().mockResolvedValue(undefined), + }; + + const twoFactorService = { + isTrustedDevice: jest.fn().mockResolvedValue(false), + rememberDevice: jest.fn(), + revokeAllDevices: jest.fn().mockResolvedValue(0), + }; + + const configService = { + get: jest.fn((key: string) => { + const config: Record = { + JWT_SECRET: 'test-secret-at-least-32-characters-long', + JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters-long', + JWT_ACCESS_EXPIRES_IN: '15m', + JWT_REFRESH_EXPIRES_IN: '7d', + BCRYPT_ROUNDS: '10', + CAPTCHA_THRESHOLD: '3', + }; + return config[key]; + }), + }; + + const buildUser = (overrides: Record = {}) => ({ + id: 'user-1', + email: 'user@test.com', + password: passwordHash, + role: 'AGENT', + tier: 'PREMIUM', + isBlocked: false, + isDeactivated: false, + isVerified: true, + twoFactorEnabled: false, + twoFactorSecret: null as string | null, + twoFactorBackupCodes: null as string[] | null, + ...overrides, + }); + + const loginDto = (overrides: Partial = {}): LoginDto => + ({ email: 'user@test.com', password: confirmedPassword, ...overrides }) as LoginDto; + + beforeAll(async () => { + passwordHash = await hashPassword(confirmedPassword, 10); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + rateLimitService.isAccountLocked.mockResolvedValue(false); + rateLimitService.getFailedAttemptsCount.mockResolvedValue(0); + rateLimitService.recordFailedAttempt.mockResolvedValue(false); + prisma.blacklistedToken.findUnique.mockResolvedValue(null); + twoFactorService.isTrustedDevice.mockResolvedValue(false); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prisma }, + { provide: UsersService, useValue: usersService }, + { provide: SessionsService, useValue: sessionsService }, + { provide: EmailService, useValue: emailService }, + { provide: LoginRateLimitService, useValue: rateLimitService }, + { provide: FraudService, useValue: fraudService }, + { provide: ConfigService, useValue: configService }, + { provide: TwoFactorService, useValue: twoFactorService }, + { + provide: ApiKeyAnalyticsService, + useValue: { checkQuota: jest.fn(), recordUsage: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + const mockLoginUser = (overrides: Record = {}) => { + const user = buildUser(overrides); + usersService.findByEmail.mockResolvedValue(user); + prisma.user.findUnique.mockResolvedValue(user); + return user; + }; + + describe('enrollment', () => { + it('issues a secret, QR URL and recovery codes without enabling 2FA yet', async () => { + prisma.user.findUnique.mockResolvedValue(buildUser()); + + const result = await service.setupTwoFactor({ sub: 'user-1' } as any); + + expect(result.secret).toEqual(expect.any(String)); + expect(result.otpAuthUrl).toContain(`secret=${result.secret}`); + expect(result.qrCodeUrl).toContain(encodeURIComponent(result.otpAuthUrl)); + expect(result.backupCodes).toHaveLength(8); + + const updateArgs = prisma.user.update.mock.calls[0][0]; + expect(updateArgs.data.twoFactorEnabled).toBe(false); + expect(updateArgs.data.twoFactorSecret).toBe(result.secret); + expect(updateArgs.data.twoFactorBackupCodes.set).toHaveLength(8); + // Only hashes are persisted – never the plaintext recovery codes. + expect(updateArgs.data.twoFactorBackupCodes.set).not.toContain(result.backupCodes[0]); + }); + + it('enables 2FA once a valid TOTP code is supplied', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + prisma.user.findUnique.mockResolvedValue(buildUser({ twoFactorSecret: secret })); + + const code = generateTotpCode({ secret }); + const result = await service.verifyTwoFactor({ sub: 'user-1' } as any, { code }); + + expect(result.message).toMatch(/enabled/i); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { twoFactorEnabled: true }, + }); + }); + + it('refuses to enable 2FA when enrollment was never initialised', async () => { + prisma.user.findUnique.mockResolvedValue(buildUser({ twoFactorSecret: null })); + + await expect( + service.verifyTwoFactor({ sub: 'user-1' } as any, { code: '123456' }), + ).rejects.toThrow(/not been initialized/i); + }); + }); + + describe('login challenge', () => { + it('requires a second factor when 2FA is enabled and none is supplied', async () => { + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: 'JBSWY3DPEHPK3PXP' }); + + await expect(service.login(loginDto())).rejects.toThrow(/code required/i); + expect(sessionsService.createSession).not.toHaveBeenCalled(); + }); + + it('rejects an invalid TOTP code', async () => { + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: 'JBSWY3DPEHPK3PXP' }); + + await expect(service.login(loginDto({ totpCode: '000000' }))).rejects.toThrow( + /invalid two-factor authentication code/i, + ); + }); + + it('accepts a valid TOTP code and issues a token pair', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: secret }); + + const result = await service.login(loginDto({ totpCode: generateTotpCode({ secret }) })); + + expect(result.accessToken).toEqual(expect.any(String)); + const claims = jwt.decode(result.accessToken) as any; + expect(claims.sub).toBe('user-1'); + expect(sessionsService.createSession).toHaveBeenCalled(); + }); + + it('consumes a valid recovery code exactly once', async () => { + const recoveryCode = 'ABCD1234'; + const otherCode = 'EFGH5678'; + mockLoginUser({ + twoFactorEnabled: true, + twoFactorSecret: 'JBSWY3DPEHPK3PXP', + twoFactorBackupCodes: [createSha256(recoveryCode), createSha256(otherCode)], + }); + + const result = await service.login(loginDto({ backupCode: recoveryCode })); + expect(result.accessToken).toEqual(expect.any(String)); + + const updateArgs = prisma.user.update.mock.calls[0][0]; + expect(updateArgs.data.twoFactorBackupCodes.set).toEqual([createSha256(otherCode)]); + }); + + it('rejects an unknown recovery code', async () => { + mockLoginUser({ + twoFactorEnabled: true, + twoFactorSecret: 'JBSWY3DPEHPK3PXP', + twoFactorBackupCodes: [createSha256('ABCD1234')], + }); + + await expect(service.login(loginDto({ backupCode: 'NOPE0000' }))).rejects.toThrow( + /invalid backup code/i, + ); + }); + }); + + describe('trusted devices', () => { + it('lets a remembered device skip the challenge entirely', async () => { + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: 'JBSWY3DPEHPK3PXP' }); + twoFactorService.isTrustedDevice.mockResolvedValue(true); + + const result = await service.login(loginDto({ trustedDeviceToken: 'device-token' })); + + expect(twoFactorService.isTrustedDevice).toHaveBeenCalledWith('user-1', 'device-token'); + expect(result.accessToken).toEqual(expect.any(String)); + }); + + it('still challenges when the supplied device token is not trusted', async () => { + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: 'JBSWY3DPEHPK3PXP' }); + twoFactorService.isTrustedDevice.mockResolvedValue(false); + + await expect(service.login(loginDto({ trustedDeviceToken: 'stale-token' }))).rejects.toThrow( + /code required/i, + ); + }); + + it('issues a one-time device token when rememberDevice is requested', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: secret }); + const expiresAt = new Date(Date.now() + 86_400_000); + twoFactorService.rememberDevice.mockResolvedValue({ + token: 'fresh-device-token', + device: { expiresAt }, + }); + + const result = await service.login( + loginDto({ totpCode: generateTotpCode({ secret }), rememberDevice: true }), + '203.0.113.10', + 'jest-agent', + ); + + expect(twoFactorService.rememberDevice).toHaveBeenCalledWith('user-1', { + userAgent: 'jest-agent', + ipAddress: '203.0.113.10', + }); + expect((result as any).trustedDeviceToken).toBe('fresh-device-token'); + expect((result as any).trustedDeviceExpiresAt).toBe(expiresAt); + }); + + it('does not remember a device when the second factor was skipped', async () => { + mockLoginUser({ twoFactorEnabled: true, twoFactorSecret: 'JBSWY3DPEHPK3PXP' }); + twoFactorService.isTrustedDevice.mockResolvedValue(true); + + await service.login(loginDto({ trustedDeviceToken: 'device-token', rememberDevice: true })); + + expect(twoFactorService.rememberDevice).not.toHaveBeenCalled(); + }); + }); +});