From 6e3f8b14392976ad249b466c064c602c10609294 Mon Sep 17 00:00:00 2001 From: Sam Piper Date: Thu, 9 May 2024 15:53:35 +0100 Subject: [PATCH] fix: more granular idempotency protection on routes + protection on authentication routes now --- .../authentication.controller.ts | 284 ++++++++-------- apps/anvil/src/sign-in/sign-in.controller.ts | 312 +++++++++--------- 2 files changed, 303 insertions(+), 293 deletions(-) diff --git a/apps/anvil/src/auth/authentication/authentication.controller.ts b/apps/anvil/src/auth/authentication/authentication.controller.ts index d418a20..3b21940 100644 --- a/apps/anvil/src/auth/authentication/authentication.controller.ts +++ b/apps/anvil/src/auth/authentication/authentication.controller.ts @@ -1,158 +1,164 @@ -import { User as GetUser } from "@/shared/decorators/user.decorator"; -import { IntegrationsService } from "@/users/integrations/integrations.service"; -import { UsersService } from "@/users/users.service"; -import type { User } from "@ignis/types/users"; +import {User as GetUser} from "@/shared/decorators/user.decorator"; +import {IntegrationsService} from "@/users/integrations/integrations.service"; +import {UsersService} from "@/users/users.service"; +import type {User} from "@ignis/types/users"; import { - BadRequestException, - ConflictException, - Controller, - Get, - Logger, - Post, - Query, - Redirect, - Req, - Res, - UnauthorizedException, - UseGuards, + BadRequestException, + ConflictException, + Controller, + Get, + Logger, + Post, + Query, + Redirect, + Req, + Res, + UnauthorizedException, + UseGuards, UseInterceptors, } from "@nestjs/common"; -import { AuthGuard } from "@nestjs/passport"; -import { Throttle } from "@nestjs/throttler"; -import { Request, Response } from "express"; -import { AuthenticationService } from "./authentication.service"; -import { BlacklistService } from "./blacklist/blacklist.service"; +import {AuthGuard} from "@nestjs/passport"; +import {Throttle} from "@nestjs/throttler"; +import {Request, Response} from "express"; +import {AuthenticationService} from "./authentication.service"; +import {BlacklistService} from "./blacklist/blacklist.service"; +import {IdempotencyCacheInterceptor} from "@/shared/interceptors/idempotency-cache.interceptor"; +import {IdempotencyCache} from "@/shared/decorators/idempotency.decorator"; @Controller("authentication") +@UseInterceptors(IdempotencyCacheInterceptor) export class AuthenticationController { - constructor( - private readonly authService: AuthenticationService, - private readonly integrationsService: IntegrationsService, - private readonly blacklistService: BlacklistService, - private readonly usersService: UsersService, - private readonly logger: Logger, - ) {} - - @UseGuards(AuthGuard("ldap")) - @Post("ldap-login") - async ldapLogin(@GetUser() user: User) { - this.logger.log(`LDAP login for user with ID: ${user.id}`, AuthenticationController.name); - return this.authService.login(user); - } - - @Post("refresh") - async refreshToken(@Req() req: Request, @Res({ passthrough: true }) res: Response) { - const refreshToken = req.cookies.refresh_token; - if (!refreshToken) { - this.logger.warn("Refresh token is missing", AuthenticationController.name); - throw new BadRequestException("Refresh token is missing"); + constructor( + private readonly authService: AuthenticationService, + private readonly integrationsService: IntegrationsService, + private readonly blacklistService: BlacklistService, + private readonly usersService: UsersService, + private readonly logger: Logger, + ) { } - const isBlacklisted = await this.blacklistService.isTokenBlacklisted(refreshToken); - if (isBlacklisted) { - this.logger.warn("The refresh token is blacklisted", AuthenticationController.name); - this.authService.clearAuthCookies(res); - throw new UnauthorizedException("The refresh token is no longer valid"); + @UseGuards(AuthGuard("ldap")) + @Post("ldap-login") + async ldapLogin(@GetUser() user: User) { + this.logger.log(`LDAP login for user with ID: ${user.id}`, AuthenticationController.name); + return this.authService.login(user); } - - const payload = await this.authService.validateRefreshToken(refreshToken); - - const user = await this.usersService.findOne(payload.sub); - if (!user) { - this.logger.warn("User not found", AuthenticationController.name); - throw new UnauthorizedException("User not found"); + + @Post("refresh") + async refreshToken(@Req() req: Request, @Res({passthrough: true}) res: Response) { + const refreshToken = req.cookies.refresh_token; + if (!refreshToken) { + this.logger.warn("Refresh token is missing", AuthenticationController.name); + throw new BadRequestException("Refresh token is missing"); + } + + const isBlacklisted = await this.blacklistService.isTokenBlacklisted(refreshToken); + if (isBlacklisted) { + this.logger.warn("The refresh token is blacklisted", AuthenticationController.name); + this.authService.clearAuthCookies(res); + throw new UnauthorizedException("The refresh token is no longer valid"); + } + + const payload = await this.authService.validateRefreshToken(refreshToken); + + const user = await this.usersService.findOne(payload.sub); + if (!user) { + this.logger.warn("User not found", AuthenticationController.name); + throw new UnauthorizedException("User not found"); + } + + const expiryDate = new Date(); + await this.blacklistService.addToBlacklist(refreshToken, expiryDate); + + const {access_token, refresh_token, csrf_token} = await this.authService.login(user); + + this.authService.setAuthCookies(res, access_token, refresh_token, csrf_token); + + this.logger.log("Tokens refreshed", AuthenticationController.name); + return {message: "Tokens refreshed"}; } - const expiryDate = new Date(); - await this.blacklistService.addToBlacklist(refreshToken, expiryDate); - - const { access_token, refresh_token, csrf_token } = await this.authService.login(user); + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + @Throttle({default: {limit: 1, ttl: 1000}}) + @Post("logout") + async logout(@Req() req: Request, @Res({passthrough: true}) res: Response) { + const refreshToken = req.cookies.refresh_token; + + if (!refreshToken) { + this.logger.warn("Refresh token is missing", AuthenticationController.name); + throw new BadRequestException("Refresh token is missing"); + } + + const expiryDate = new Date(); + try { + await this.blacklistService.addToBlacklist(refreshToken, expiryDate); + this.logger.log("Refresh token added to blacklist", AuthenticationController.name); + } catch (error) { + this.logger.error( + "Error adding refresh token to blacklist", + (error as Error).stack, + AuthenticationController.name, + ); + throw new ConflictException("Refresh token is invalid or expired"); + } + + this.authService.clearAuthCookies(res); + + this.logger.log("Successfully logged out", AuthenticationController.name); + return {message: "Successfully logged out"}; + } - this.authService.setAuthCookies(res, access_token, refresh_token, csrf_token); + // @UseGuards(AuthGuard("discord")) + // @Get("discord-login") + // async discordLogin(@Req() req: any) { + // // Check if user is authenticated with another method like LDAP before linking + // if (!req.isAuthenticated()) { + // throw new UnauthorizedException( + // "User is not authenticated with primary method", + // ); + // } + // // Link Discord account to authenticated user + // await this.integrationsService.linkDiscordAccount(req.user.id, req.user); + // return req.user; + // } + // + // @UseGuards(AuthGuard("discord")) + // @Get("discord/callback") + // async discordRedirect(@Req() req: any) { + // const tokens = await this.authService.login(req.user); + // return { + // ...tokens, + // user: req.user, + // }; + // } + + @UseGuards(AuthGuard("google")) + @Get("login") + async googleLogin(@Req() req: Request) { + this.logger.log("Google login initiated", AuthenticationController.name); + } - this.logger.log("Tokens refreshed", AuthenticationController.name); - return { message: "Tokens refreshed" }; - } + @UseGuards(AuthGuard("google")) + @Get("google/callback") + @Redirect() + async googleRedirect(@Req() req: any, @Res({passthrough: true}) res: Response) { + const {access_token, refresh_token, csrf_token} = await this.authService.login(req.user); - @Throttle({ default: { limit: 1, ttl: 1000 } }) - @Post("logout") - async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { - const refreshToken = req.cookies.refresh_token; + this.authService.setAuthCookies(res, access_token, refresh_token, csrf_token); - if (!refreshToken) { - this.logger.warn("Refresh token is missing", AuthenticationController.name); - throw new BadRequestException("Refresh token is missing"); + this.logger.log(`Google login successful for user with ID: ${req.user.id}`, AuthenticationController.name); + return {url: `${process.env.FRONT_END_URL}/auth/login/complete`}; } - const expiryDate = new Date(); - try { - await this.blacklistService.addToBlacklist(refreshToken, expiryDate); - this.logger.log("Refresh token added to blacklist", AuthenticationController.name); - } catch (error) { - this.logger.error( - "Error adding refresh token to blacklist", - (error as Error).stack, - AuthenticationController.name, - ); - throw new ConflictException("Refresh token is invalid or expired"); - } + @Get("validate-access") + async validateToken(@Req() req: Request, @Query("role") requiredRole: string) { + this.logger.log(`Validating access token for role: ${requiredRole}`, AuthenticationController.name); + const token = req.cookies.access_token; + if (!token) { + throw new UnauthorizedException("Access token is missing"); + } - this.authService.clearAuthCookies(res); - - this.logger.log("Successfully logged out", AuthenticationController.name); - return { message: "Successfully logged out" }; - } - - // @UseGuards(AuthGuard("discord")) - // @Get("discord-login") - // async discordLogin(@Req() req: any) { - // // Check if user is authenticated with another method like LDAP before linking - // if (!req.isAuthenticated()) { - // throw new UnauthorizedException( - // "User is not authenticated with primary method", - // ); - // } - // // Link Discord account to authenticated user - // await this.integrationsService.linkDiscordAccount(req.user.id, req.user); - // return req.user; - // } - // - // @UseGuards(AuthGuard("discord")) - // @Get("discord/callback") - // async discordRedirect(@Req() req: any) { - // const tokens = await this.authService.login(req.user); - // return { - // ...tokens, - // user: req.user, - // }; - // } - - @UseGuards(AuthGuard("google")) - @Get("login") - async googleLogin(@Req() req: Request) { - this.logger.log("Google login initiated", AuthenticationController.name); - } - - @UseGuards(AuthGuard("google")) - @Get("google/callback") - @Redirect() - async googleRedirect(@Req() req: any, @Res({ passthrough: true }) res: Response) { - const { access_token, refresh_token, csrf_token } = await this.authService.login(req.user); - - this.authService.setAuthCookies(res, access_token, refresh_token, csrf_token); - - this.logger.log(`Google login successful for user with ID: ${req.user.id}`, AuthenticationController.name); - return { url: `${process.env.FRONT_END_URL}/auth/login/complete` }; - } - - @Get("validate-access") - async validateToken(@Req() req: Request, @Query("role") requiredRole: string) { - this.logger.log(`Validating access token for role: ${requiredRole}`, AuthenticationController.name); - const token = req.cookies.access_token; - if (!token) { - throw new UnauthorizedException("Access token is missing"); + await this.authService.validateAccessToken(token, requiredRole); + return {status: "ok"}; } - - await this.authService.validateAccessToken(token, requiredRole); - return { status: "ok" }; - } } diff --git a/apps/anvil/src/sign-in/sign-in.controller.ts b/apps/anvil/src/sign-in/sign-in.controller.ts index f1e82e4..f0ef496 100644 --- a/apps/anvil/src/sign-in/sign-in.controller.ts +++ b/apps/anvil/src/sign-in/sign-in.controller.ts @@ -1,163 +1,167 @@ -import { CheckAbilities } from "@/auth/authorization/decorators/check-abilities-decorator"; -import { IsAdmin, IsRep } from "@/auth/authorization/decorators/check-roles-decorator"; -import { CaslAbilityGuard } from "@/auth/authorization/guards/casl-ability.guard"; -import { IdempotencyCache } from "@/shared/decorators/idempotency.decorator"; -import { User } from "@/shared/decorators/user.decorator"; -import { ldapLibraryToUcardNumber } from "@/shared/functions/utils"; -import { IdempotencyCacheInterceptor } from "@/shared/interceptors/idempotency-cache.interceptor"; -import { TrainingService } from "@/training/training.service"; -import { UsersService } from "@/users/users.service"; -import { sign_in as sign_in_ } from "@ignis/types"; -import type { List, Location, LocationStatus } from "@ignis/types/sign_in"; -import type { User as User_ } from "@ignis/types/users"; -import { Body, Controller, Get, Param, ParseIntPipe, Patch, Post, UseGuards, UseInterceptors } from "@nestjs/common"; -import { Logger } from "@nestjs/common"; -import { AuthGuard } from "@nestjs/passport"; -import { FinaliseSignInDto, UpdateSignInDto } from "./dto/sigs-in-dto"; -import { SignInService } from "./sign-in.service"; +import {CheckAbilities} from "@/auth/authorization/decorators/check-abilities-decorator"; +import {IsAdmin, IsRep} from "@/auth/authorization/decorators/check-roles-decorator"; +import {CaslAbilityGuard} from "@/auth/authorization/guards/casl-ability.guard"; +import {IdempotencyCache} from "@/shared/decorators/idempotency.decorator"; +import {User} from "@/shared/decorators/user.decorator"; +import {ldapLibraryToUcardNumber} from "@/shared/functions/utils"; +import {IdempotencyCacheInterceptor} from "@/shared/interceptors/idempotency-cache.interceptor"; +import {TrainingService} from "@/training/training.service"; +import {UsersService} from "@/users/users.service"; +import {sign_in as sign_in_} from "@ignis/types"; +import type {List, Location, LocationStatus} from "@ignis/types/sign_in"; +import type {User as User_} from "@ignis/types/users"; +import {Body, Controller, Get, Param, ParseIntPipe, Patch, Post, UseGuards, UseInterceptors} from "@nestjs/common"; +import {Logger} from "@nestjs/common"; +import {AuthGuard} from "@nestjs/passport"; +import {FinaliseSignInDto, UpdateSignInDto} from "./dto/sigs-in-dto"; +import {SignInService} from "./sign-in.service"; @Controller("location/:location") -@UseInterceptors(IdempotencyCacheInterceptor) @UseGuards(AuthGuard("jwt"), CaslAbilityGuard) export class SignInController { - constructor( - private readonly trainingService: TrainingService, - private readonly signInService: SignInService, - private readonly userService: UsersService, - private readonly logger: Logger, - ) {} - - @Get() - @IsRep() - async getList(@Param("location") location: Location): Promise { - return this.signInService.getList(location); - } - - @Get("sign-in/:ucard_number") - @IsRep() - async signInOptions( - @Param("location") location: Location, - @Param("ucard_number") ucard_number: string, - ): Promise { - this.logger.log( - `Retrieving sign-in options for UCard number: ${ucard_number} at location: ${location}`, - SignInController.name, - ); - const user = await this.signInService.getUserForSignIn(location, ucard_number); - - if (user?.is_rep) { - return { - // reasons, - training: await this.signInService.getTrainings(user.id, location), - ...user, - ...{ infractions: [] }, - }; + constructor( + private readonly trainingService: TrainingService, + private readonly signInService: SignInService, + private readonly userService: UsersService, + private readonly logger: Logger, + ) { } - const extras = await this.signInService.preSignInChecks(location, user.ucard_number); - - // const [trainings, reasons] = await Promise.all([ - // this.trainingService.getUserxxTrainingForLocation(user.username, location), - // this.signInService.getSignInReasons(), - // ]); - return { - // reasons, - training: await this.signInService.getTrainings(user.id, location), - ...user, - ...extras, - }; - } - - @Post("sign-in/:ucard_number") - @IsRep() - @IdempotencyCache(60) - async signIn( - @Param("location") location: Location, - @Param("ucard_number") ucard_number: string, - @Body() finaliseSignInDto: FinaliseSignInDto, - ) { - this.logger.log(`Signing in UCard number: ${ucard_number} at location: ${location}`, SignInController.name); - const ucard_number_ = ldapLibraryToUcardNumber(ucard_number); - if (await this.signInService.isRep(ucard_number_)) { - return await this.signInService.repSignIn(location, ucard_number_, finaliseSignInDto.reason_id); + @Get() + @IsRep() + async getList(@Param("location") location: Location): Promise { + return this.signInService.getList(location); } - return await this.signInService.signIn( - location, - ucard_number_, - finaliseSignInDto.tools, - finaliseSignInDto.reason_id, - ); - } - - @Patch("sign-in/:ucard_number") - @IsRep() - async updateVisitPurpose( - @Param("location") location: Location, - @Param("ucard_number", ParseIntPipe) ucard_number: number, - @Body() updateSignInDto: UpdateSignInDto, - ) { - this.logger.log( - `Updating visit purpose for UCard number: ${ucard_number} at location: ${location}`, - SignInController.name, - ); - return await this.signInService.updateVisitPurpose( - location, - ucard_number, - updateSignInDto.tools, - updateSignInDto.reason_id, - ); - } - - @Post("sign-out/:ucard_number") - @IsRep() - @IdempotencyCache(60) - async signOut(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { - this.logger.log(`Signing out UCard number: ${ucard_number} at location: ${location}`, SignInController.name); - return await this.signInService.signOut(location, ldapLibraryToUcardNumber(ucard_number)); - } - - @Get("status") - async getLocationStatus(@Param("location") location: Location): Promise { - this.logger.log(`Retrieving sign-in status for location: ${location}`, SignInController.name); - return await this.signInService.getStatusForLocation(location); - } - - // @Post("queue/remotely") - // @IdempotencyCache(60) - // async addToQueueRemotely(@Param("location") location: Location, @User() user: User_) { - // this.logger.log( - // `Adding user with ID: ${user.id} to queue remotely at location: ${location}`, - // SignInController.name, - // ); - // await this.signInService.addToQueue(location, undefined, user.id); - // } - - @Post("queue/in-person/:ucard_number") - @IsRep() - @IdempotencyCache(60) - async addToQueueInPerson(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { - this.logger.log( - `Adding UCard number: ${ucard_number} to queue in-person at location: ${location}`, - SignInController.name, - ); - await this.signInService.addToQueue(location, ucard_number); - } - - @Post("queue/remove/:id") - @IsRep() - @CheckAbilities(["READ"], "ALL") // FIXME: needs an any rather than all guard also allows for users to remove themselves - @IdempotencyCache(60) - async removeFromQueue(@Param("location") location: Location, @Param("id") user_id: string) { - this.logger.log(`Removing user with ID: ${user_id} from queue at location: ${location}`, SignInController.name); - await this.signInService.removeFromQueue(location, user_id); - } - - // FIXME: events sign in - - @Get("/common-reasons") - @IsRep() - async getPopularSignInReasons(@Param("location") location: Location) { - return this.signInService.getPopularSignInReasons(location); - } + @Get("sign-in/:ucard_number") + @IsRep() + async signInOptions( + @Param("location") location: Location, + @Param("ucard_number") ucard_number: string, + ): Promise { + this.logger.log( + `Retrieving sign-in options for UCard number: ${ucard_number} at location: ${location}`, + SignInController.name, + ); + const user = await this.signInService.getUserForSignIn(location, ucard_number); + + if (user?.is_rep) { + return { + // reasons, + training: await this.signInService.getTrainings(user.id, location), + ...user, + ...{infractions: []}, + }; + } + + const extras = await this.signInService.preSignInChecks(location, user.ucard_number); + + // const [trainings, reasons] = await Promise.all([ + // this.trainingService.getUserxxTrainingForLocation(user.username, location), + // this.signInService.getSignInReasons(), + // ]); + return { + // reasons, + training: await this.signInService.getTrainings(user.id, location), + ...user, + ...extras, + }; + } + + @Post("sign-in/:ucard_number") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + async signIn( + @Param("location") location: Location, + @Param("ucard_number") ucard_number: string, + @Body() finaliseSignInDto: FinaliseSignInDto, + ) { + this.logger.log(`Signing in UCard number: ${ucard_number} at location: ${location}`, SignInController.name); + const ucard_number_ = ldapLibraryToUcardNumber(ucard_number); + if (await this.signInService.isRep(ucard_number_)) { + return await this.signInService.repSignIn(location, ucard_number_, finaliseSignInDto.reason_id); + } + + return await this.signInService.signIn( + location, + ucard_number_, + finaliseSignInDto.tools, + finaliseSignInDto.reason_id, + ); + } + + @Patch("sign-in/:ucard_number") + @IsRep() + async updateVisitPurpose( + @Param("location") location: Location, + @Param("ucard_number", ParseIntPipe) ucard_number: number, + @Body() updateSignInDto: UpdateSignInDto, + ) { + this.logger.log( + `Updating visit purpose for UCard number: ${ucard_number} at location: ${location}`, + SignInController.name, + ); + return await this.signInService.updateVisitPurpose( + location, + ucard_number, + updateSignInDto.tools, + updateSignInDto.reason_id, + ); + } + + @Post("sign-out/:ucard_number") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + async signOut(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { + this.logger.log(`Signing out UCard number: ${ucard_number} at location: ${location}`, SignInController.name); + return await this.signInService.signOut(location, ldapLibraryToUcardNumber(ucard_number)); + } + + @Get("status") + async getLocationStatus(@Param("location") location: Location): Promise { + this.logger.log(`Retrieving sign-in status for location: ${location}`, SignInController.name); + return await this.signInService.getStatusForLocation(location); + } + + // @Post("queue/remotely") + // @IdempotencyCache(60) + // async addToQueueRemotely(@Param("location") location: Location, @User() user: User_) { + // this.logger.log( + // `Adding user with ID: ${user.id} to queue remotely at location: ${location}`, + // SignInController.name, + // ); + // await this.signInService.addToQueue(location, undefined, user.id); + // } + + @Post("queue/in-person/:ucard_number") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + async addToQueueInPerson(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { + this.logger.log( + `Adding UCard number: ${ucard_number} to queue in-person at location: ${location}`, + SignInController.name, + ); + await this.signInService.addToQueue(location, ucard_number); + } + + @Post("queue/remove/:id") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @CheckAbilities(["READ"], "ALL") // FIXME: needs an any rather than all guard also allows for users to remove themselves + @IdempotencyCache(60) + async removeFromQueue(@Param("location") location: Location, @Param("id") user_id: string) { + this.logger.log(`Removing user with ID: ${user_id} from queue at location: ${location}`, SignInController.name); + await this.signInService.removeFromQueue(location, user_id); + } + + // FIXME: events sign in + + @Get("/common-reasons") + @IsRep() + async getPopularSignInReasons(@Param("location") location: Location) { + return this.signInService.getPopularSignInReasons(location); + } }