Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ model User {
pendingEmail String? @map("pending_email")
emailVerificationToken String? @map("email_verification_token")
emailVerificationExpires DateTime? @map("email_verification_expires")
reactivationToken String? @map("reactivation_token")
reactivationTokenExpires DateTime? @map("reactivation_token_expires")
trustScore Int @default(0) @map("trust_score")
lastTrustScoreUpdate DateTime? @map("last_trust_score_update")
createdAt DateTime @default(now()) @map("created_at")
Expand Down
45 changes: 45 additions & 0 deletions src/auth/auth.service.captcha.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,51 @@ describe('AuthService – CAPTCHA failure lockout', () => {
});
});

describe('5 CAPTCHA failures trigger account lockout', () => {
it('locks account after 5 invalid CAPTCHA submissions', async () => {
mockCaptchaFail();
rateLimitService.isAccountLocked
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);

rateLimitService.getFailedAttemptsCount.mockResolvedValue(3);

let callCount = 0;
rateLimitService.recordFailedAttempt.mockImplementation(async () => {
callCount++;
// After 5th failed attempt, the account is considered locked
if (callCount >= 5) {
rateLimitService.isAccountLocked.mockResolvedValue(true);
rateLimitService.getLockoutInfo.mockResolvedValue({
isLocked: true,
remainingLockoutMinutes: 30,
});
return true;
}
return false;
});

const loginArgs = {
email: 'user@example.com',
password: 'pass',
captchaToken: 'bad',
};

// First 5 attempts fail with "Invalid CAPTCHA"
for (let i = 0; i < 5; i++) {
await expect(service.login(loginArgs)).rejects.toThrow('Invalid CAPTCHA');
}

// 6th attempt: account is locked
await expect(service.login(loginArgs)).rejects.toThrow(/locked/i);

expect(rateLimitService.recordFailedAttempt).toHaveBeenCalledTimes(5);
});
});

