Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
127 changes: 75 additions & 52 deletions README.md

Large diffs are not rendered by default.

56 changes: 42 additions & 14 deletions docs/MFA_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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);
27 changes: 27 additions & 0 deletions prisma/migrations/20260926130000_add_trusted_devices/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 31 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
119 changes: 119 additions & 0 deletions src/analytics/analytics.gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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();
});
});
Loading