Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
GEOCODING_USER_AGENT=PropChain-Backend/1.0 (geocoding)
GEOCODING_TIMEOUT_MS=5000
# GOOGLE_GEOCODING_API_KEY=

# CAPTCHA Configuration
# Set to 'true' only in development environments to bypass CAPTCHA verification
CAPTCHA_BYPASS=false
60 changes: 53 additions & 7 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @ts-nocheck

import {
BadRequestException,
Injectable,
Expand Down Expand Up @@ -862,6 +860,21 @@ export class AuthService {
}
});

// Audit log password change (#886)
await this.prisma.activityLog
.create({
data: {
userId: user.sub,
action: 'PASSWORD_CHANGED',
entityType: 'USER',
entityId: user.sub,
description: 'User changed their password',
},
})
.catch((err) => {
this.logger.error(`Failed to audit-log password change: ${err}`);
});

await this.sessionsService.revokeAllSessions(existingUser.id);

return { message: 'Password updated successfully' };
Expand Down Expand Up @@ -924,6 +937,21 @@ export class AuthService {
},
});

// Audit log 2FA enable (#886)
await this.prisma.activityLog
.create({
data: {
userId: user.sub,
action: 'TWO_FACTOR_ENABLED',
entityType: 'USER',
entityId: user.sub,
description: 'User enabled two-factor authentication',
},
})
.catch((err) => {
this.logger.error(`Failed to audit-log 2FA enable: ${err}`);
});

return { message: 'Two-factor authentication enabled successfully' };
}

Expand Down Expand Up @@ -952,6 +980,21 @@ export class AuthService {
},
});

// Audit log 2FA disable (#886)
await this.prisma.activityLog
.create({
data: {
userId: user.sub,
action: 'TWO_FACTOR_DISABLED',
entityType: 'USER',
entityId: user.sub,
description: 'User disabled two-factor authentication',
},
})
.catch((err) => {
this.logger.error(`Failed to audit-log 2FA disable: ${err}`);
});

return { message: 'Two-factor authentication disabled successfully' };
}

Expand Down Expand Up @@ -1531,12 +1574,15 @@ export class AuthService {

private async verifyCaptcha(token: string): Promise<boolean> {
const secret = this.configService.get<string>('RECAPTCHA_SECRET');
const bypass = this.configService.get<string>('CAPTCHA_BYPASS') === 'true';

if (bypass) {
this.logger.warn('CAPTCHA bypass is enabled via CAPTCHA_BYPASS=true. This should only be used in development.');
return true;
}

if (!secret) {
if (process.env.NODE_ENV === 'production') {
throw new Error('RECAPTCHA_SECRET is not configured in production');
}
this.logger.warn('RECAPTCHA_SECRET is not configured, skipping CAPTCHA verification');
return true; // Bypass only in non-production
throw new Error('RECAPTCHA_SECRET is not configured. Set CAPTCHA_BYPASS=true for development environments.');
}

try {
Expand Down
63 changes: 49 additions & 14 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @ts-nocheck

import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
Expand All @@ -13,6 +11,7 @@ import { RateLimitService } from './auth/rate-limit.service';
import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor';
import { setupSwagger } from './config/swagger.config';
import { validateEnvironment } from './utils/validate-env';
import { TraceInterceptor } from './tracing/trace.interceptor';

async function bootstrap() {
validateEnvironment();
Expand All @@ -27,21 +26,57 @@ async function bootstrap() {
`Node.js >= ${REQUIRED_NODE_MAJOR} required, found ${process.versions.node}. ` +
`Please upgrade Node.js (see https://nodejs.org/).`,
);
}

const app = await NestFactory.create(AppModule);

// Setup Swagger documentation
setupSwagger(app);
// CORS configuration
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim())
: ['http://localhost:3000'];

app.enableShutdownHooks();
const isProduction = process.env.NODE_ENV === 'production';

const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`PropChain API running on http://localhost:${port}`);
logger.log(`API Versioning enabled. Supported versions: v1, v2`);
logger.log(`📚 Swagger UI available at http://localhost:${port}/api/docs`);
logger.log(`📋 OpenAPI spec available at http://localhost:${port}/api/openapi.json`);
logger.log(`💾 Redis Caching enabled`);
logger.log(`🛡️ Rate Limiting enabled (per-user, per-endpoint, IP-based)`);
if (isProduction && corsOrigins.includes('*')) {
logger.warn('Wildcard CORS origins are not allowed in production. Using default origins.');
corsOrigins.length = 0;
corsOrigins.push('http://localhost:3000');
}

bootstrap();
app.enableCors({
origin: corsOrigins,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization', 'API-Version', 'api-key'],
});

// Security headers middleware
app.use((req: any, res: any, next: any) => {
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});

// Distributed tracing interceptor
app.useGlobalInterceptors(new TraceInterceptor());

// Setup Swagger documentation
setupSwagger(app);

app.enableShutdownHooks();

const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`PropChain API running on http://localhost:${port}`);
logger.log(`API Versioning enabled. Supported versions: v1, v2`);
logger.log(`📚 Swagger UI available at http://localhost:${port}/api/docs`);
logger.log(`📋 OpenAPI spec available at http://localhost:${port}/api/openapi.json`);
logger.log(`💾 Redis Caching enabled`);
logger.log(`🛡️ Rate Limiting enabled (per-user, per-endpoint, IP-based)`);
}

bootstrap();
35 changes: 35 additions & 0 deletions src/tracing/trace.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { randomUUID } from 'crypto';

@Injectable()
export class TraceInterceptor implements NestInterceptor {
private readonly logger = new Logger(TraceInterceptor.name);

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const traceId = randomUUID();
const startTime = Date.now();

const className = context.getClass().name;
const handlerName = context.getHandler().name;

request.headers['x-trace-id'] = traceId;

this.logger.log(`[${traceId}] ${className}.${handlerName} - started`);

return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - startTime;
this.logger.log(`[${traceId}] ${className}.${handlerName} - completed (${duration}ms)`);
},
error: (error) => {
const duration = Date.now() - startTime;
this.logger.error(`[${traceId}] ${className}.${handlerName} - failed (${duration}ms): ${error.message}`);
},
}),
);
}
}
9 changes: 9 additions & 0 deletions src/tracing/tracing.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { TraceInterceptor } from './trace.interceptor';

@Global()
@Module({
providers: [TraceInterceptor],
exports: [TraceInterceptor],
})
export class TracingModule {}
Loading