diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2cf9b087..94a4c4c5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module, NestModule } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import configuration from './config/configuration'; import { AppController } from './app.controller'; @@ -9,6 +9,8 @@ import { PerplexityController } from './controllers/perplexity/perplexity.contro import { UserController } from './user/user.controller'; import { ReportsModule } from './reports/reports.module'; import { HealthController } from './health/health.controller'; +import { AuthMiddleware } from './auth/auth.middleware'; + @Module({ imports: [ ConfigModule.forRoot({ @@ -21,8 +23,7 @@ import { HealthController } from './health/health.controller'; providers: [AppService, AwsSecretsService, PerplexityService], }) export class AppModule implements NestModule { - configure() { - // Add your middleware configuration here if needed - // If you don't need middleware, you can leave this empty + configure(consumer: MiddlewareConsumer) { + consumer.apply(AuthMiddleware).forRoutes('*'); // Apply to all routes } } diff --git a/backend/src/auth/auth.middleware.ts b/backend/src/auth/auth.middleware.ts new file mode 100644 index 00000000..f954afb0 --- /dev/null +++ b/backend/src/auth/auth.middleware.ts @@ -0,0 +1,58 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; + +// Extend the Express Request interface to include the user property +export interface RequestWithUser extends Request { + user?: { + sub: string; + email?: string; + groups?: string[]; + [key: string]: any; + } | null; +} + +// Add this interface to define the token structure +interface DecodedToken { + payload: { + sub: string; + username?: string; + email?: string; + [key: string]: any; + }; + header: any; + signature: string; +} + +@Injectable() +export class AuthMiddleware implements NestMiddleware { + constructor(private configService: ConfigService) {} + + use(req: RequestWithUser, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + try { + // Verify the JWT token + const decodedToken = jwt.decode(token, { complete: true }) as DecodedToken; + + // Access user info from the payload + req.user = { + sub: decodedToken?.payload.sub as string, + username: decodedToken?.payload.username as string, + }; + } catch (error) { + // If token verification fails, set user to null + console.log('AuthMiddleware error'); + console.log(error); + req.user = null; + } + } else { + // No token provided + req.user = null; + } + + next(); + } +} diff --git a/backend/src/iac/backend-stack.ts b/backend/src/iac/backend-stack.ts index d19f6a55..41ae0e17 100644 --- a/backend/src/iac/backend-stack.ts +++ b/backend/src/iac/backend-stack.ts @@ -445,12 +445,62 @@ export class BackendStack extends cdk.Stack { // Add CORS to all resources api.root.addCorsPreflight(corsOptions); - apiResource.addCorsPreflight(corsOptions); - reportsResource.addCorsPreflight(corsOptions); - latestResource.addCorsPreflight(corsOptions); - reportIdResource.addCorsPreflight(corsOptions); - reportStatusResource.addCorsPreflight(corsOptions); - docsResource.addCorsPreflight(corsOptions); + apiResource.addCorsPreflight({ + ...corsOptions, + allowCredentials: false, // This is crucial - make sure OPTIONS requests don't require credentials + }); + reportsResource.addCorsPreflight({ + ...corsOptions, + allowCredentials: false, + }); + latestResource.addCorsPreflight({ + ...corsOptions, + allowCredentials: false, + }); + reportIdResource.addCorsPreflight({ + ...corsOptions, + allowCredentials: false, + }); + reportStatusResource.addCorsPreflight({ + ...corsOptions, + allowCredentials: false, + }); + docsResource.addCorsPreflight({ + ...corsOptions, + allowCredentials: false, + }); + + // Configure Gateway Responses to add CORS headers to error responses + const gatewayResponseTypes = [ + apigateway.ResponseType.UNAUTHORIZED, + apigateway.ResponseType.ACCESS_DENIED, + apigateway.ResponseType.DEFAULT_4XX, + apigateway.ResponseType.DEFAULT_5XX, + apigateway.ResponseType.RESOURCE_NOT_FOUND, + apigateway.ResponseType.MISSING_AUTHENTICATION_TOKEN, + apigateway.ResponseType.INVALID_API_KEY, + apigateway.ResponseType.THROTTLED, + apigateway.ResponseType.INTEGRATION_FAILURE, + apigateway.ResponseType.INTEGRATION_TIMEOUT, + ]; + + gatewayResponseTypes.forEach(responseType => { + new apigateway.CfnGatewayResponse( + this, + `${appName}GatewayResponse-${responseType.responseType.toString()}-${props.environment}`, + { + restApiId: api.restApiId, + responseType: responseType.responseType.toString(), + responseParameters: { + 'gatewayresponse.header.Access-Control-Allow-Origin': "'*'", + 'gatewayresponse.header.Access-Control-Allow-Headers': + "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'", + 'gatewayresponse.header.Access-Control-Allow-Methods': + "'GET,POST,PUT,PATCH,DELETE,OPTIONS'", + }, + }, + ); + }); // Create API Gateway execution role with required permissions new iam.Role(this, `${appName}APIGatewayRole-${props.environment}`, { diff --git a/backend/src/iac/update-api-policy.js b/backend/src/iac/update-api-policy.js index 35f59502..510d6ad8 100755 --- a/backend/src/iac/update-api-policy.js +++ b/backend/src/iac/update-api-policy.js @@ -62,29 +62,36 @@ async function main() { const policy = { Version: '2012-10-17', Statement: [ - // Allow authenticated Cognito users { - Effect: 'Allow', - Principal: '*', - Action: 'execute-api:Invoke', - Resource: `arn:aws:execute-api:${REGION}:*:${api.id}/*/*`, - Condition: { - StringEquals: { - 'cognito-identity.amazonaws.com:aud': cognitoUserPoolId + "Version": "2012-10-17", + "Statement": [ + // Allow OPTIONS requests + { + "Effect": "Allow", + "Principal": "*", + "Action": "execute-api:Invoke", + "Resource": "arn:aws:execute-api:us-east-1:*:xhvwo6wp66/*/OPTIONS/*" + }, + { + // Allow all other requests - authentication will be handled by Cognito + "Effect": "Allow", + "Principal": "*", + "Action": "execute-api:Invoke", + "Resource": "arn:aws:execute-api:us-east-1:*:xhvwo6wp66/*/*" + }, + { + // Deny non-HTTPS requests + "Effect": "Deny", + "Principal": "*", + "Action": "execute-api:Invoke", + "Resource": "arn:aws:execute-api:us-east-1:*:xhvwo6wp66/*/*", + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + } } - } - }, - // Deny non-HTTPS requests - { - Effect: 'Deny', - Principal: '*', - Action: 'execute-api:Invoke', - Resource: `arn:aws:execute-api:${REGION}:*:${api.id}/*/*`, - Condition: { - Bool: { - 'aws:SecureTransport': 'false' - } - } + ] } ] }; diff --git a/backend/src/main.ts b/backend/src/main.ts index 2687ebcb..270af094 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -13,9 +13,10 @@ async function bootstrap() { // Enable CORS app.enableCors({ origin: [ - 'http://localhost:5173', // Vite default dev server + 'http://localhost:5173', 'http://localhost:3000', - 'http://localhost:4173', // Vite preview + 'http://localhost:4173', + 'https://localhost', // Add this for Capacitor ...(process.env.FRONTEND_URL ? [process.env.FRONTEND_URL] : []), ], methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts index e9f78ea4..42317a81 100644 --- a/backend/src/reports/reports.controller.ts +++ b/backend/src/reports/reports.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Patch, Param, Body, Query, ValidationPipe } from '@nestjs/common'; +import { + Controller, + Get, + Patch, + Param, + Body, + Query, + ValidationPipe, + Req, + UnauthorizedException, +} from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -11,6 +21,7 @@ import { ReportsService } from './reports.service'; import { Report } from './models/report.model'; import { GetReportsQueryDto } from './dto/get-reports.dto'; import { UpdateReportStatusDto } from './dto/update-report-status.dto'; +import { RequestWithUser } from '../auth/auth.middleware'; @ApiTags('reports') @Controller('reports') @@ -21,18 +32,19 @@ export class ReportsController { @ApiOperation({ summary: 'Get all reports' }) @ApiResponse({ status: 200, - description: 'Returns all reports', + description: 'Returns all reports for the authenticated user', type: [Report], }) @Get() - async findAll(): Promise { - return this.reportsService.findAll(); + async findAll(@Req() request: RequestWithUser): Promise { + const userId = this.extractUserId(request); + return this.reportsService.findAll(userId); } @ApiOperation({ summary: 'Get latest reports' }) @ApiResponse({ status: 200, - description: 'Returns the latest reports', + description: 'Returns the latest reports for the authenticated user', type: [Report], }) @ApiQuery({ @@ -41,14 +53,18 @@ export class ReportsController { description: 'Maximum number of reports to return', }) @Get('latest') - async findLatest(@Query(ValidationPipe) queryDto: GetReportsQueryDto): Promise { - return this.reportsService.findLatest(queryDto); + async findLatest( + @Query(ValidationPipe) queryDto: GetReportsQueryDto, + @Req() request: RequestWithUser, + ): Promise { + const userId = this.extractUserId(request); + return this.reportsService.findLatest(queryDto, userId); } @ApiOperation({ summary: 'GET report' }) @ApiResponse({ status: 200, - description: 'Report status updated successfully', + description: 'Report details', type: Report, }) @ApiResponse({ @@ -60,8 +76,9 @@ export class ReportsController { description: 'Report ID', }) @Get(':id') - async getReport(@Param('id') id: string): Promise { - return this.reportsService.findOne(id); + async getReport(@Param('id') id: string, @Req() request: RequestWithUser): Promise { + const userId = this.extractUserId(request); + return this.reportsService.findOne(id, userId); } @ApiOperation({ summary: 'Update report status' }) @@ -82,7 +99,20 @@ export class ReportsController { async updateStatus( @Param('id') id: string, @Body(ValidationPipe) updateDto: UpdateReportStatusDto, + @Req() request: RequestWithUser, ): Promise { - return this.reportsService.updateStatus(id, updateDto); + const userId = this.extractUserId(request); + return this.reportsService.updateStatus(id, updateDto, userId); + } + + private extractUserId(request: RequestWithUser): string { + // The user object is attached to the request by our middleware + const user = request.user; + + if (!user || !user.sub) { + throw new UnauthorizedException('User ID not found in request'); + } + + return user.sub; } } diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 7f7ae201..30dfadb0 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -1,10 +1,17 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + InternalServerErrorException, + Logger, + ForbiddenException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DynamoDBClient, ScanCommand, GetItemCommand, UpdateItemCommand, + DynamoDBServiceException, } from '@aws-sdk/client-dynamodb'; import { marshall, unmarshall } from '@aws-sdk/util-dynamodb'; import { Report } from './models/report.model'; @@ -15,115 +22,232 @@ import { UpdateReportStatusDto } from './dto/update-report-status.dto'; export class ReportsService { private readonly dynamoClient: DynamoDBClient; private readonly tableName: string; + private readonly logger = new Logger(ReportsService.name); constructor(private configService: ConfigService) { const region = this.configService.get('AWS_REGION', 'us-east-1'); + const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID'); + const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY'); - try { - this.dynamoClient = new DynamoDBClient({ - region: this.configService.get('AWS_REGION', 'us-east-1'), - }); - } catch (error: unknown) { - console.error('DynamoDB Client Config:', JSON.stringify(error, null, 2)); - const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID'); - const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY'); - - const clientConfig: any = { region }; + // Prepare client configuration + const clientConfig: any = { region }; - // Only add credentials if both values are present - if (accessKeyId && secretAccessKey) { - clientConfig.credentials = { accessKeyId, secretAccessKey }; - } + // Only add credentials if both values are present + if (accessKeyId && secretAccessKey) { + clientConfig.credentials = { accessKeyId, secretAccessKey }; + } + try { this.dynamoClient = new DynamoDBClient(clientConfig); + } catch (error: unknown) { + this.logger.error('Failed to initialize DynamoDB client:', error); + throw new InternalServerErrorException('Failed to initialize database connection'); } this.tableName = this.configService.get('DYNAMODB_REPORTS_TABLE', 'reports'); } - async findAll(): Promise { - const command = new ScanCommand({ - TableName: this.tableName, - }); + async findAll(userId: string): Promise { + if (!userId) { + throw new ForbiddenException('User ID is required'); + } try { + // If the table has a GSI for userId, use QueryCommand instead + const command = new ScanCommand({ + TableName: this.tableName, + FilterExpression: 'userId = :userId', + ExpressionAttributeValues: marshall({ + ':userId': userId, + }), + }); + const response = await this.dynamoClient.send(command); return (response.Items || []).map(item => unmarshall(item) as Report); } catch (error: unknown) { - console.error('DynamoDB Error Details:', JSON.stringify(error, null, 2)); - if (error instanceof Error && error.name === 'UnrecognizedClientException') { - throw new Error( - 'Invalid AWS credentials. Please check your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.', - ); + this.logger.error(`Error fetching reports for user ${userId}:`); + this.logger.error(error); + + if (error instanceof DynamoDBServiceException) { + if (error.name === 'UnrecognizedClientException') { + throw new InternalServerErrorException( + 'Invalid AWS credentials. Please check your AWS configuration.', + ); + } else if (error.name === 'ResourceNotFoundException') { + throw new InternalServerErrorException( + `Table "${this.tableName}" not found. Please check your database configuration.`, + ); + } } - throw error; + + throw new InternalServerErrorException('Failed to fetch reports from database'); } } - async findLatest(queryDto: GetReportsQueryDto): Promise { - console.log('Running ReportsService.findLatest', queryDto); + async findLatest(queryDto: GetReportsQueryDto, userId: string): Promise { + this.logger.log( + `Running findLatest with params: ${JSON.stringify(queryDto)} for user ${userId}`, + ); + + if (!userId) { + throw new ForbiddenException('User ID is required'); + } + // Convert limit to a number to avoid serialization errors const limit = typeof queryDto.limit === 'string' ? parseInt(queryDto.limit, 10) : queryDto.limit || 10; - const command = new ScanCommand({ - TableName: this.tableName, - Limit: limit, - }); + try { + // If the table has a GSI for userId, use QueryCommand instead + const command = new ScanCommand({ + TableName: this.tableName, + FilterExpression: 'userId = :userId', + ExpressionAttributeValues: marshall({ + ':userId': userId, + }), + Limit: limit * 5, // Fetch more items since we'll filter by userId + }); + + const response = await this.dynamoClient.send(command); + const reports = (response.Items || []).map(item => unmarshall(item) as Report); - const response = await this.dynamoClient.send(command); - const reports = (response.Items || []).map(item => unmarshall(item) as Report); + // Sort by createdAt in descending order + return reports + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, limit); + } catch (error: unknown) { + this.logger.error(`Error fetching latest reports for user ${userId}:`); + this.logger.error(error); + + if (error instanceof DynamoDBServiceException) { + if (error.name === 'ResourceNotFoundException') { + throw new InternalServerErrorException( + `Table "${this.tableName}" not found. Please check your database configuration.`, + ); + } + } - // Sort by createdAt in descending order - return reports - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, limit); + throw new InternalServerErrorException('Failed to fetch latest reports from database'); + } } - async findOne(id: string): Promise { + async findOne(id: string, userId: string): Promise { + if (!id) { + throw new NotFoundException('Report ID is required'); + } + + if (!userId) { + throw new ForbiddenException('User ID is required'); + } + const command = new GetItemCommand({ TableName: this.tableName, Key: marshall({ id }), }); - const response = await this.dynamoClient.send(command); + try { + const response = await this.dynamoClient.send(command); + + if (!response.Item) { + throw new NotFoundException(`Report with ID ${id} not found`); + } - if (!response.Item) { - throw new NotFoundException(`Report with ID ${id} not found`); - } + const report = unmarshall(response.Item) as Report; - return unmarshall(response.Item) as Report; - } + // Verify the report belongs to the user + if (report.userId !== userId) { + throw new ForbiddenException('You do not have permission to access this report'); + } + + return report; + } catch (error: unknown) { + if (error instanceof NotFoundException) { + throw error; + } - async updateStatus(id: string, updateDto: UpdateReportStatusDto): Promise { - // First check if the report exists - const existingReport = await this.findOne(id); + this.logger.error(`Error fetching report with ID ${id}:`); + this.logger.error(error); - const command = new UpdateItemCommand({ - TableName: this.tableName, - Key: marshall({ id }), - UpdateExpression: 'SET #status = :status, updatedAt = :updatedAt', - ExpressionAttributeNames: { - '#status': 'status', - }, - ExpressionAttributeValues: marshall({ - ':status': updateDto.status, - ':updatedAt': new Date().toISOString(), - }), - ReturnValues: 'ALL_NEW', - }); + if (error instanceof DynamoDBServiceException) { + if (error.name === 'ResourceNotFoundException') { + throw new InternalServerErrorException( + `Table "${this.tableName}" not found. Please check your database configuration.`, + ); + } + } + + throw new InternalServerErrorException(`Failed to fetch report with ID ${id}`); + } + } - const response = await this.dynamoClient.send(command); + async updateStatus( + id: string, + updateDto: UpdateReportStatusDto, + userId: string, + ): Promise { + if (!id) { + throw new NotFoundException('Report ID is required'); + } + + if (!updateDto || !updateDto.status) { + throw new InternalServerErrorException('Status is required for update'); + } - if (!response.Attributes) { - // If for some reason Attributes is undefined, return the existing report with updated status - return { - ...existingReport, - status: updateDto.status, - updatedAt: new Date().toISOString(), - }; + if (!userId) { + throw new ForbiddenException('User ID is required'); } - return unmarshall(response.Attributes) as Report; + try { + // First check if the report exists and belongs to the user + const existingReport = await this.findOne(id, userId); + + const command = new UpdateItemCommand({ + TableName: this.tableName, + Key: marshall({ id }), + UpdateExpression: 'SET #status = :status, updatedAt = :updatedAt', + ConditionExpression: 'userId = :userId', // Ensure the report belongs to the user + ExpressionAttributeNames: { + '#status': 'status', + }, + ExpressionAttributeValues: marshall({ + ':status': updateDto.status, + ':updatedAt': new Date().toISOString(), + ':userId': userId, + }), + ReturnValues: 'ALL_NEW', + }); + + const response = await this.dynamoClient.send(command); + + if (!response.Attributes) { + // If for some reason Attributes is undefined, return the existing report with updated status + return { + ...existingReport, + status: updateDto.status, + updatedAt: new Date().toISOString(), + }; + } + + return unmarshall(response.Attributes) as Report; + } catch (error: unknown) { + if (error instanceof NotFoundException) { + throw error; + } + + this.logger.error(`Error updating report status for ID ${id}:`); + this.logger.error(error); + + if (error instanceof DynamoDBServiceException) { + if (error.name === 'ConditionalCheckFailedException') { + throw new ForbiddenException('You do not have permission to update this report'); + } else if (error.name === 'ResourceNotFoundException') { + throw new InternalServerErrorException( + `Table "${this.tableName}" not found. Please check your database configuration.`, + ); + } + } + + throw new InternalServerErrorException(`Failed to update report status for ID ${id}`); + } } }