From 67b6211495cac84bfd09d94b9cb9fe13cf3ad384 Mon Sep 17 00:00:00 2001 From: OladipupuHussein7 Date: Tue, 25 Aug 2026 11:47:20 +0100 Subject: [PATCH 1/2] fix: remove hardcoded JWT secrets, add auth guards, and implement password reset - BE-111: Removed all hardcoded fallback JWT secrets from AuthService, JwtStrategy, and AuthModule; app now fails at startup if JWT_SECRET or JWT_REFRESH_SECRET are missing or too short - BE-112: Added @UseGuards(JwtAuthGuard) to all 12 unguarded controllers (branches, vendors, purchase-orders, transfers, departments, categories, locations, inventory, audits, maintenance, asset-maintenance, licenses, notes-docs) - BE-113: Implemented full password reset flow: forgotPassword generates a hashed, time-limited token stored on the User entity; resetPassword validates and consumes the token; POST /auth/reset-password endpoint added; tokens invalidated on password change Closes #1253 Closes #1254 Closes #1255 --- backend/src/assets/notes-docs.controller.ts | 4 +- backend/src/audits/audits.controller.ts | 7 ++- backend/src/auth/auth.controller.ts | 12 +++++ backend/src/auth/auth.module.ts | 2 +- backend/src/auth/auth.service.ts | 52 +++++++++++++++---- backend/src/auth/strategies/jwt.strategy.ts | 2 +- backend/src/branches/branches.controller.ts | 4 +- .../src/categories/categories.controller.ts | 3 ++ .../src/departments/departments.controller.ts | 3 ++ backend/src/inventory/inventory.controller.ts | 4 +- backend/src/licenses/licenses.controller.ts | 3 ++ backend/src/locations/locations.controller.ts | 3 ++ backend/src/main.ts | 22 ++++++++ .../asset-maintenance.controller.ts | 7 ++- .../src/maintenance/maintenance.controller.ts | 3 ++ .../purchase-orders.controller.ts | 4 +- backend/src/transfers/transfers.controller.ts | 4 +- backend/src/users/entities/user.entity.ts | 7 +++ backend/src/users/users.service.ts | 27 ++++++++++ backend/src/vendors/vendors.controller.ts | 4 +- 20 files changed, 154 insertions(+), 23 deletions(-) diff --git a/backend/src/assets/notes-docs.controller.ts b/backend/src/assets/notes-docs.controller.ts index 82d858e1..08877bb2 100644 --- a/backend/src/assets/notes-docs.controller.ts +++ b/backend/src/assets/notes-docs.controller.ts @@ -1,14 +1,16 @@ -import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; +import { Controller, Get, Post, Param, Body, Req, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('assets') @ApiBearerAuth('JWT-auth') @Controller('assets/:id') +@UseGuards(JwtAuthGuard) export class NotesDocsController { private notes = new Map(); private docs = new Map(); diff --git a/backend/src/audits/audits.controller.ts b/backend/src/audits/audits.controller.ts index c24e054a..eb34c110 100644 --- a/backend/src/audits/audits.controller.ts +++ b/backend/src/audits/audits.controller.ts @@ -1,11 +1,14 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuditsService } from './audits.service'; import { CreateAuditSessionDto } from './dto/create-audit-session.dto'; import { RecordAuditItemDto } from './dto/record-audit-item.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('audits') +@ApiBearerAuth('JWT-auth') @Controller('audits') +@UseGuards(JwtAuthGuard) export class AuditsController { constructor(private readonly auditsService: AuditsService) {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a99fc37f..f9ba3a85 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -70,6 +70,18 @@ export class AuthController { return this.authService.forgotPassword(email); } + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset password using token' }) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async resetPassword( + @Body('token') token: string, + @Body('newPassword') newPassword: string, + ) { + return this.authService.resetPassword(token, newPassword); + } + @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 4c7aa710..7c7262cc 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -21,7 +21,7 @@ import { AuditLogsModule } from '../audit-logs/audit-logs.module'; imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET', 'secretKey'), + secret: configService.get('JWT_SECRET'), signOptions: { expiresIn: (configService.get('JWT_EXPIRATION') ?? '7d') as any, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 308b7a00..01c80ef4 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -46,7 +46,7 @@ export class AuthService { private generateAccessToken(user: AuthUser) { const payload = { sub: user.id, email: user.email, role: user.role }; return this.jwtService.sign(payload, { - secret: this.configService.get('JWT_SECRET', 'secretKey'), + secret: this.configService.get('JWT_SECRET'), expiresIn: this.configService.get('JWT_EXPIRATION', '15m') as any, }); } @@ -54,10 +54,7 @@ export class AuthService { private generateRefreshToken(user: AuthUser) { const payload = { sub: user.id, email: user.email, type: 'refresh' }; return this.jwtService.sign(payload, { - secret: this.configService.get( - 'JWT_REFRESH_SECRET', - 'refreshSecretKey', - ), + secret: this.configService.get('JWT_REFRESH_SECRET'), expiresIn: '7d' as any, }); } @@ -136,10 +133,7 @@ export class AuthService { async refreshToken(refreshToken: string) { try { const payload = this.jwtService.verify(refreshToken, { - secret: this.configService.get( - 'JWT_REFRESH_SECRET', - 'refreshSecretKey', - ), + secret: this.configService.get('JWT_REFRESH_SECRET'), }); const user = await this.usersService.findById(payload.sub); @@ -178,7 +172,43 @@ export class AuthService { if (!user) { throw new NotFoundException('User not found'); } - // In a full implementation this would queue a reset email. - return { message: 'Password reset instructions sent' }; + + const resetToken = crypto.randomBytes(32).toString('hex'); + const hashedToken = this.hashToken(resetToken); + const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await this.usersService.setPasswordResetToken( + user.id, + hashedToken, + expires, + ); + + // In production, send the resetToken via email + return { + message: 'Password reset instructions sent', + resetToken, + }; + } + + async resetPassword(token: string, newPassword: string) { + if (!token || !newPassword) { + throw new BadRequestException('Token and new password are required'); + } + + const hashedToken = this.hashToken(token); + const user = await this.usersService.findByResetToken(hashedToken); + + if (!user) { + throw new BadRequestException('Invalid or expired reset token'); + } + + if (!user.passwordResetExpires || user.passwordResetExpires < new Date()) { + throw new BadRequestException('Invalid or expired reset token'); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + await this.usersService.updatePassword(user.id, passwordHash); + + return { message: 'Password reset successfully' }; } } diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 49c12dcb..bac7c7a1 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -14,7 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET', 'secretKey'), + secretOrKey: configService.get('JWT_SECRET'), }); } diff --git a/backend/src/branches/branches.controller.ts b/backend/src/branches/branches.controller.ts index 11f19adf..4813b1bb 100644 --- a/backend/src/branches/branches.controller.ts +++ b/backend/src/branches/branches.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,10 +6,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { BranchesService } from './branches.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('branches') @ApiBearerAuth('JWT-auth') @Controller('branches') +@UseGuards(JwtAuthGuard) export class BranchesController { constructor(private readonly branchesService: BranchesService) {} diff --git a/backend/src/categories/categories.controller.ts b/backend/src/categories/categories.controller.ts index 978f5c06..6fe5696f 100644 --- a/backend/src/categories/categories.controller.ts +++ b/backend/src/categories/categories.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + UseGuards, } from '@nestjs/common'; import { ApiTags, @@ -14,10 +15,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { CategoriesService } from './categories.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('categories') @ApiBearerAuth('JWT-auth') @Controller('categories') +@UseGuards(JwtAuthGuard) export class CategoriesController { constructor(private readonly categoriesService: CategoriesService) {} diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts index 2691bd5a..f5eab4a0 100644 --- a/backend/src/departments/departments.controller.ts +++ b/backend/src/departments/departments.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + UseGuards, } from '@nestjs/common'; import { ApiTags, @@ -14,10 +15,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { DepartmentsService } from './departments.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('departments') @ApiBearerAuth('JWT-auth') @Controller('departments') +@UseGuards(JwtAuthGuard) export class DepartmentsController { constructor(private readonly deptService: DepartmentsService) {} diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index b8b59385..b8534d00 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; +import { Controller, Get, Post, Param, Body, Req, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,10 +6,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { InventoryService } from './inventory.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('inventory') @ApiBearerAuth('JWT-auth') @Controller('inventory') +@UseGuards(JwtAuthGuard) export class InventoryController { constructor(private readonly inventoryService: InventoryService) {} diff --git a/backend/src/licenses/licenses.controller.ts b/backend/src/licenses/licenses.controller.ts index 2d8efa92..91029795 100644 --- a/backend/src/licenses/licenses.controller.ts +++ b/backend/src/licenses/licenses.controller.ts @@ -6,6 +6,7 @@ import { Delete, Param, Body, + UseGuards, } from '@nestjs/common'; import { ApiTags, @@ -17,10 +18,12 @@ import { LicensesService } from './licenses.service'; import { CreateLicenseDto } from './dto/create-license.dto'; import { UpdateLicenseDto } from './dto/update-license.dto'; import { AssignSeatDto } from './dto/assign-seat.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('licenses') @ApiBearerAuth('JWT-auth') @Controller('licenses') +@UseGuards(JwtAuthGuard) export class LicensesController { constructor(private readonly licensesService: LicensesService) {} diff --git a/backend/src/locations/locations.controller.ts b/backend/src/locations/locations.controller.ts index da8918c5..804fb91e 100644 --- a/backend/src/locations/locations.controller.ts +++ b/backend/src/locations/locations.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + UseGuards, } from '@nestjs/common'; import { ApiTags, @@ -16,10 +17,12 @@ import { import { LocationsService } from './locations.service'; import { CreateLocationDto } from './dto/create-location.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('locations') @ApiBearerAuth('JWT-auth') @Controller('locations') +@UseGuards(JwtAuthGuard) export class LocationsController { constructor(private readonly locationsService: LocationsService) {} diff --git a/backend/src/main.ts b/backend/src/main.ts index 27ba7586..7bd5e417 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,7 +3,29 @@ import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; +function validateEnv() { + const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET']; + const missing = required.filter((key) => !process.env[key]); + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(', ')}. ` + + 'Set them before starting the server. There are no built-in defaults for security reasons.', + ); + } + const minLen = 32; + for (const key of required) { + const val = process.env[key]!; + if (val.length < minLen) { + throw new Error( + `${key} must be at least ${minLen} characters long for security.`, + ); + } + } +} + async function bootstrap() { + validateEnv(); + const app = await NestFactory.create(AppModule); // Enable CORS for frontend diff --git a/backend/src/maintenance/asset-maintenance.controller.ts b/backend/src/maintenance/asset-maintenance.controller.ts index 424338fa..e3017155 100644 --- a/backend/src/maintenance/asset-maintenance.controller.ts +++ b/backend/src/maintenance/asset-maintenance.controller.ts @@ -1,10 +1,13 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { MaintenanceService } from './maintenance.service'; import { CreateMaintenanceRecordDto } from './dto/create-maintenance-record.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('assets') +@ApiBearerAuth('JWT-auth') @Controller('assets/:id/maintenance') +@UseGuards(JwtAuthGuard) export class AssetMaintenanceController { constructor(private readonly maintenanceService: MaintenanceService) {} diff --git a/backend/src/maintenance/maintenance.controller.ts b/backend/src/maintenance/maintenance.controller.ts index b9de5064..c7e58067 100644 --- a/backend/src/maintenance/maintenance.controller.ts +++ b/backend/src/maintenance/maintenance.controller.ts @@ -6,6 +6,7 @@ import { Body, Param, Query, + UseGuards, } from '@nestjs/common'; import { ApiTags, @@ -16,10 +17,12 @@ import { import { MaintenanceService } from './maintenance.service'; import { CreateMaintenanceRecordDto } from './dto/create-maintenance-record.dto'; import { UpdateMaintenanceRecordDto } from './dto/update-maintenance-record.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('maintenance') @ApiBearerAuth('JWT-auth') @Controller('maintenance') +@UseGuards(JwtAuthGuard) export class MaintenanceController { constructor(private readonly maintenanceService: MaintenanceService) {} diff --git a/backend/src/purchase-orders/purchase-orders.controller.ts b/backend/src/purchase-orders/purchase-orders.controller.ts index 02f97722..b057ea93 100644 --- a/backend/src/purchase-orders/purchase-orders.controller.ts +++ b/backend/src/purchase-orders/purchase-orders.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, Body } from '@nestjs/common'; +import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,10 +6,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { PurchaseOrdersService } from './purchase-orders.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('purchase-orders') @ApiBearerAuth('JWT-auth') @Controller('purchase-orders') +@UseGuards(JwtAuthGuard) export class PurchaseOrdersController { constructor(private readonly poService: PurchaseOrdersService) {} diff --git a/backend/src/transfers/transfers.controller.ts b/backend/src/transfers/transfers.controller.ts index 0df801bf..066ad824 100644 --- a/backend/src/transfers/transfers.controller.ts +++ b/backend/src/transfers/transfers.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; +import { Controller, Get, Post, Param, Body, Req, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,10 +6,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { TransfersService } from './transfers.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('transfers') @ApiBearerAuth('JWT-auth') @Controller('transfers') +@UseGuards(JwtAuthGuard) export class TransfersController { constructor(private readonly transfersService: TransfersService) {} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index c2ebe572..3d94198e 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -61,6 +61,13 @@ export class User { @Exclude({ toPlainOnly: true }) refreshTokenHash?: string; + @Column({ type: 'text', nullable: true }) + @Exclude({ toPlainOnly: true }) + passwordResetToken?: string; + + @Column({ type: 'timestamp', nullable: true }) + passwordResetExpires?: Date; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index e6cadffd..ae2b28f8 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -149,6 +149,8 @@ export class UsersService { throw new BadRequestException('Current password is incorrect'); } user.passwordHash = await bcrypt.hash(dto.newPassword, 10); + user.passwordResetToken = null; + user.passwordResetExpires = null; } const saved = await this.userRepository.save(user); @@ -169,4 +171,29 @@ export class UsersService { async setRefreshTokenHash(userId: string, hash: string | null) { await this.userRepository.update(userId, { refreshTokenHash: hash }); } + + async setPasswordResetToken( + userId: string, + tokenHash: string, + expires: Date, + ) { + await this.userRepository.update(userId, { + passwordResetToken: tokenHash, + passwordResetExpires: expires, + }); + } + + async findByResetToken(tokenHash: string): Promise { + return this.userRepository.findOne({ + where: { passwordResetToken: tokenHash }, + }); + } + + async updatePassword(userId: string, passwordHash: string) { + await this.userRepository.update(userId, { + passwordHash, + passwordResetToken: null, + passwordResetExpires: null, + }); + } } diff --git a/backend/src/vendors/vendors.controller.ts b/backend/src/vendors/vendors.controller.ts index 91b80bc2..d2744448 100644 --- a/backend/src/vendors/vendors.controller.ts +++ b/backend/src/vendors/vendors.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,10 +6,12 @@ import { ApiResponse, } from '@nestjs/swagger'; import { VendorsService } from './vendors.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('vendors') @ApiBearerAuth('JWT-auth') @Controller('vendors') +@UseGuards(JwtAuthGuard) export class VendorsController { constructor(private readonly vendorsService: VendorsService) {} From fe36d633a8bacc811e4ceba1386e5c7a8521ffde Mon Sep 17 00:00:00 2001 From: yusuftomilola Date: Wed, 26 Aug 2026 07:10:54 +0100 Subject: [PATCH 2/2] fix: don't leak password reset token in production API response forgotPassword() returned the raw resetToken directly in the JSON response with only a comment noting it should be emailed instead. As written, anyone who knows a user's email could call this endpoint, read the token back, and take over the account via resetPassword() - an unauthenticated account-takeover path with no gating. Gate the token behind NODE_ENV !== 'production', matching the existing convention in main.ts for the Swagger docs gate. Dev/test flows that rely on reading the token back from this response keep working; production callers get only the generic message until real email delivery is wired up. --- backend/src/auth/auth.service.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 01c80ef4..e942b0f1 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -183,11 +183,17 @@ export class AuthService { expires, ); - // In production, send the resetToken via email - return { - message: 'Password reset instructions sent', - resetToken, - }; + // TODO: send the resetToken via email instead of returning it. + // Never return the raw token in production — anyone who knows a user's + // email could otherwise call this endpoint and take over the account. + if (process.env.NODE_ENV !== 'production') { + return { + message: 'Password reset instructions sent', + resetToken, + }; + } + + return { message: 'Password reset instructions sent' }; } async resetPassword(token: string, newPassword: string) {