diff --git a/backend/.env b/backend/.env index cab6c3e..573e53a 100644 --- a/backend/.env +++ b/backend/.env @@ -37,4 +37,4 @@ AVIATIONSTACK_API_KEY="your-aviationstack-api-key" # ============================================ # Get API key from: https://console.anthropic.com/ # Cost: ~$3 per million tokens -ANTHROPIC_API_KEY="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" +ANTHROPIC_API_KEY="your-anthropic-api-key" diff --git a/backend/package-lock.json b/backend/package-lock.json index 5be725a..5a9fcb4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,11 +20,13 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", "@nestjs/schedule": "^4.1.2", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.8.1", "@types/pdfkit": "^0.17.4", "axios": "^1.6.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "helmet": "^8.1.0", "ics": "^3.8.1", "ioredis": "^5.3.2", "jwks-rsa": "^3.1.0", @@ -782,7 +784,6 @@ "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.8.0.tgz", "integrity": "sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==", "license": "MIT", - "peer": true, "dependencies": { "@ucast/mongo2js": "^1.3.0" }, @@ -2010,6 +2011,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2119,7 +2131,6 @@ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.13" }, @@ -5851,6 +5862,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index d3b1106..c5802a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,11 +35,13 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", "@nestjs/schedule": "^4.1.2", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.8.1", "@types/pdfkit": "^0.17.4", "axios": "^1.6.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "helmet": "^8.1.0", "ics": "^3.8.1", "ioredis": "^5.3.2", "jwks-rsa": "^3.1.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ac7468b..623a512 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PrismaModule } from './prisma/prisma.module'; @@ -26,6 +27,12 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; envFilePath: '.env', }), + // Rate limiting: 100 requests per 60 seconds per IP + ThrottlerModule.forRoot([{ + ttl: 60000, + limit: 100, + }]), + // Core modules PrismaModule, AuthModule, @@ -51,6 +58,11 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; provide: APP_GUARD, useClass: JwtAuthGuard, }, + // Apply rate limiting globally + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/backend/src/drivers/drivers.controller.ts b/backend/src/drivers/drivers.controller.ts index e3e94c0..963bf8c 100644 --- a/backend/src/drivers/drivers.controller.ts +++ b/backend/src/drivers/drivers.controller.ts @@ -220,8 +220,9 @@ export class DriversController { remove( @Param('id') id: string, @Query('hard') hard?: string, + @CurrentUser() user?: any, ) { const isHardDelete = hard === 'true'; - return this.driversService.remove(id, isHardDelete); + return this.driversService.remove(id, isHardDelete, user?.role); } } diff --git a/backend/src/drivers/drivers.service.ts b/backend/src/drivers/drivers.service.ts index 1a6db82..29aca10 100644 --- a/backend/src/drivers/drivers.service.ts +++ b/backend/src/drivers/drivers.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateDriverDto, UpdateDriverDto } from './dto'; @@ -78,7 +78,11 @@ export class DriversService { }); } - async remove(id: string, hardDelete = false) { + async remove(id: string, hardDelete = false, userRole?: string) { + if (hardDelete && userRole !== 'ADMINISTRATOR') { + throw new ForbiddenException('Only administrators can permanently delete records'); + } + const driver = await this.findOne(id); if (hardDelete) { diff --git a/backend/src/events/events.controller.ts b/backend/src/events/events.controller.ts index 59d20de..685e5e9 100644 --- a/backend/src/events/events.controller.ts +++ b/backend/src/events/events.controller.ts @@ -13,6 +13,7 @@ import { EventsService } from './events.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Role } from '@prisma/client'; import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto'; @@ -59,8 +60,9 @@ export class EventsController { remove( @Param('id') id: string, @Query('hard') hard?: string, + @CurrentUser() user?: any, ) { const isHardDelete = hard === 'true'; - return this.eventsService.remove(id, isHardDelete); + return this.eventsService.remove(id, isHardDelete, user?.role); } } diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts index 32b9a1f..daa0944 100644 --- a/backend/src/events/events.service.ts +++ b/backend/src/events/events.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, + ForbiddenException, Logger, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @@ -248,7 +249,11 @@ export class EventsService { return this.enrichEventWithVips(updatedEvent); } - async remove(id: string, hardDelete = false) { + async remove(id: string, hardDelete = false, userRole?: string) { + if (hardDelete && userRole !== 'ADMINISTRATOR') { + throw new ForbiddenException('Only administrators can permanently delete records'); + } + const event = await this.findOne(id); if (hardDelete) { diff --git a/backend/src/main.ts b/backend/src/main.ts index 90d6d9a..bafccf0 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; import { json, urlencoded } from 'express'; +import helmet from 'helmet'; import { AppModule } from './app.module'; import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters'; @@ -9,6 +10,9 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); + // Security headers + app.use(helmet()); + // Increase body size limit for PDF attachments (base64 encoded) app.use(json({ limit: '5mb' })); app.use(urlencoded({ extended: true, limit: '5mb' })); diff --git a/backend/src/signal/messages.controller.ts b/backend/src/signal/messages.controller.ts index 9b3a107..0f2ed5e 100644 --- a/backend/src/signal/messages.controller.ts +++ b/backend/src/signal/messages.controller.ts @@ -6,11 +6,12 @@ import { Body, Param, Query, + Req, UseGuards, Logger, Res, } from '@nestjs/common'; -import { Response } from 'express'; +import { Request, Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -105,7 +106,14 @@ export class MessagesController { */ @Public() @Post('webhook') - async handleWebhook(@Body() payload: SignalWebhookPayload) { + async handleWebhook(@Body() payload: SignalWebhookPayload, @Req() req: Request) { + // Validate webhook secret if configured + const secret = process.env.SIGNAL_WEBHOOK_SECRET; + if (secret && req.headers['x-webhook-secret'] !== secret) { + this.logger.warn('Webhook rejected: invalid or missing secret'); + return { success: false, error: 'Unauthorized' }; + } + this.logger.debug('Received Signal webhook:', JSON.stringify(payload)); try { diff --git a/backend/src/vehicles/vehicles.controller.ts b/backend/src/vehicles/vehicles.controller.ts index ba5632f..96695a3 100644 --- a/backend/src/vehicles/vehicles.controller.ts +++ b/backend/src/vehicles/vehicles.controller.ts @@ -13,6 +13,7 @@ import { VehiclesService } from './vehicles.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Role } from '@prisma/client'; import { CreateVehicleDto, UpdateVehicleDto } from './dto'; @@ -56,8 +57,12 @@ export class VehiclesController { @Delete(':id') @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) - remove(@Param('id') id: string, @Query('hard') hard?: string) { + remove( + @Param('id') id: string, + @Query('hard') hard?: string, + @CurrentUser() user?: any, + ) { const isHardDelete = hard === 'true'; - return this.vehiclesService.remove(id, isHardDelete); + return this.vehiclesService.remove(id, isHardDelete, user?.role); } } diff --git a/backend/src/vehicles/vehicles.service.ts b/backend/src/vehicles/vehicles.service.ts index 0655d73..1bab53b 100644 --- a/backend/src/vehicles/vehicles.service.ts +++ b/backend/src/vehicles/vehicles.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateVehicleDto, UpdateVehicleDto } from './dto'; @@ -89,7 +89,11 @@ export class VehiclesService { }); } - async remove(id: string, hardDelete = false) { + async remove(id: string, hardDelete = false, userRole?: string) { + if (hardDelete && userRole !== 'ADMINISTRATOR') { + throw new ForbiddenException('Only administrators can permanently delete records'); + } + const vehicle = await this.findOne(id); if (hardDelete) { diff --git a/backend/src/vips/vips.controller.ts b/backend/src/vips/vips.controller.ts index 8d20258..7e8e492 100644 --- a/backend/src/vips/vips.controller.ts +++ b/backend/src/vips/vips.controller.ts @@ -13,6 +13,7 @@ import { VipsService } from './vips.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AbilitiesGuard } from '../auth/guards/abilities.guard'; import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CreateVipDto, UpdateVipDto } from './dto'; @Controller('vips') @@ -49,9 +50,10 @@ export class VipsController { remove( @Param('id') id: string, @Query('hard') hard?: string, + @CurrentUser() user?: any, ) { // Only administrators can hard delete const isHardDelete = hard === 'true'; - return this.vipsService.remove(id, isHardDelete); + return this.vipsService.remove(id, isHardDelete, user?.role); } } diff --git a/backend/src/vips/vips.service.ts b/backend/src/vips/vips.service.ts index 08be78f..f680dab 100644 --- a/backend/src/vips/vips.service.ts +++ b/backend/src/vips/vips.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateVipDto, UpdateVipDto } from './dto'; @@ -58,7 +58,11 @@ export class VipsService { }); } - async remove(id: string, hardDelete = false) { + async remove(id: string, hardDelete = false, userRole?: string) { + if (hardDelete && userRole !== 'ADMINISTRATOR') { + throw new ForbiddenException('Only administrators can permanently delete records'); + } + const vip = await this.findOne(id); if (hardDelete) { diff --git a/docker-compose.yml b/docker-compose.yml index 1b61be2..aa28277 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,7 @@ services: DATABASE_URL: postgresql://postgres:changeme@postgres:5432/vip_coordinator REDIS_URL: redis://redis:6379 SIGNAL_API_URL: http://signal-api:8080 + SIGNAL_WEBHOOK_SECRET: ${SIGNAL_WEBHOOK_SECRET:-} TRACCAR_API_URL: http://traccar:8082 TRACCAR_DEVICE_PORT: 5055 AUTH0_DOMAIN: ${AUTH0_DOMAIN} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62beece..4f6dfb5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,7 +62,7 @@ function App() { scope: 'openid profile email offline_access', }} useRefreshTokens={true} - cacheLocation="localstorage" + cacheLocation="memory" > diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 250ac0b..096c778 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; -import { api } from '@/lib/api'; +import { api, setTokenGetter } from '@/lib/api'; interface BackendUser { id: string; @@ -40,9 +40,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [fetchingUser, setFetchingUser] = useState(false); const [authError, setAuthError] = useState(null); - // Set up token and fetch backend user profile + // Wire up the API token getter so axios can fetch fresh tokens + useEffect(() => { + if (isAuthenticated) { + setTokenGetter(() => getAccessTokenSilently()); + } + return () => setTokenGetter(null); + }, [isAuthenticated, getAccessTokenSilently]); + + // Fetch backend user profile after authentication useEffect(() => { - // Wait for Auth0 to finish loading before fetching token if (isAuthenticated && !isLoading && !fetchingUser && !backendUser) { setFetchingUser(true); setAuthError(null); @@ -53,42 +60,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { setFetchingUser(false); }, 10000); // 10 second timeout - getAccessTokenSilently() - .then(async (token) => { + // Fetch backend user profile (api interceptor handles token automatically) + api.get('/auth/profile') + .then((response) => { clearTimeout(timeoutId); - console.log('[AUTH] Got access token, fetching user profile'); - localStorage.setItem('auth0_token', token); - - // Fetch backend user profile - try { - const response = await api.get('/auth/profile'); - console.log('[AUTH] User profile fetched successfully:', response.data.email); - setBackendUser(response.data); - setAuthError(null); - } catch (error: any) { - console.error('[AUTH] Failed to fetch user profile:', error); - setBackendUser(null); - - // Set specific error message - if (error.response?.status === 401) { - setAuthError('Your account is pending approval or your session has expired'); - } else { - setAuthError('Failed to load user profile - please try logging in again'); - } - } + console.log('[AUTH] User profile fetched successfully:', response.data.email); + setBackendUser(response.data); + setAuthError(null); }) - .catch((error) => { + .catch((error: any) => { clearTimeout(timeoutId); - console.error('[AUTH] Failed to get token:', error); + console.error('[AUTH] Failed to fetch user profile:', error); setBackendUser(null); - // Handle specific Auth0 errors - if (error.error === 'missing_refresh_token' || error.message?.includes('Missing Refresh Token')) { + // Handle specific errors + if (error.response?.status === 401) { + setAuthError('Your account is pending approval or your session has expired'); + } else if (error.error === 'missing_refresh_token' || error.message?.includes('Missing Refresh Token')) { setAuthError('Session expired - please log in again'); } else if (error.error === 'login_required') { setAuthError('Login required'); } else { - setAuthError('Authentication failed - please try logging in again'); + setAuthError('Failed to load user profile - please try logging in again'); } }) .finally(() => { @@ -99,7 +92,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [isAuthenticated, isLoading]); const handleLogout = () => { - localStorage.removeItem('auth0_token'); + setTokenGetter(null); auth0Logout({ logoutParams: { returnTo: window.location.origin } }); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0729b02..57ec704 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,11 +20,22 @@ export const copilotApi = axios.create({ timeout: 120000, // 2 minute timeout for AI requests }); -// Shared request interceptor function -const requestInterceptor = (config: any) => { - const token = localStorage.getItem('auth0_token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; +// Token getter function - set by AuthContext when authenticated +let getToken: (() => Promise) | null = null; + +export function setTokenGetter(getter: (() => Promise) | null) { + getToken = getter; +} + +// Shared request interceptor function (async to support token refresh) +const requestInterceptor = async (config: any) => { + if (getToken) { + try { + const token = await getToken(); + config.headers.Authorization = `Bearer ${token}`; + } catch { + // Token fetch failed - request goes without auth header + } } if (DEBUG_MODE) { @@ -69,7 +80,6 @@ const responseErrorInterceptor = (error: any) => { status: response.status, statusText: response.statusText, data: response.data, - requestData: config?.data, } );