describe('password failure (unchanged behavior)', () => {
it('still records a failed attempt on wrong password', async () => {
rateLimitService.isAccountLocked.mockResolvedValue(false);
Expand Down
6 changes: 6 additions & 0 deletions src/auth/security.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ export function verifyTotpCode({
return false;
}

export function generateReactivationToken(): { token: string; hash: string } {
const token = randomToken(32);
const hash = createSha256(token);
return { token, hash };
}

export function parseDuration(input: string, fallbackSeconds: number): number {
const value = input?.trim();
if (!value) {
Expand Down
18 changes: 18 additions & 0 deletions src/cache/cache-metrics.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
/**
* Cache Metrics Interceptor
* Automatically tracks cache performance metrics
*
* DI Contract:
* This interceptor must be registered via app.useGlobalInterceptors() in main.ts
* using the already-resolved singleton from the DI container:
*
* const cacheMetricsInterceptor = app.get(CacheMetricsInterceptor);
* app.useGlobalInterceptors(cacheMetricsInterceptor);
*
* WHY: The CacheMonitoringService it depends on is provided at the CacheModule
* level. If @UseInterceptors(CacheMetricsInterceptor) were used on a controller
* instead, NestJS would instantiate a NEW interceptor instance from a sub-module
* injector, which would receive a DIFFERENT CacheMonitoringService singleton.
* As a result, metric counters would not reflect server-wide values, the stats
* endpoint would be unreliable, and alerting (low hit-rate / high response-time)
* would break silently.
*
* Any refactoring that changes how this interceptor is registered MUST verify
* that CacheMonitoringService remains a true application-wide singleton.
*/

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
Expand Down
3 changes: 3 additions & 0 deletions src/users/dto/deactivation.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ export class ReactivateAccountDto {
@IsOptional()
@IsString()
reason?: string;

@IsString()
token: string;
}
6 changes: 6 additions & 0 deletions src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ export class UsersController {
return this.usersService.deactivate(user.sub, deactivateDto);
}

@UseGuards(JwtAuthGuard)
@Post('me/request-reactivation')
requestReactivation(@CurrentUser() user: AuthUserPayload) {
return this.usersService.requestReactivation(user.sub);
}

@UseGuards(JwtAuthGuard)
@Post('me/reactivate')
reactivateAccount(
Expand Down
70 changes: 65 additions & 5 deletions src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import * as crypto from 'crypto';
import { PrismaService } from '../database/prisma.service';
import { CreateUserDto, SearchUsersDto, UpdatePreferencesDto, UpdateUserDto } from './dto/user.dto';
import { DeactivateAccountDto, ReactivateAccountDto } from './dto/deactivation.dto';
import { hashPassword, sanitizeUser } from '../auth/security.utils';
import { hashPassword, sanitizeUser, createSha256, generateReactivationToken } from '../auth/security.utils';
import * as fs from 'fs';
import * as path from 'path';
import { UpdateProfileDto } from './dto/update-profile.dto';
Expand Down Expand Up @@ -484,19 +485,76 @@ export class UsersService implements OnModuleInit {
return updatedUser;
}

async reactivate(userId: string, data: ReactivateAccountDto = {}) {
void data;
async requestReactivation(userId: string): Promise<{ message: string }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});

if (!user) {
throw new NotFoundException('User not found');
}

if (!user.isDeactivated) {
throw new BadRequestException('Account is not deactivated');
}

const { token, hash } = generateReactivationToken();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);

await this.prisma.user.update({
where: { id: userId },
data: {
reactivationToken: hash,
reactivationTokenExpires: expiresAt,
},
});

this.logger.log(`Reactivation token generated for user ${userId} (${user.email})`);

return {
message: 'Reactivation token generated. Use the token to reactivate your account.',
};
}

async reactivate(userId: string, data: ReactivateAccountDto) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});

if (!user) {
throw new Error('User not found');
throw new NotFoundException('User not found');
}

if (!user.isDeactivated) {
throw new Error('Account is not deactivated');
throw new BadRequestException('Account is not deactivated');
}

if (!user.reactivationToken || !user.reactivationTokenExpires) {
throw new BadRequestException('No reactivation token has been requested. Please request a token first.');
}

if (new Date() > user.reactivationTokenExpires) {
await this.prisma.user.update({
where: { id: userId },
data: {
reactivationToken: null,
reactivationTokenExpires: null,
},
});
throw new BadRequestException('Reactivation token has expired. Please request a new one.');
}

// Constant-time comparison of token hash
const providedHash = createSha256(data.token);
const storedHash = user.reactivationToken;
const storedHashBuffer = Buffer.from(storedHash);
const providedHashBuffer = Buffer.from(providedHash);

if (
storedHashBuffer.length !== providedHashBuffer.length ||
!crypto.timingSafeEqual(storedHashBuffer, providedHashBuffer)
) {
throw new BadRequestException('Invalid reactivation token');
}

const updatedUser = await this.prisma.user.update({
Expand All @@ -505,6 +563,8 @@ export class UsersService implements OnModuleInit {
isDeactivated: false,
deactivatedAt: null,
scheduledDeletionAt: null,
reactivationToken: null,
reactivationTokenExpires: null,
},
select: {
id: true,
Expand Down
78 changes: 78 additions & 0 deletions test/cache/cache-metrics.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
import { PrismaService } from '../../src/database/prisma.service';
import { CacheMonitoringService } from '../../src/cache/cache-monitoring.service';

class FakePrismaService {
users = new Map<string, any>();
blacklistedToken = new Map<string, any>();

async $connect() {}
async $disconnect() {}

user = {
findUnique: async ({ where }: any) => {
if (where?.id) return this.users.get(where.id) ?? null;
if (where?.email) return Array.from(this.users.values()).find((u) => u.email === where.email) ?? null;
return null;
},
update: async ({ where, data }: any) => {
const user = this.users.get(where.id);
const updated = { ...user, ...data };
this.users.set(where.id, updated);
return updated;
},
} as any;
}

describe('CacheMetricsInterceptor e2e — singleton verification', () => {
let app: INestApplication;
let monitoringService: CacheMonitoringService;
let fakePrisma: FakePrismaService;

beforeAll(async () => {
fakePrisma = new FakePrismaService();

const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(PrismaService)
.useValue(fakePrisma as any)
.compile();

app = moduleRef.createNestApplication();
await app.init();

monitoringService = app.get(CacheMonitoringService);
monitoringService.resetMetrics();
}, 20000);

afterAll(async () => {
await app.close();
});

it('records metrics after an HTTP request', async () => {
const metricsBefore = monitoringService.getMetrics();
expect(metricsBefore.totalRequests).toBe(0);

await request(app.getHttpServer())
.get('/api/properties')
.expect(200);

const metricsAfter = monitoringService.getMetrics();
expect(metricsAfter.totalRequests).toBeGreaterThanOrEqual(1);
expect(metricsAfter.avgResponseTime).toBeGreaterThan(0);
});

it('accumulates metrics across multiple requests', async () => {
monitoringService.resetMetrics();

await request(app.getHttpServer()).get('/api/properties');
await request(app.getHttpServer()).get('/api/properties');
await request(app.getHttpServer()).get('/api/properties');

const metrics = monitoringService.getMetrics();
expect(metrics.totalRequests).toBeGreaterThanOrEqual(3);
expect(metrics.avgResponseTime).toBeGreaterThan(0);
});
});
Loading