diff --git a/.env.example b/.env.example index 74b39ad5b..8fa7b48f4 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,11 @@ DATABASE_PORT=5432 DATABASE_NAME=securing-safe-food DATABASE_NAME_TEST=securing-safe-food-test DATABASE_USERNAME=postgres -DATABASE_PASSWORD=PLACEHOLDER_PASSWORD \ No newline at end of file +DATABASE_PASSWORD=PLACEHOLDER_PASSWORD + +AWS_ACCESS_KEY_ID = PLACEHOLDER_AWS_ACCESS_KEY +AWS_SECRET_ACCESS_KEY = PLACEHOLDER_AWS_SECRET_KEY +AWS_REGION = PLACEHOLDER_AWS_REGION +COGNITO_CLIENT_SECRET = PLACEHOLDER_COGNITO_CLIENT_SECRET + +AWS_BUCKET_NAME = 'confirm-delivery-photos' \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md index 9eec729f5..d2b154bc0 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -27,10 +27,48 @@ You can check that your database connection details are correct by running `nx s Finally, run `yarn run typeorm:migrate` to load all the tables into your database. If everything is set up correctly, you should see "Migration ... has been executed successfully." in the terminal. +# AWS Setup + +We have a few environment variables that we utilize to access several AWS services throughout the application. Below is a list of each of them and how to access each after logging in to AWS + +1. `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`: + - Click on your username in the top right corner, and navigate to Security Credentials + - Scroll down to access keys, and create a new key + - Select "Local code" as the purpose for the key, and add an optional description + - Replace both the public and secret keys in the .env file to those values. Note that the secret key will not be accessible after you leave this page + - Click done + +2. `AWS_REGION`: +This can be found next to your profile name when you login to the main page. Some accounts may be different, but we generally use us-east-1 or us-east-2. +This is the region that you find on the right side after clicking on the location dropdown, usually saying "United States (*some region*)". +For example, if we want to use Ohio as the region, we would put `AWS_REGION="us-east2"` + +3. `AWS_BUCKET_NAME`: +This one is already given to you. As of right now, we only use one bucket, confirm-delivery-photos to store photos in a public S3 Bucket. This may be subject to change as we use S3 more in the project. + +4. `COGNITO_CLIENT_SECRET`: +This is used to help authenticate you with AWS Cognito and allow you to properly sign in using proper credential. To find this: + - Navigate to AWS Cognito + - Make sure you are on "United States (N. Virginia) as your region + - Go into User pools and click on the one that says "ssf" (NOTE: You can also validate the User pool id in the `auth/aws_exports.ts` file) + - Go to App Clients, and click on 'ssf client w secret' + - There, you can validate the information in `auth/aws_exports.ts` (the `userPoolClientId`), as well as copy the client secret into your env file + +5. Creating a new user within AWS Cognito + There are 2 ways you can create a new user in AWS Cognito. The simplest, is through loading the up, going to the landing page, and creating a new account there. If you choose to do it alternatively through the console, follow these steps: + - Navigate to AWS Cognito + - Make sure you are on "United States (N. Virginia) as your region + - Go into User pools and click on the one that says "ssf" + - Go to Users + - If you do not already see your email there, create a new User, setting an email in password (this will be what you login with on the frontend) + - Click 'Create User' + - Load up the app, and go to the landing page + - Verify you are able to login with these new credentials you created + ### Running backend tests 1. Create a **separate** Postgres database (for example `securing-safe-food-test`). 2. Add a `DATABASE_NAME_TEST` entry (and optionally `DATABASE_HOST/PORT/USERNAME/PASSWORD`) to your `.env` so the test data source can connect to that database. 3. Run the backend test suite with `npx jest`. -Each spec builds up the database and tables, tears it all down, and runs all the migrations on each tests. This ensures that we always have the most up to date data that we test with. \ No newline at end of file +Each spec builds up the database and tables, tears it all down, and runs all the migrations on each tests. This ensures that we always have the most up to date data that we test with. diff --git a/apps/backend/src/allocations/allocations.module.ts b/apps/backend/src/allocations/allocations.module.ts index fed7360bf..3284e1afd 100644 --- a/apps/backend/src/allocations/allocations.module.ts +++ b/apps/backend/src/allocations/allocations.module.ts @@ -1,15 +1,17 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Allocation } from './allocations.entity'; import { AllocationsController } from './allocations.controller'; import { AllocationsService } from './allocations.service'; -import { AuthService } from '../auth/auth.service'; -import { JwtStrategy } from '../auth/jwt.strategy'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Allocation])], + imports: [ + TypeOrmModule.forFeature([Allocation]), + forwardRef(() => AuthModule), + ], controllers: [AllocationsController], - providers: [AllocationsService, AuthService, JwtStrategy], + providers: [AllocationsService], exports: [AllocationsService], }) export class AllocationModule {} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index eac8a5b6b..3af03db00 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,14 +1,17 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; - import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../users/users.module'; @Module({ - imports: [UsersModule, PassportModule.register({ defaultStrategy: 'jwt' })], + imports: [ + forwardRef(() => UsersModule), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], controllers: [AuthController], providers: [AuthService, JwtStrategy], + exports: [AuthService, JwtStrategy], }) export class AuthModule {} diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index a0bae3ad1..5ebb427e6 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { AdminDeleteUserCommand, AdminInitiateAuthCommand, - AttributeType, CognitoIdentityProviderClient, ConfirmForgotPasswordCommand, ConfirmSignUpCommand, @@ -29,8 +28,8 @@ export class AuthService { this.providerClient = new CognitoIdentityProviderClient({ region: CognitoAuthConfig.region, credentials: { - accessKeyId: process.env.NX_AWS_ACCESS_KEY, - secretAccessKey: process.env.NX_AWS_SECRET_ACCESS_KEY, + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, }); @@ -43,28 +42,17 @@ export class AuthService { // (see https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash) calculateHash(username: string): string { const hmac = createHmac('sha256', this.clientSecret); - hmac.update(username + CognitoAuthConfig.clientId); + hmac.update(username + CognitoAuthConfig.userPoolClientId); return hmac.digest('base64'); } - async getUser(userSub: string): Promise { - const listUsersCommand = new ListUsersCommand({ - UserPoolId: CognitoAuthConfig.userPoolId, - Filter: `sub = "${userSub}"`, - }); - - // TODO need error handling - const { Users } = await this.providerClient.send(listUsersCommand); - return Users[0].Attributes; - } - async signup( { firstName, lastName, email, password }: SignUpDto, role: Role = Role.VOLUNTEER, ): Promise { // Needs error handling const signUpCommand = new SignUpCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, SecretHash: this.calculateHash(email), Username: email, Password: password, @@ -88,7 +76,7 @@ export class AuthService { async verifyUser(email: string, verificationCode: string): Promise { const confirmCommand = new ConfirmSignUpCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, SecretHash: this.calculateHash(email), Username: email, ConfirmationCode: verificationCode, @@ -100,7 +88,7 @@ export class AuthService { async signin({ email, password }: SignInDto): Promise { const signInCommand = new AdminInitiateAuthCommand({ AuthFlow: 'ADMIN_USER_PASSWORD_AUTH', - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, UserPoolId: CognitoAuthConfig.userPoolId, AuthParameters: { USERNAME: email, @@ -125,7 +113,7 @@ export class AuthService { }: RefreshTokenDto): Promise { const refreshCommand = new AdminInitiateAuthCommand({ AuthFlow: 'REFRESH_TOKEN_AUTH', - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, UserPoolId: CognitoAuthConfig.userPoolId, AuthParameters: { REFRESH_TOKEN: refreshToken, @@ -144,7 +132,7 @@ export class AuthService { async forgotPassword(email: string) { const forgotCommand = new ForgotPasswordCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, Username: email, SecretHash: this.calculateHash(email), }); @@ -158,7 +146,7 @@ export class AuthService { newPassword, }: ConfirmPasswordDto) { const confirmComamnd = new ConfirmForgotPasswordCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, SecretHash: this.calculateHash(email), Username: email, ConfirmationCode: confirmationCode, diff --git a/apps/backend/src/auth/aws-exports.ts b/apps/backend/src/auth/aws-exports.ts index 48541a193..97711cea2 100644 --- a/apps/backend/src/auth/aws-exports.ts +++ b/apps/backend/src/auth/aws-exports.ts @@ -1,6 +1,6 @@ const CognitoAuthConfig = { - userPoolId: 'us-east-1_oshVQXLX6', - clientId: '42bfm2o2pmk57mpm5399s0e9no', + userPoolClientId: '1kehn2mr64h94mire6os55bib7', + userPoolId: 'us-east-1_StSYXMibq', region: 'us-east-1', }; diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index 44d8789d4..80060cb6e 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -1,19 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { passportJwtSecret } from 'jwks-rsa'; import { ExtractJwt, Strategy } from 'passport-jwt'; - +import { UsersService } from '../users/users.service'; import CognitoAuthConfig from './aws-exports'; +import { AuthService } from './auth.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor() { + constructor(private usersService: UsersService) { const cognitoAuthority = `https://cognito-idp.${CognitoAuthConfig.region}.amazonaws.com/${CognitoAuthConfig.userPoolId}`; super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - _audience: CognitoAuthConfig.clientId, + _audience: CognitoAuthConfig.userPoolClientId, issuer: cognitoAuthority, algorithms: ['RS256'], secretOrKeyProvider: passportJwtSecret({ @@ -26,6 +27,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload) { - return { idUser: payload.sub, email: payload.email }; + const dbUser = await this.usersService.findUserByCognitoId(payload.sub); + return dbUser; } } diff --git a/apps/backend/src/auth/ownership.decorator.ts b/apps/backend/src/auth/ownership.decorator.ts new file mode 100644 index 000000000..871018451 --- /dev/null +++ b/apps/backend/src/auth/ownership.decorator.ts @@ -0,0 +1,25 @@ +import { SetMetadata, Type } from '@nestjs/common'; + +// Resolver function type to get the owner user ID for a given entity ID +export type OwnerIdResolver = (params: { + entityId: number; + services: ServiceRegistry; +}) => Promise; + +// Registry of services that can be easily resolved +// Eliminates the issues with circular dependencies +// allowing the lambdas to resolve only the services they need +export interface ServiceRegistry { + get(serviceClass: Type): T; +} + +// Configuration for ownership check +export interface OwnershipConfig { + idParam: string; + resolver: OwnerIdResolver; +} + +export const OWNERSHIP_CHECK_KEY = 'ownership_check'; + +export const CheckOwnership = (config: OwnershipConfig) => + SetMetadata(OWNERSHIP_CHECK_KEY, config); diff --git a/apps/backend/src/auth/ownership.guard.ts b/apps/backend/src/auth/ownership.guard.ts new file mode 100644 index 000000000..b65622466 --- /dev/null +++ b/apps/backend/src/auth/ownership.guard.ts @@ -0,0 +1,99 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + NotFoundException, + Type, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ModuleRef } from '@nestjs/core'; +import { + OWNERSHIP_CHECK_KEY, + OwnershipConfig, + ServiceRegistry, +} from './ownership.decorator'; + +@Injectable() +export class OwnershipGuard implements CanActivate { + constructor(private reflector: Reflector, private moduleRef: ModuleRef) {} + + async canActivate(context: ExecutionContext): Promise { + const config = this.reflector.get( + OWNERSHIP_CHECK_KEY, + context.getHandler(), + ); + + if (!config) { + return true; + } + + // Process all request information and the logged in user + const req = context.switchToHttp().getRequest(); + const user = req.user; + + // Admins bypass ownership checks + if (user.role === 'ADMIN') { + return true; + } + + if (!user) { + throw new ForbiddenException('Not authenticated'); + } + + // Get the id from the parameters + const entityId = Number(req.params[config.idParam]); + + if (isNaN(entityId)) { + throw new ForbiddenException(`Invalid ${config.idParam}`); + } + + // Create a service registry that easily resolves services + const services = this.createServiceRegistry(); + + try { + // Execute the lambda function to get the owner user ID + const ownerId = await config.resolver({ + entityId, + services, + }); + + if (ownerId === null || ownerId === undefined) { + throw new ForbiddenException('Unable to determine resource ownership'); + } + + if (ownerId !== user.id) { + throw new ForbiddenException('Access denied'); + } + + return true; + } catch (error) { + console.error('Error in ownership resolver:', error); + throw new ForbiddenException('Error verifying resource ownership'); + } + } + + // Use a service registry for easy service resolution and caching + private createServiceRegistry(): ServiceRegistry { + const cache = new Map, unknown>(); + const moduleRef = this.moduleRef; + + return { + get(serviceClass: Type): T { + // Return cached service if already resolved before + if (cache.has(serviceClass)) { + return cache.get(serviceClass) as T; + } + + // Resolve and cache the service + try { + const service = moduleRef.get(serviceClass, { strict: false }); + cache.set(serviceClass, service); + return service; + } catch (error) { + throw new Error(`Could not resolve service: ${serviceClass.name}`); + } + }, + }; + } +} diff --git a/apps/backend/src/auth/roles.decorator.ts b/apps/backend/src/auth/roles.decorator.ts new file mode 100644 index 000000000..a28701d24 --- /dev/null +++ b/apps/backend/src/auth/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '../users/types'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/backend/src/auth/roles.guard.ts b/apps/backend/src/auth/roles.guard.ts new file mode 100644 index 000000000..b2e71324f --- /dev/null +++ b/apps/backend/src/auth/roles.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from '../users/types'; +import { ROLES_KEY } from './roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + return requiredRoles.some((role) => user.role === role); + } +} diff --git a/apps/backend/src/auth/sharedAuth.module.ts b/apps/backend/src/auth/sharedAuth.module.ts new file mode 100644 index 000000000..757fb974d --- /dev/null +++ b/apps/backend/src/auth/sharedAuth.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OwnershipGuard } from './ownership.guard'; + +@Module({ + providers: [OwnershipGuard], + exports: [OwnershipGuard], +}) +export class SharedAuthModule {} diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index 6435dd9ca..638714d90 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -5,17 +5,20 @@ import { Param, Get, Patch, + UseGuards, ParseIntPipe, BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; +import { AuthGuard } from '@nestjs/passport'; import { FoodType } from './types'; import { CreateMultipleDonationItemsDto } from './dtos/create-donation-items.dto'; @Controller('donation-items') //@UseInterceptors() +@UseGuards(AuthGuard('jwt')) export class DonationItemsController { constructor(private donationItemsService: DonationItemsService) {} diff --git a/apps/backend/src/donationItems/donationItems.module.ts b/apps/backend/src/donationItems/donationItems.module.ts index a416372fc..ef377d2ba 100644 --- a/apps/backend/src/donationItems/donationItems.module.ts +++ b/apps/backend/src/donationItems/donationItems.module.ts @@ -2,14 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { DonationItemsController } from './donationItems.controller'; +import { AuthModule } from '../auth/auth.module'; import { Donation } from '../donations/donations.entity'; @Module({ - imports: [TypeOrmModule.forFeature([DonationItem, Donation])], + imports: [TypeOrmModule.forFeature([DonationItem, Donation]), AuthModule], controllers: [DonationItemsController], - providers: [DonationItemsService, AuthService, JwtStrategy], + providers: [DonationItemsService], }) export class DonationItemsModule {} diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 740a26507..ef9f112d6 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -13,6 +13,16 @@ export class DonationItemsService { @InjectRepository(Donation) private donationRepo: Repository, ) {} + async findOne(itemId: number): Promise { + validateId(itemId, 'Donation Item'); + + const donationItem = await this.repo.findOneBy({ itemId }); + if (!donationItem) { + throw new NotFoundException(`Donation item ${itemId} not found`); + } + return donationItem; + } + async getAllDonationItems(donationId: number): Promise { validateId(donationId, 'Donation'); return this.repo.find({ where: { donation: { donationId } } }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 6bcd2a7e8..ad09b322f 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -6,12 +6,14 @@ import { Patch, Param, NotFoundException, + UseGuards, ParseIntPipe, BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; +import { AuthGuard } from '@nestjs/passport'; import { DonationStatus } from './types'; @Controller('donations') diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 311971d1d..79ee1a2e1 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -1,19 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { DonationsController } from './donations.controller'; import { ManufacturerModule } from '../foodManufacturers/manufacturer.module'; +import { AuthModule } from '../auth/auth.module'; import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; @Module({ imports: [ TypeOrmModule.forFeature([Donation, FoodManufacturer]), ManufacturerModule, + AuthModule, ], controllers: [DonationsController], - providers: [DonationService, AuthService, JwtStrategy], + providers: [DonationService], }) export class DonationModule {} diff --git a/apps/backend/src/foodManufacturers/manufacturer.module.ts b/apps/backend/src/foodManufacturers/manufacturer.module.ts index 2ba2b117d..dcb5a32f1 100644 --- a/apps/backend/src/foodManufacturers/manufacturer.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturer.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturer.entity'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([FoodManufacturer])], + imports: [TypeOrmModule.forFeature([FoodManufacturer]), AuthModule], }) export class ManufacturerModule {} diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index ec6dc0f04..8cadc0fb0 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -7,6 +7,7 @@ import { Body, UploadedFiles, UseInterceptors, + UseGuards, BadRequestException, NotFoundException, } from '@nestjs/common'; @@ -16,6 +17,10 @@ import { FoodRequest } from './request.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; +import { AuthGuard } from '@nestjs/passport'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; +import { RolesGuard } from '../auth/roles.guard'; import { OrdersService } from '../orders/order.service'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; @@ -23,6 +28,7 @@ import { OrderDetailsDto } from './dtos/order-details.dto'; @Controller('requests') // @UseInterceptors() +@UseGuards(RolesGuard) export class RequestsController { constructor( private requestsService: RequestsService, @@ -117,7 +123,7 @@ export class RequestsController { ); } - //TODO: delete endpoint, here temporarily as a logic reference for order status impl. + @Roles(Role.PANTRY) @Post('/:requestId/confirm-delivery') @ApiBody({ description: 'Details for a confirmation form', diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index 14a605d80..d1c196804 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -3,21 +3,23 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RequestsController } from './request.controller'; import { FoodRequest } from './request.entity'; import { RequestsService } from './request.service'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { AWSS3Module } from '../aws/aws-s3.module'; import { MulterModule } from '@nestjs/platform-express'; +import { AuthModule } from '../auth/auth.module'; import { OrdersService } from '../orders/order.service'; import { Order } from '../orders/order.entity'; import { Pantry } from '../pantries/pantries.entity'; +import { SharedAuthModule } from '../auth/sharedAuth.module'; @Module({ imports: [ AWSS3Module, MulterModule.register({ dest: './uploads' }), TypeOrmModule.forFeature([FoodRequest, Order, Pantry]), + AuthModule, + SharedAuthModule, ], controllers: [RequestsController], - providers: [RequestsService, OrdersService, AuthService, JwtStrategy], + providers: [RequestsService, OrdersService], }) export class RequestsModule {} diff --git a/apps/backend/src/interceptors/current-user.interceptor.ts b/apps/backend/src/interceptors/current-user.interceptor.ts index e60b545a9..f5682ca64 100644 --- a/apps/backend/src/interceptors/current-user.interceptor.ts +++ b/apps/backend/src/interceptors/current-user.interceptor.ts @@ -16,18 +16,13 @@ export class CurrentUserInterceptor implements NestInterceptor { async intercept(context: ExecutionContext, handler: CallHandler) { const request = context.switchToHttp().getRequest(); - const cognitoUserAttributes = await this.authService.getUser( - request.user.userId, - ); - const userEmail = cognitoUserAttributes.find( - (attribute) => attribute.Name === 'email', - ).Value; - const users = await this.usersService.find(userEmail); - if (users.length > 0) { - const user = users[0]; - - request.user = user; + if (request.user) { + const dbUser = await this.usersService.findUserByCognitoId( + request.user.sub, + ); + console.log(dbUser); + request.currentUser = dbUser; } return handler.handle(); diff --git a/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts b/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts new file mode 100644 index 000000000..7763af075 --- /dev/null +++ b/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserPoolId1769189327767 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS user_cognito_sub VARCHAR(255) NOT NULL DEFAULT '';`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE users DROP COLUMN IF EXISTS user_cognito_sub;`, + ); + } +} diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 870dc1eff..ea9f8aeaa 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -7,6 +7,7 @@ import { Body, Query, BadRequestException, + UseGuards, } from '@nestjs/common'; import { OrdersService } from './order.service'; import { Order } from './order.entity'; @@ -15,6 +16,10 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; +import { AuthGuard } from '@nestjs/passport'; +import { OwnershipGuard } from '../auth/ownership.guard'; +import { CheckOwnership } from '../auth/ownership.decorator'; +import { PantriesService } from '../pantries/pantries.service'; @Controller('orders') export class OrdersController { @@ -54,6 +59,32 @@ export class OrdersController { return this.ordersService.findOrderPantry(orderId); } + // Test endpoint for right now + @UseGuards(AuthGuard('jwt'), OwnershipGuard) + @CheckOwnership({ + idParam: 'orderId', + resolver: async ({ entityId, services }) => { + const request = await services + .get(OrdersService) + .findOrderFoodRequest(entityId); + + if (!request) { + console.log('Request not found on order'); + return null; + } + + const pantry = await services + .get(PantriesService) + .findOne(request.pantryId); + + if (!pantry) { + console.log('Pantry not found'); + return null; + } + console.log('Found pantry:', pantry); + return pantry?.pantryUser?.id ?? null; + }, + }) @Get('/:orderId/request') async getRequestFromOrder( @Param('orderId', ParseIntPipe) orderId: number, diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index 4937eced7..1f435f438 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -1,17 +1,20 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OrdersController } from './order.controller'; import { Order } from './order.entity'; import { OrdersService } from './order.service'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { Pantry } from '../pantries/pantries.entity'; import { AllocationModule } from '../allocations/allocations.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Order, Pantry]), AllocationModule], + imports: [ + TypeOrmModule.forFeature([Order, Pantry]), + AllocationModule, + forwardRef(() => AuthModule), + ], controllers: [OrdersController], - providers: [OrdersService, AuthService, JwtStrategy], + providers: [OrdersService], exports: [OrdersService], }) export class OrdersModule {} diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index ee8287ce2..e98b2af49 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -5,10 +5,15 @@ import { Param, ParseIntPipe, Post, - ValidationPipe, + UseGuards, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; import { PantriesService } from './pantries.service'; +import { RolesGuard } from '../auth/roles.guard'; +import { Role } from '../users/types'; +import { Roles } from '../auth/roles.decorator'; +import { AuthGuard } from '@nestjs/passport'; +import { ValidationPipe } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ApiBody } from '@nestjs/swagger'; import { @@ -21,6 +26,8 @@ import { } from './types'; import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; +import { OwnershipGuard } from '../auth/ownership.guard'; +import { CheckOwnership } from '../auth/ownership.decorator'; @Controller('pantries') export class PantriesController { @@ -34,6 +41,14 @@ export class PantriesController { return this.pantriesService.getPendingPantries(); } + @UseGuards(AuthGuard('jwt'), OwnershipGuard) + @CheckOwnership({ + idParam: 'pantryId', + resolver: async ({ entityId, services }) => { + const pantry = await services.get(PantriesService).findOne(entityId); + return pantry?.pantryUser?.id ?? null; + }, + }) @Get('/:pantryId') async getPantry( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -219,6 +234,7 @@ export class PantriesController { return this.pantriesService.approve(pantryId); } + @Roles(Role.ADMIN) @Post('/deny/:pantryId') async denyPantry( @Param('pantryId', ParseIntPipe) pantryId: number, diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 3de2a4c50..a60ffbf7f 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -1,12 +1,20 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PantriesService } from './pantries.service'; import { PantriesController } from './pantries.controller'; import { Pantry } from './pantries.entity'; +import { AuthModule } from '../auth/auth.module'; import { OrdersModule } from '../orders/order.module'; +import { User } from '../users/user.entity'; +import { SharedAuthModule } from '../auth/sharedAuth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Pantry]), OrdersModule], + imports: [ + TypeOrmModule.forFeature([Pantry, User]), + OrdersModule, + forwardRef(() => AuthModule), + SharedAuthModule, + ], controllers: [PantriesController], providers: [PantriesService], exports: [PantriesService], diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index a2f254ecf..c3c779733 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -15,7 +15,10 @@ export class PantriesService { async findOne(pantryId: number): Promise { validateId(pantryId, 'Pantry'); - const pantry = await this.repo.findOne({ where: { pantryId } }); + const pantry = await this.repo.findOne({ + where: { pantryId }, + relations: ['pantryUser'], + }); if (!pantry) { throw new NotFoundException(`Pantry ${pantryId} not found`); diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index 746484cea..4481b22dc 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -38,6 +38,14 @@ export class User { }) phone: string; + @Column({ + type: 'varchar', + length: 255, + name: 'user_cognito_sub', + default: '', + }) + userCognitoSub: string; + @ManyToMany(() => Pantry, (pantry) => pantry.volunteers) @JoinTable({ name: 'volunteer_assignments', diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 6f11265d9..7040fc373 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -8,19 +8,14 @@ import { Post, BadRequestException, Body, - //UseGuards, - //UseInterceptors, } from '@nestjs/common'; import { UsersService } from './users.service'; -//import { AuthGuard } from '@nestjs/passport'; import { User } from './user.entity'; import { Role } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; import { Pantry } from '../pantries/pantries.entity'; -//import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Controller('users') -//@UseInterceptors(CurrentUserInterceptor) export class UsersController { constructor(private usersService: UsersService) {} @@ -31,7 +26,6 @@ export class UsersController { return this.usersService.getVolunteersAndPantryAssignments(); } - // @UseGuards(AuthGuard('jwt')) @Get('/:id') async getUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.findOne(userId); diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 6a780a8d6..23177621e 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -1,17 +1,19 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { User } from './user.entity'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; -import { AuthService } from '../auth/auth.service'; import { PantriesModule } from '../pantries/pantries.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), PantriesModule], - exports: [UsersService], + imports: [ + TypeOrmModule.forFeature([User]), + forwardRef(() => PantriesModule), + forwardRef(() => AuthModule), + ], controllers: [UsersController], - providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], + providers: [UsersService], + exports: [UsersService], }) export class UsersModule {} diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 65f90ae1e..21b31b05a 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -5,7 +5,6 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; - import { User } from './user.entity'; import { Role } from './types'; import { validateId } from '../utils/validation.utils'; @@ -66,8 +65,8 @@ export class UsersService { return volunteer; } - find(email: string) { - return this.repo.find({ where: { email } }); + async findByEmail(email: string): Promise { + return this.repo.findOneBy({ email }); } async update(id: number, attrs: Partial) { @@ -139,4 +138,12 @@ export class UsersService { volunteer.pantries = [...volunteer.pantries, ...newPantries]; return this.repo.save(volunteer); } + + async findUserByCognitoId(cognitoId: string): Promise { + const user = await this.repo.findOneBy({ userCognitoSub: cognitoId }); + if (!user) { + throw new NotFoundException(`User with cognitoId ${cognitoId} not found`); + } + return user; + } } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 462b214a2..64fd23094 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -1,4 +1,9 @@ -import axios, { type AxiosInstance, AxiosResponse } from 'axios'; +import axios, { + AxiosError, + AxiosResponse, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios'; import { User, Order, @@ -21,9 +26,40 @@ const defaultBaseUrl = export class ApiClient { private axiosInstance: AxiosInstance; + private accessToken: string | undefined; constructor() { this.axiosInstance = axios.create({ baseURL: defaultBaseUrl }); + + // Attach the access token to each request if available + // All API requests will go through this interceptor, making the user required to login + this.axiosInstance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = this.accessToken || localStorage.getItem('accessToken'); + //console.log('Attaching token to request:', token); + if (token) { + config.headers = config.headers || {}; + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error), + ); + + this.axiosInstance.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 403) { + // TODO: For a future ticket, figure out a better method than renavigation on failure (or a better place to check than in the api requests) + window.location.replace('/unauthorized'); + } + return Promise.reject(error); + }, + ); + } + + public setAccessToken(token: string | undefined) { + this.accessToken = token; } public async getHello(): Promise { @@ -240,10 +276,21 @@ export class ApiClient { requestId: number, data: FormData, ): Promise { - await this.axiosInstance.post( - `/api/requests/${requestId}/confirm-delivery`, - data, - ); + try { + const response = await this.axiosInstance.post( + `/api/requests/${requestId}/confirm-delivery`, + data, + ); + + if (response.status === 200) { + alert('Delivery confirmation submitted successfully'); + window.location.href = '/request-form/1'; + } else { + alert(`Failed to submit: ${response.statusText}`); + } + } catch (error) { + alert(`Error submitting delivery confirmation: ${error}`); + } } } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index e31dc2846..9490cc78c 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -1,7 +1,4 @@ -import { useEffect } from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; - -import apiClient from '@api/apiClient'; import Root from '@containers/root'; import NotFound from '@containers/404'; import LandingPage from '@containers/landingPage'; @@ -29,6 +26,7 @@ import { Authenticator } from '@aws-amplify/ui-react'; import { Amplify } from 'aws-amplify'; import CognitoAuthConfig from './aws-exports'; import { Button } from '@chakra-ui/react'; +import Unauthorized from '@containers/unauthorized'; Amplify.configure(CognitoAuthConfig); @@ -82,6 +80,54 @@ const router = createBrowserRouter([ element: , action: submitPantryApplicationForm, }, + + { + path: '/unauthorized', + element: , + }, + + // Private routes (protected by auth) + + { + path: '/landing-page', + element: ( + + + + ), + }, + { + path: '/pantry-overview', + element: ( + + + + ), + }, + { + path: '/pantry-dashboard/:pantryId', + element: ( + + + + ), + }, + { + path: '/pantry-past-orders', + element: ( + + + + ), + }, + { + path: '/pantries', + element: ( + + + + ), + }, { path: '/pantry-application/submitted', element: , @@ -143,21 +189,20 @@ const router = createBrowserRouter([ ), - loader: pantryIdLoader, }, { - path: '/donation-management', + path: '/approve-pantries', element: ( - + ), }, { - path: '/approve-pantries', + path: '/donation-management', element: ( - + ), }, @@ -199,10 +244,10 @@ const router = createBrowserRouter([ ]); export const App: React.FC = () => { - useEffect(() => { - document.title = 'SSF'; - apiClient.getHello().then((res) => console.log(res)); - }, []); + // useEffect(() => { + // document.title = 'SSF'; + // apiClient.getHello().then((res) => console.log(res)); + // }, []); return ( diff --git a/apps/frontend/src/aws-exports.ts b/apps/frontend/src/aws-exports.ts index e27da0b43..ad17bcd30 100644 --- a/apps/frontend/src/aws-exports.ts +++ b/apps/frontend/src/aws-exports.ts @@ -4,6 +4,9 @@ const CognitoAuthConfig = { userPoolClientId: '198bdfe995p1kb4jnopt3sk6i1', userPoolId: 'us-east-1_StSYXMibq', region: 'us-east-1', + loginWith: { + email: true, + }, }, }, }; diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index bbe3882ad..f2042addf 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -22,8 +22,8 @@ const ApprovePantries: React.FC = () => { try { const data = await ApiClient.getAllPendingPantries(); setPendingPantries(data); - } catch (error) { - alert('Error fetching unapproved pantries: ' + error); + } catch (err) { + console.log(err); } }; diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index a7d784410..424e74e18 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -44,7 +44,7 @@ const Homepage: React.FC = () => { - + Request Form (Pantry ID: 1) diff --git a/apps/frontend/src/containers/landingPage.tsx b/apps/frontend/src/containers/landingPage.tsx index 0e1a72b4c..98bbd7ede 100644 --- a/apps/frontend/src/containers/landingPage.tsx +++ b/apps/frontend/src/containers/landingPage.tsx @@ -1,5 +1,18 @@ +import { Button } from '@chakra-ui/react'; +import SignOutButton from '@components/signOutButton'; +import { signOut } from 'aws-amplify/auth'; + const LandingPage: React.FC = () => { - return <>Landing page; + const handleSignOut = async () => { + await signOut(); + }; + + return ( + <> + Landing page + + + ); }; export default LandingPage; diff --git a/apps/frontend/src/containers/root.tsx b/apps/frontend/src/containers/root.tsx index dea053b17..d179353fc 100644 --- a/apps/frontend/src/containers/root.tsx +++ b/apps/frontend/src/containers/root.tsx @@ -1,6 +1,10 @@ import { Outlet } from 'react-router-dom'; import Header from '../components/Header'; +import { useAuth } from '../hooks/useAuth'; + const Root: React.FC = () => { + useAuth(); + return (
diff --git a/apps/frontend/src/containers/unauthorized.tsx b/apps/frontend/src/containers/unauthorized.tsx new file mode 100644 index 000000000..83c687b7f --- /dev/null +++ b/apps/frontend/src/containers/unauthorized.tsx @@ -0,0 +1,10 @@ +export const Unauthorized: React.FC = () => { + return ( +
+

Oops!

+

You are not an authorized user for this page!

+
+ ); +}; + +export default Unauthorized; diff --git a/apps/frontend/src/hooks/useAuth.ts b/apps/frontend/src/hooks/useAuth.ts new file mode 100644 index 000000000..e97937615 --- /dev/null +++ b/apps/frontend/src/hooks/useAuth.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { fetchAuthSession } from 'aws-amplify/auth'; +import apiClient from '@api/apiClient'; + +// Hook to manage authentication state and set the API client's access token +export function useAuth() { + useEffect(() => { + const updateToken = async () => { + try { + const session = await fetchAuthSession(); + const idToken = session.tokens?.idToken?.toString(); + apiClient.setAccessToken(idToken); + } catch (error) { + console.error('Error fetching auth session:', error); + apiClient.setAccessToken(undefined); + } + }; + + updateToken(); + }, []); +}