diff --git a/.env.example b/.env.example
index 836f2915..36973c5c 100644
--- a/.env.example
+++ b/.env.example
@@ -102,6 +102,7 @@ PASSWORD_REQUIRE_NUMBERS=true
PASSWORD_REQUIRE_UPPERCASE=true
PASSWORD_HISTORY_COUNT=5
PASSWORD_EXPIRY_DAYS=90
+PASSWORD_EXPIRY_WARNING_DAYS=7
# Authentication Security
JWT_BLACKLIST_ENABLED=true
diff --git a/junit.xml b/junit.xml
index b6f24d62..a2a40b7f 100644
--- a/junit.xml
+++ b/junit.xml
@@ -61,7 +61,7 @@
-
+
@@ -265,7 +265,7 @@
-
+
@@ -337,13 +337,13 @@
-
+
-
+
@@ -401,7 +401,7 @@
-
+
@@ -411,7 +411,7 @@
-
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 77c01a4c..df173052 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -42,6 +42,9 @@ model User {
// Activity
activities UserActivity[]
+ // Password history for rotation policy
+ passwordHistory PasswordHistory[]
+
properties Property[]
receivedTransactions Transaction[] @relation("UserTransactions")
userRole Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
@@ -373,20 +376,58 @@ model Document {
}
model ApiKey {
- id String @id @default(cuid())
- name String
- key String @unique
- keyPrefix String @map("key_prefix")
- scopes String[]
- requestCount BigInt @default(0) @map("request_count")
- lastUsedAt DateTime? @map("last_used_at")
- isActive Boolean @default(true) @map("is_active")
- rateLimit Int? @map("rate_limit")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ id String @id @default(cuid())
+ name String
+ key String @unique
+ keyPrefix String @map("key_prefix")
+ keyVersion Int @default(1) @map("key_version")
+ scopes String[]
+ requestCount BigInt @default(0) @map("request_count")
+ lastUsedAt DateTime? @map("last_used_at")
+ isActive Boolean @default(true) @map("is_active")
+ rateLimit Int? @map("rate_limit")
+ lastRotatedAt DateTime? @map("last_rotated_at")
+ rotationDueAt DateTime? @map("rotation_due_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ usageLogs ApiKeyUsageLog[]
@@index([keyPrefix])
@@index([isActive])
@@index([createdAt])
+ @@index([rotationDueAt])
@@map("api_keys")
}
+
+model ApiKeyUsageLog {
+ id String @id @default(cuid())
+ apiKeyId String @map("api_key_id")
+ endpoint String
+ method String
+ statusCode Int @map("status_code")
+ responseTime Int @map("response_time")
+ ipAddress String? @map("ip_address")
+ userAgent String? @map("user_agent")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
+
+ @@index([apiKeyId])
+ @@index([endpoint])
+ @@index([createdAt])
+ @@map("api_key_usage_logs")
+}
+
+model PasswordHistory {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ passwordHash String @map("password_hash")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@index([createdAt])
+ @@map("password_history")
+}
diff --git a/src/api-keys/api-key-analytics.service.ts b/src/api-keys/api-key-analytics.service.ts
new file mode 100644
index 00000000..a7c391ef
--- /dev/null
+++ b/src/api-keys/api-key-analytics.service.ts
@@ -0,0 +1,281 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../database/prisma/prisma.service';
+
+export interface UsageLogEntry {
+ apiKeyId: string;
+ endpoint: string;
+ method: string;
+ statusCode: number;
+ responseTime: number;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+export interface ApiKeyAnalyticsSummary {
+ totalRequests: number;
+ uniqueEndpoints: number;
+ averageResponseTime: number;
+ errorRate: number;
+ topEndpoints: EndpointUsage[];
+ requestsByDay: DailyRequestCount[];
+ requestsByHour: HourlyRequestCount[];
+}
+
+export interface EndpointUsage {
+ endpoint: string;
+ method: string;
+ count: number;
+ averageResponseTime: number;
+ errorCount: number;
+}
+
+export interface DailyRequestCount {
+ date: string;
+ count: number;
+}
+
+export interface HourlyRequestCount {
+ hour: number;
+ count: number;
+}
+
+export interface ApiKeyUsageReport {
+ apiKeyId: string;
+ apiKeyName: string;
+ period: {
+ start: Date;
+ end: Date;
+ };
+ summary: ApiKeyAnalyticsSummary;
+}
+
+@Injectable()
+export class ApiKeyAnalyticsService {
+ private readonly logger = new Logger(ApiKeyAnalyticsService.name);
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ /**
+ * Log an API key usage event
+ */
+ async logUsage(entry: UsageLogEntry): Promise {
+ try {
+ await this.prisma.apiKeyUsageLog.create({
+ data: {
+ apiKeyId: entry.apiKeyId,
+ endpoint: entry.endpoint,
+ method: entry.method,
+ statusCode: entry.statusCode,
+ responseTime: entry.responseTime,
+ ipAddress: entry.ipAddress,
+ userAgent: entry.userAgent,
+ },
+ });
+ } catch (error) {
+ this.logger.error(`Failed to log API key usage: ${error.message}`);
+ // Don't throw - usage logging should not break the request
+ }
+ }
+
+ /**
+ * Get analytics summary for a specific API key
+ */
+ async getAnalyticsSummary(apiKeyId: string, startDate: Date, endDate: Date): Promise {
+ const logs = await this.prisma.apiKeyUsageLog.findMany({
+ where: {
+ apiKeyId,
+ createdAt: {
+ gte: startDate,
+ lte: endDate,
+ },
+ },
+ select: {
+ endpoint: true,
+ method: true,
+ statusCode: true,
+ responseTime: true,
+ createdAt: true,
+ },
+ });
+
+ const totalRequests = logs.length;
+ const errorCount = logs.filter(l => l.statusCode >= 400).length;
+ const totalResponseTime = logs.reduce((sum, l) => sum + l.responseTime, 0);
+
+ // Group by endpoint
+ const endpointMap = new Map();
+ for (const log of logs) {
+ const key = `${log.method} ${log.endpoint}`;
+ const existing = endpointMap.get(key) || { count: 0, responseTime: 0, errors: 0 };
+ existing.count++;
+ existing.responseTime += log.responseTime;
+ if (log.statusCode >= 400) {
+ existing.errors++;
+ }
+ endpointMap.set(key, existing);
+ }
+
+ const topEndpoints: EndpointUsage[] = Array.from(endpointMap.entries())
+ .map(([key, data]) => {
+ const [method, endpoint] = key.split(' ', 2);
+ return {
+ endpoint,
+ method,
+ count: data.count,
+ averageResponseTime: Math.round(data.responseTime / data.count),
+ errorCount: data.errors,
+ };
+ })
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 10);
+
+ // Group by day
+ const dayMap = new Map();
+ for (const log of logs) {
+ const day = log.createdAt.toISOString().split('T')[0];
+ dayMap.set(day, (dayMap.get(day) || 0) + 1);
+ }
+
+ const requestsByDay: DailyRequestCount[] = Array.from(dayMap.entries())
+ .map(([date, count]) => ({ date, count }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+
+ // Group by hour
+ const hourMap = new Map();
+ for (const log of logs) {
+ const hour = log.createdAt.getHours();
+ hourMap.set(hour, (hourMap.get(hour) || 0) + 1);
+ }
+
+ const requestsByHour: HourlyRequestCount[] = Array.from(hourMap.entries())
+ .map(([hour, count]) => ({ hour, count }))
+ .sort((a, b) => a.hour - b.hour);
+
+ return {
+ totalRequests,
+ uniqueEndpoints: endpointMap.size,
+ averageResponseTime: totalRequests > 0 ? Math.round(totalResponseTime / totalRequests) : 0,
+ errorRate: totalRequests > 0 ? Math.round((errorCount / totalRequests) * 100) : 0,
+ topEndpoints,
+ requestsByDay,
+ requestsByHour,
+ };
+ }
+
+ /**
+ * Get a full usage report for an API key
+ */
+ async getUsageReport(apiKeyId: string, startDate: Date, endDate: Date): Promise {
+ const apiKey = await this.prisma.apiKey.findUnique({
+ where: { id: apiKeyId },
+ select: { id: true, name: true },
+ });
+
+ if (!apiKey) {
+ throw new Error(`API key with ID ${apiKeyId} not found`);
+ }
+
+ const summary = await this.getAnalyticsSummary(apiKeyId, startDate, endDate);
+
+ return {
+ apiKeyId: apiKey.id,
+ apiKeyName: apiKey.name,
+ period: { start: startDate, end: endDate },
+ summary,
+ };
+ }
+
+ /**
+ * Get analytics for all API keys (admin view)
+ */
+ async getAllKeysAnalytics(startDate: Date, endDate: Date): Promise {
+ const apiKeys = await this.prisma.apiKey.findMany({
+ select: { id: true, name: true },
+ });
+
+ const reports: ApiKeyUsageReport[] = [];
+ for (const apiKey of apiKeys) {
+ const summary = await this.getAnalyticsSummary(apiKey.id, startDate, endDate);
+ if (summary.totalRequests > 0) {
+ reports.push({
+ apiKeyId: apiKey.id,
+ apiKeyName: apiKey.name,
+ period: { start: startDate, end: endDate },
+ summary,
+ });
+ }
+ }
+
+ return reports.sort((a, b) => b.summary.totalRequests - a.summary.totalRequests);
+ }
+
+ /**
+ * Clean up old usage logs (data retention)
+ */
+ async cleanupOldLogs(retentionDays: number): Promise {
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
+
+ const result = await this.prisma.apiKeyUsageLog.deleteMany({
+ where: {
+ createdAt: { lt: cutoffDate },
+ },
+ });
+
+ this.logger.log(`Cleaned up ${result.count} old API key usage logs`);
+ return result.count;
+ }
+
+ /**
+ * Get usage statistics for a specific endpoint
+ */
+ async getEndpointStats(
+ endpoint: string,
+ startDate: Date,
+ endDate: Date,
+ ): Promise<{
+ totalRequests: number;
+ averageResponseTime: number;
+ errorRate: number;
+ topApiKeys: { apiKeyId: string; apiKeyName: string; count: number }[];
+ }> {
+ const logs = await this.prisma.apiKeyUsageLog.findMany({
+ where: {
+ endpoint,
+ createdAt: {
+ gte: startDate,
+ lte: endDate,
+ },
+ },
+ include: {
+ apiKey: {
+ select: { id: true, name: true },
+ },
+ },
+ });
+
+ const totalRequests = logs.length;
+ const errorCount = logs.filter(l => l.statusCode >= 400).length;
+ const totalResponseTime = logs.reduce((sum, l) => sum + l.responseTime, 0);
+
+ // Group by API key
+ const keyMap = new Map();
+ for (const log of logs) {
+ const existing = keyMap.get(log.apiKeyId) || { name: log.apiKey.name, count: 0 };
+ existing.count++;
+ keyMap.set(log.apiKeyId, existing);
+ }
+
+ const topApiKeys = Array.from(keyMap.entries())
+ .map(([apiKeyId, data]) => ({ apiKeyId, apiKeyName: data.name, count: data.count }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 10);
+
+ return {
+ totalRequests,
+ averageResponseTime: totalRequests > 0 ? Math.round(totalResponseTime / totalRequests) : 0,
+ errorRate: totalRequests > 0 ? Math.round((errorCount / totalRequests) * 100) : 0,
+ topApiKeys,
+ };
+ }
+}
diff --git a/src/api-keys/api-key-rotation.scheduler.ts b/src/api-keys/api-key-rotation.scheduler.ts
new file mode 100644
index 00000000..306d73ca
--- /dev/null
+++ b/src/api-keys/api-key-rotation.scheduler.ts
@@ -0,0 +1,83 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Cron, CronExpression } from '@nestjs/schedule';
+import { ApiKeyService } from './api-key.service';
+import { ApiKeyAnalyticsService } from './api-key-analytics.service';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class ApiKeyRotationScheduler {
+ private readonly logger = new Logger(ApiKeyRotationScheduler.name);
+ private readonly retentionDays: number;
+
+ constructor(
+ private readonly apiKeyService: ApiKeyService,
+ private readonly analyticsService: ApiKeyAnalyticsService,
+ private readonly configService: ConfigService,
+ ) {
+ this.retentionDays = this.configService.get('API_KEY_LOG_RETENTION_DAYS', 90);
+ }
+
+ /**
+ * Check for expired API keys and rotate them automatically
+ * Runs daily at midnight
+ */
+ @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
+ async handleAutomaticRotation(): Promise {
+ this.logger.log('Starting automatic API key rotation check...');
+
+ try {
+ const results = await this.apiKeyService.autoRotateExpiredKeys();
+
+ if (results.length > 0) {
+ this.logger.log(`Automatically rotated ${results.length} API key(s)`);
+ results.forEach(result => {
+ this.logger.log(` - ${result.name}: ${result.oldKeyPrefix} -> ${result.newKeyPrefix}`);
+ });
+ } else {
+ this.logger.log('No API keys required automatic rotation');
+ }
+ } catch (error) {
+ this.logger.error(`Automatic rotation failed: ${error.message}`);
+ }
+ }
+
+ /**
+ * Check for keys approaching rotation and log warnings
+ * Runs daily at 6 AM
+ */
+ @Cron(CronExpression.EVERY_DAY_AT_6AM)
+ async handleRotationWarnings(): Promise {
+ this.logger.log('Checking for API keys approaching rotation...');
+
+ try {
+ const approachingKeys = await this.apiKeyService.getKeysApproachingRotation();
+
+ if (approachingKeys.length > 0) {
+ this.logger.warn(`${approachingKeys.length} API key(s) will require rotation soon:`);
+ approachingKeys.forEach(key => {
+ this.logger.warn(` - ${key.name} (${key.keyPrefix}): ${key.daysUntilRotation} days until rotation`);
+ });
+ } else {
+ this.logger.log('No API keys approaching rotation');
+ }
+ } catch (error) {
+ this.logger.error(`Rotation warning check failed: ${error.message}`);
+ }
+ }
+
+ /**
+ * Clean up old usage logs for data retention compliance
+ * Runs weekly on Sunday at 2 AM
+ */
+ @Cron(CronExpression.EVERY_WEEK)
+ async handleLogCleanup(): Promise {
+ this.logger.log('Starting API key usage log cleanup...');
+
+ try {
+ const deletedCount = await this.analyticsService.cleanupOldLogs(this.retentionDays);
+ this.logger.log(`Cleaned up ${deletedCount} old usage log entries`);
+ } catch (error) {
+ this.logger.error(`Log cleanup failed: ${error.message}`);
+ }
+ }
+}
diff --git a/src/api-keys/api-key.controller.ts b/src/api-keys/api-key.controller.ts
index c126ea7e..43643e3c 100644
--- a/src/api-keys/api-key.controller.ts
+++ b/src/api-keys/api-key.controller.ts
@@ -11,8 +11,8 @@ import {
HttpStatus,
Query,
} from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
-import { ApiKeyService } from './api-key.service';
+import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
+import { ApiKeyService, RotationStatus, RotationResult } from './api-key.service';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { ApiKeyResponseDto, CreateApiKeyResponseDto } from './dto/api-key-response.dto';
@@ -125,4 +125,91 @@ export class ApiKeyController {
async revoke(@Param('id') id: string): Promise {
return this.apiKeyService.revoke(id);
}
+
+ // ==================== ROTATION ENDPOINTS ====================
+
+ @Post(':id/rotate')
+ @ApiOperation({
+ summary: 'Rotate API key',
+ description: 'Generate a new API key and deactivate the old one. The new key is shown only once.',
+ })
+ @ApiParam({ name: 'id', description: 'API key ID' })
+ @ApiResponse({
+ status: 200,
+ description: 'API key rotated successfully',
+ })
+ @ApiResponse({ status: 404, description: 'API key not found' })
+ @ApiResponse({ status: 400, description: 'Cannot rotate a revoked API key' })
+ @ApiResponse({ status: 401, description: 'Unauthorized' })
+ async rotateKey(@Param('id') id: string): Promise {
+ return this.apiKeyService.rotateKey(id);
+ }
+
+ @Get(':id/rotation-status')
+ @ApiOperation({
+ summary: 'Get rotation status',
+ description: 'Check if an API key requires rotation and when it was last rotated',
+ })
+ @ApiParam({ name: 'id', description: 'API key ID' })
+ @ApiResponse({
+ status: 200,
+ description: 'Rotation status retrieved successfully',
+ })
+ @ApiResponse({ status: 404, description: 'API key not found' })
+ @ApiResponse({ status: 401, description: 'Unauthorized' })
+ async getRotationStatus(@Param('id') id: string): Promise {
+ return this.apiKeyService.getRotationStatus(id);
+ }
+
+ @Get('rotation/required')
+ @ApiOperation({
+ summary: 'Get keys requiring rotation',
+ description: 'List all API keys that have passed their rotation due date',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'List of API keys requiring rotation',
+ })
+ @ApiResponse({ status: 401, description: 'Unauthorized' })
+ async getKeysRequiringRotation(): Promise {
+ return this.apiKeyService.getKeysRequiringRotation();
+ }
+
+ @Get('rotation/approaching')
+ @ApiOperation({
+ summary: 'Get keys approaching rotation',
+ description: 'List all API keys that will require rotation within the warning period',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'List of API keys approaching rotation',
+ })
+ @ApiResponse({ status: 401, description: 'Unauthorized' })
+ async getKeysApproachingRotation(): Promise {
+ return this.apiKeyService.getKeysApproachingRotation();
+ }
+
+ // ==================== ANALYTICS ENDPOINTS ====================
+
+ @Get(':id/analytics')
+ @ApiOperation({
+ summary: 'Get API key usage analytics',
+ description: 'Retrieve detailed usage analytics for a specific API key',
+ })
+ @ApiParam({ name: 'id', description: 'API key ID' })
+ @ApiQuery({ name: 'startDate', description: 'Start date (ISO 8601)', example: '2026-01-01T00:00:00Z' })
+ @ApiQuery({ name: 'endDate', description: 'End date (ISO 8601)', example: '2026-01-31T23:59:59Z' })
+ @ApiResponse({
+ status: 200,
+ description: 'Usage analytics retrieved successfully',
+ })
+ @ApiResponse({ status: 404, description: 'API key not found' })
+ @ApiResponse({ status: 401, description: 'Unauthorized' })
+ async getUsageAnalytics(
+ @Param('id') id: string,
+ @Query('startDate') startDate: string,
+ @Query('endDate') endDate: string,
+ ) {
+ return this.apiKeyService.getUsageAnalytics(id, new Date(startDate), new Date(endDate));
+ }
}
diff --git a/src/api-keys/api-key.service.ts b/src/api-keys/api-key.service.ts
index a1fbc959..8ff5b1c0 100644
--- a/src/api-keys/api-key.service.ts
+++ b/src/api-keys/api-key.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException, UnauthorizedException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../database/prisma/prisma.service';
import { RedisService } from '../common/services/redis.service';
@@ -7,22 +7,48 @@ import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { ApiKeyResponseDto, CreateApiKeyResponseDto } from './dto/api-key-response.dto';
import { API_KEY_SCOPES, ApiKeyScope } from './enums/api-key-scope.enum';
+import { ApiKeyAnalyticsService, UsageLogEntry } from './api-key-analytics.service';
import * as crypto from 'crypto';
import * as CryptoJS from 'crypto-js';
+export interface RotationResult {
+ id: string;
+ name: string;
+ oldKeyPrefix: string;
+ newKeyPrefix: string;
+ key: string; // New plain key (shown only once)
+ rotatedAt: Date;
+}
+
+export interface RotationStatus {
+ id: string;
+ name: string;
+ keyPrefix: string;
+ lastRotatedAt?: Date;
+ rotationDueAt?: Date;
+ daysUntilRotation?: number;
+ requiresRotation: boolean;
+}
+
@Injectable()
export class ApiKeyService {
private readonly encryptionKey: string;
private readonly globalRateLimit: number;
+ private readonly rotationIntervalDays: number;
+ private readonly rotationWarningDays: number;
+ private readonly logger = new Logger(ApiKeyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly configService: ConfigService,
private readonly paginationService: PaginationService,
+ private readonly analyticsService: ApiKeyAnalyticsService,
) {
this.encryptionKey = this.configService.get('ENCRYPTION_KEY');
this.globalRateLimit = this.configService.get('API_KEY_RATE_LIMIT_PER_MINUTE', 60);
+ this.rotationIntervalDays = this.configService.get('API_KEY_ROTATION_DAYS', 90);
+ this.rotationWarningDays = this.configService.get('API_KEY_ROTATION_WARNING_DAYS', 7);
if (!this.encryptionKey) {
throw new Error('ENCRYPTION_KEY must be set in environment variables');
@@ -36,6 +62,10 @@ export class ApiKeyService {
const keyPrefix = this.extractKeyPrefix(plainKey);
const encryptedKey = this.encryptKey(plainKey);
+ // Set rotation due date
+ const rotationDueAt = new Date();
+ rotationDueAt.setDate(rotationDueAt.getDate() + this.rotationIntervalDays);
+
const apiKey = await this.prisma.apiKey.create({
data: {
name: createApiKeyDto.name,
@@ -43,6 +73,8 @@ export class ApiKeyService {
keyPrefix,
scopes: createApiKeyDto.scopes,
rateLimit: createApiKeyDto.rateLimit,
+ rotationDueAt,
+ lastRotatedAt: new Date(),
},
});
@@ -253,8 +285,212 @@ export class ApiKeyService {
lastUsedAt: apiKey.lastUsedAt,
isActive: apiKey.isActive,
rateLimit: apiKey.rateLimit,
+ lastRotatedAt: apiKey.lastRotatedAt,
+ rotationDueAt: apiKey.rotationDueAt,
createdAt: apiKey.createdAt,
updatedAt: apiKey.updatedAt,
};
}
+
+ // ==================== KEY ROTATION METHODS ====================
+
+ /**
+ * Rotate an API key - generates a new key and deactivates the old one
+ */
+ async rotateKey(id: string): Promise {
+ const apiKey = await this.prisma.apiKey.findUnique({
+ where: { id },
+ });
+
+ if (!apiKey) {
+ throw new NotFoundException(`API key with ID ${id} not found`);
+ }
+
+ if (!apiKey.isActive) {
+ throw new BadRequestException('Cannot rotate a revoked API key');
+ }
+
+ // Generate new key
+ const newPlainKey = this.generateApiKey();
+ const newKeyPrefix = this.extractKeyPrefix(newPlainKey);
+ const newEncryptedKey = this.encryptKey(newPlainKey);
+
+ // Calculate new rotation due date
+ const newRotationDueAt = new Date();
+ newRotationDueAt.setDate(newRotationDueAt.getDate() + this.rotationIntervalDays);
+
+ // Update the API key with new values
+ const updatedKey = await this.prisma.apiKey.update({
+ where: { id },
+ data: {
+ key: newEncryptedKey,
+ keyPrefix: newKeyPrefix,
+ keyVersion: { increment: 1 },
+ lastRotatedAt: new Date(),
+ rotationDueAt: newRotationDueAt,
+ },
+ });
+
+ // Clear the old rate limit cache
+ await this.redis.del(`rate_limit:${apiKey.keyPrefix}`);
+
+ this.logger.log(`Rotated API key ${id}: ${apiKey.keyPrefix} -> ${newKeyPrefix}`);
+
+ return {
+ id: updatedKey.id,
+ name: updatedKey.name,
+ oldKeyPrefix: apiKey.keyPrefix,
+ newKeyPrefix,
+ key: newPlainKey,
+ rotatedAt: updatedKey.lastRotatedAt!,
+ };
+ }
+
+ /**
+ * Get rotation status for an API key
+ */
+ async getRotationStatus(id: string): Promise {
+ const apiKey = await this.prisma.apiKey.findUnique({
+ where: { id },
+ });
+
+ if (!apiKey) {
+ throw new NotFoundException(`API key with ID ${id} not found`);
+ }
+
+ const now = new Date();
+ const rotationDueAt = apiKey.rotationDueAt;
+ const daysUntilRotation = rotationDueAt
+ ? Math.ceil((rotationDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
+ : undefined;
+
+ return {
+ id: apiKey.id,
+ name: apiKey.name,
+ keyPrefix: apiKey.keyPrefix,
+ lastRotatedAt: apiKey.lastRotatedAt || undefined,
+ rotationDueAt: rotationDueAt || undefined,
+ daysUntilRotation,
+ requiresRotation: daysUntilRotation !== undefined && daysUntilRotation <= 0,
+ };
+ }
+
+ /**
+ * Get all API keys that require rotation
+ */
+ async getKeysRequiringRotation(): Promise {
+ const now = new Date();
+
+ const keys = await this.prisma.apiKey.findMany({
+ where: {
+ isActive: true,
+ rotationDueAt: { lte: now },
+ },
+ });
+
+ return keys.map(apiKey => ({
+ id: apiKey.id,
+ name: apiKey.name,
+ keyPrefix: apiKey.keyPrefix,
+ lastRotatedAt: apiKey.lastRotatedAt || undefined,
+ rotationDueAt: apiKey.rotationDueAt || undefined,
+ daysUntilRotation: 0,
+ requiresRotation: true,
+ }));
+ }
+
+ /**
+ * Get API keys approaching rotation (within warning period)
+ */
+ async getKeysApproachingRotation(): Promise {
+ const now = new Date();
+ const warningDate = new Date();
+ warningDate.setDate(warningDate.getDate() + this.rotationWarningDays);
+
+ const keys = await this.prisma.apiKey.findMany({
+ where: {
+ isActive: true,
+ rotationDueAt: {
+ gt: now,
+ lte: warningDate,
+ },
+ },
+ });
+
+ return keys.map(apiKey => {
+ const daysUntilRotation = apiKey.rotationDueAt
+ ? Math.ceil((apiKey.rotationDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
+ : 0;
+
+ return {
+ id: apiKey.id,
+ name: apiKey.name,
+ keyPrefix: apiKey.keyPrefix,
+ lastRotatedAt: apiKey.lastRotatedAt || undefined,
+ rotationDueAt: apiKey.rotationDueAt || undefined,
+ daysUntilRotation,
+ requiresRotation: false,
+ };
+ });
+ }
+
+ /**
+ * Automatic rotation for all expired keys (called by scheduler)
+ */
+ async autoRotateExpiredKeys(): Promise {
+ const expiredKeys = await this.getKeysRequiringRotation();
+ const results: RotationResult[] = [];
+
+ for (const key of expiredKeys) {
+ try {
+ const result = await this.rotateKey(key.id);
+ results.push(result);
+ this.logger.log(`Auto-rotated API key: ${key.name} (${key.id})`);
+ } catch (error) {
+ this.logger.error(`Failed to auto-rotate API key ${key.id}: ${error.message}`);
+ }
+ }
+
+ return results;
+ }
+
+ // ==================== USAGE ANALYTICS INTEGRATION ====================
+
+ /**
+ * Log detailed usage for analytics
+ */
+ async logDetailedUsage(
+ apiKeyId: string,
+ endpoint: string,
+ method: string,
+ statusCode: number,
+ responseTime: number,
+ ipAddress?: string,
+ userAgent?: string,
+ ): Promise {
+ await this.analyticsService.logUsage({
+ apiKeyId,
+ endpoint,
+ method,
+ statusCode,
+ responseTime,
+ ipAddress,
+ userAgent,
+ });
+ }
+
+ /**
+ * Get usage analytics for an API key
+ */
+ async getUsageAnalytics(id: string, startDate: Date, endDate: Date) {
+ const apiKey = await this.prisma.apiKey.findUnique({
+ where: { id },
+ });
+
+ if (!apiKey) {
+ throw new NotFoundException(`API key with ID ${id} not found`);
+ }
+
+ return this.analyticsService.getUsageReport(id, startDate, endDate);
+ }
}
diff --git a/src/api-keys/api-keys.module.ts b/src/api-keys/api-keys.module.ts
index 76dc089a..e35e1994 100644
--- a/src/api-keys/api-keys.module.ts
+++ b/src/api-keys/api-keys.module.ts
@@ -1,13 +1,16 @@
import { Module } from '@nestjs/common';
+import { ScheduleModule } from '@nestjs/schedule';
import { ApiKeyService } from './api-key.service';
import { ApiKeyController } from './api-key.controller';
+import { ApiKeyAnalyticsService } from './api-key-analytics.service';
+import { ApiKeyRotationScheduler } from './api-key-rotation.scheduler';
import { PrismaModule } from '../database/prisma/prisma.module';
import { PaginationService } from '../common/pagination';
@Module({
- imports: [PrismaModule],
+ imports: [PrismaModule, ScheduleModule.forRoot()],
controllers: [ApiKeyController],
- providers: [ApiKeyService, PaginationService],
- exports: [ApiKeyService],
+ providers: [ApiKeyService, ApiKeyAnalyticsService, ApiKeyRotationScheduler, PaginationService],
+ exports: [ApiKeyService, ApiKeyAnalyticsService],
})
export class ApiKeysModule {}
diff --git a/src/api-keys/dto/api-key-response.dto.ts b/src/api-keys/dto/api-key-response.dto.ts
index c669d677..485c1d3f 100644
--- a/src/api-keys/dto/api-key-response.dto.ts
+++ b/src/api-keys/dto/api-key-response.dto.ts
@@ -25,6 +25,12 @@ export class ApiKeyResponseDto {
@ApiProperty({ example: 100, nullable: true })
rateLimit: number | null;
+ @ApiProperty({ example: '2026-01-15T08:00:00.000Z', nullable: true })
+ lastRotatedAt?: Date;
+
+ @ApiProperty({ example: '2026-04-15T08:00:00.000Z', nullable: true })
+ rotationDueAt?: Date;
+
@ApiProperty({ example: '2026-01-15T08:00:00.000Z' })
createdAt: Date;
diff --git a/src/common/services/password-rotation.service.ts b/src/common/services/password-rotation.service.ts
new file mode 100644
index 00000000..86e424b4
--- /dev/null
+++ b/src/common/services/password-rotation.service.ts
@@ -0,0 +1,239 @@
+import { Injectable, BadRequestException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from '../../database/prisma/prisma.service';
+import * as bcrypt from 'bcrypt';
+
+export interface PasswordRotationCheck {
+ canRotate: boolean;
+ reason?: string;
+ daysUntilExpiry?: number;
+ lastRotation?: Date;
+}
+
+export interface PasswordHistoryEntry {
+ id: string;
+ userId: string;
+ createdAt: Date;
+}
+
+@Injectable()
+export class PasswordRotationService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ /**
+ * Check if password rotation is required for a user
+ */
+ async checkRotationStatus(userId: string): Promise {
+ const passwordExpiryDays = this.configService.get('PASSWORD_EXPIRY_DAYS', 90);
+ const user = await this.prisma.user.findUnique({
+ where: { id: userId },
+ include: {
+ passwordHistory: {
+ orderBy: { createdAt: 'desc' },
+ take: 1,
+ },
+ },
+ });
+
+ if (!user || !user.password) {
+ return { canRotate: false, reason: 'User not found or no password set' };
+ }
+
+ // Check if password has expired
+ const lastPasswordChange = user.passwordHistory[0]?.createdAt || user.createdAt;
+ const daysSinceChange = Math.floor((Date.now() - lastPasswordChange.getTime()) / (1000 * 60 * 60 * 24));
+ const daysUntilExpiry = Math.max(0, passwordExpiryDays - daysSinceChange);
+
+ if (daysSinceChange >= passwordExpiryDays) {
+ return {
+ canRotate: true,
+ reason: 'Password has expired and must be changed',
+ daysUntilExpiry: 0,
+ lastRotation: lastPasswordChange,
+ };
+ }
+
+ return {
+ canRotate: true,
+ daysUntilExpiry,
+ lastRotation: lastPasswordChange,
+ };
+ }
+
+ /**
+ * Validate that a new password is not in the user's password history
+ */
+ async validatePasswordNotInHistory(
+ userId: string,
+ newPassword: string,
+ ): Promise<{ valid: boolean; reason?: string }> {
+ const passwordHistoryCount = this.configService.get('PASSWORD_HISTORY_COUNT', 5);
+
+ // Get recent password history
+ const passwordHistory = await this.prisma.passwordHistory.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ take: passwordHistoryCount,
+ });
+
+ // Check against each historical password
+ for (const entry of passwordHistory) {
+ const isMatch = await bcrypt.compare(newPassword, entry.passwordHash);
+ if (isMatch) {
+ return {
+ valid: false,
+ reason: `Cannot reuse any of your last ${passwordHistoryCount} passwords`,
+ };
+ }
+ }
+
+ return { valid: true };
+ }
+
+ /**
+ * Add a password to the user's history
+ */
+ async addPasswordToHistory(userId: string, passwordHash: string): Promise {
+ const passwordHistoryCount = this.configService.get('PASSWORD_HISTORY_COUNT', 5);
+
+ await this.prisma.$transaction(async tx => {
+ // Add new password to history
+ await tx.passwordHistory.create({
+ data: {
+ userId,
+ passwordHash,
+ },
+ });
+
+ // Remove old entries beyond the history limit
+ const historyEntries = await tx.passwordHistory.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ skip: passwordHistoryCount,
+ });
+
+ if (historyEntries.length > 0) {
+ await tx.passwordHistory.deleteMany({
+ where: {
+ id: {
+ in: historyEntries.map(entry => entry.id),
+ },
+ },
+ });
+ }
+ });
+ }
+
+ /**
+ * Validate password rotation requirements before allowing password change
+ */
+ async validatePasswordRotation(userId: string, newPassword: string): Promise<{ valid: boolean; reason?: string }> {
+ // Check rotation status
+ const rotationStatus = await this.checkRotationStatus(userId);
+ if (!rotationStatus.canRotate && rotationStatus.reason) {
+ return { valid: false, reason: rotationStatus.reason };
+ }
+
+ // Check password history
+ const historyCheck = await this.validatePasswordNotInHistory(userId, newPassword);
+ if (!historyCheck.valid) {
+ return { valid: false, reason: historyCheck.reason };
+ }
+
+ return { valid: true };
+ }
+
+ /**
+ * Get password history for a user
+ */
+ async getPasswordHistory(userId: string, limit = 10): Promise {
+ const history = await this.prisma.passwordHistory.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ take: limit,
+ select: {
+ id: true,
+ userId: true,
+ createdAt: true,
+ },
+ });
+
+ return history;
+ }
+
+ /**
+ * Clear password history for a user (admin function)
+ */
+ async clearPasswordHistory(userId: string): Promise {
+ await this.prisma.passwordHistory.deleteMany({
+ where: { userId },
+ });
+ }
+
+ /**
+ * Get users with expired passwords
+ */
+ async getUsersWithExpiredPasswords(): Promise<{ userId: string; email: string; daysExpired: number }[]> {
+ const passwordExpiryDays = this.configService.get('PASSWORD_EXPIRY_DAYS', 90);
+
+ const users = await this.prisma.user.findMany({
+ where: {
+ password: { not: null },
+ },
+ include: {
+ passwordHistory: {
+ orderBy: { createdAt: 'desc' },
+ take: 1,
+ },
+ },
+ });
+
+ const expiredUsers: { userId: string; email: string; daysExpired: number }[] = [];
+
+ for (const user of users) {
+ const lastPasswordChange = user.passwordHistory[0]?.createdAt || user.createdAt;
+ const daysSinceChange = Math.floor((Date.now() - lastPasswordChange.getTime()) / (1000 * 60 * 60 * 24));
+
+ if (daysSinceChange > passwordExpiryDays) {
+ expiredUsers.push({
+ userId: user.id,
+ email: user.email,
+ daysExpired: daysSinceChange - passwordExpiryDays,
+ });
+ }
+ }
+
+ return expiredUsers;
+ }
+
+ /**
+ * Check if user needs to rotate password (for middleware/guard usage)
+ */
+ async requiresPasswordRotation(userId: string): Promise {
+ const passwordExpiryDays = this.configService.get('PASSWORD_EXPIRY_DAYS', 90);
+ const warningDays = this.configService.get('PASSWORD_EXPIRY_WARNING_DAYS', 7);
+
+ const user = await this.prisma.user.findUnique({
+ where: { id: userId },
+ include: {
+ passwordHistory: {
+ orderBy: { createdAt: 'desc' },
+ take: 1,
+ },
+ },
+ });
+
+ if (!user || !user.password) {
+ return false;
+ }
+
+ const lastPasswordChange = user.passwordHistory[0]?.createdAt || user.createdAt;
+ const daysSinceChange = Math.floor((Date.now() - lastPasswordChange.getTime()) / (1000 * 60 * 60 * 24));
+
+ // Require rotation if expired or within warning period
+ return daysSinceChange >= passwordExpiryDays - warningDays;
+ }
+}
diff --git a/src/common/validators/password.validator.ts b/src/common/validators/password.validator.ts
index 54f500fb..a5cdb253 100644
--- a/src/common/validators/password.validator.ts
+++ b/src/common/validators/password.validator.ts
@@ -1,24 +1,44 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
+export interface PasswordValidationResult {
+ valid: boolean;
+ errors: string[];
+ strength: 'weak' | 'medium' | 'strong';
+ score: number;
+}
+
@Injectable()
export class PasswordValidator {
constructor(private readonly configService: ConfigService) {}
- validatePassword(password: string): { valid: boolean; errors: string[] } {
+ validatePassword(password: string): PasswordValidationResult {
const errors: string[] = [];
+ let score = 0;
// Length validation
const minLength = this.configService.get('PASSWORD_MIN_LENGTH', 12);
if (password.length < minLength) {
errors.push(`Password must be at least ${minLength} characters long`);
+ } else {
+ score += password.length >= 16 ? 2 : 1;
}
- // Special characters validation
- if (this.configService.get('PASSWORD_REQUIRE_SPECIAL_CHARS', true)) {
- const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
- if (!specialCharRegex.test(password)) {
- errors.push('Password must contain at least one special character');
+ // Lowercase validation
+ const lowercaseRegex = /[a-z]/;
+ if (!lowercaseRegex.test(password)) {
+ errors.push('Password must contain at least one lowercase letter');
+ } else {
+ score += 1;
+ }
+
+ // Uppercase validation
+ if (this.configService.get('PASSWORD_REQUIRE_UPPERCASE', true)) {
+ const uppercaseRegex = /[A-Z]/;
+ if (!uppercaseRegex.test(password)) {
+ errors.push('Password must contain at least one uppercase letter');
+ } else {
+ score += 1;
}
}
@@ -27,19 +47,73 @@ export class PasswordValidator {
const numberRegex = /\d/;
if (!numberRegex.test(password)) {
errors.push('Password must contain at least one number');
+ } else {
+ score += 1;
}
}
- // Uppercase validation
- if (this.configService.get('PASSWORD_REQUIRE_UPPERCASE', true)) {
- const uppercaseRegex = /[A-Z]/;
- if (!uppercaseRegex.test(password)) {
- errors.push('Password must contain at least one uppercase letter');
+ // Special characters validation
+ if (this.configService.get('PASSWORD_REQUIRE_SPECIAL_CHARS', true)) {
+ const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
+ if (!specialCharRegex.test(password)) {
+ errors.push('Password must contain at least one special character');
+ } else {
+ score += 1;
}
}
+ // Entropy check - bonus points for complexity
+ const uniqueChars = new Set(password).size;
+ if (uniqueChars >= password.length * 0.7) {
+ score += 1;
+ }
+
+ // Sequential characters check
+ if (this.hasSequentialChars(password)) {
+ errors.push('Password must not contain sequential characters (e.g., abc, 123)');
+ } else {
+ score += 1;
+ }
+
+ // Repeated characters check
+ if (this.hasRepeatedChars(password)) {
+ errors.push('Password must not contain repeated characters (e.g., aaa, 111)');
+ } else {
+ score += 1;
+ }
+
// Common password patterns to avoid
- const commonPatterns = [/password/i, /123456/, /qwerty/, /abc123/, /admin/, /welcome/];
+ const commonPatterns = [
+ /password/i,
+ /123456/,
+ /qwerty/i,
+ /abc123/i,
+ /admin/i,
+ /welcome/i,
+ /letmein/i,
+ /monkey/i,
+ /dragon/i,
+ /master/i,
+ /sunshine/i,
+ /princess/i,
+ /football/i,
+ /baseball/i,
+ /iloveyou/i,
+ /trustno1/i,
+ /shadow/i,
+ /ashley/i,
+ /michael/i,
+ /jesus/i,
+ /mustang/i,
+ /access/i,
+ /love/i,
+ /pussy/i,
+ /696969/i,
+ /qwertyuiop/i,
+ /qazwsx/i,
+ /zaq12wsx/i,
+ /!@#\$%^&\*/,
+ ];
for (const pattern of commonPatterns) {
if (pattern.test(password)) {
@@ -48,19 +122,107 @@ export class PasswordValidator {
}
}
+ // Check for keyboard patterns
+ if (this.hasKeyboardPattern(password)) {
+ errors.push('Password must not contain keyboard patterns (e.g., asdf, qwer)');
+ } else {
+ score += 1;
+ }
+
+ // Determine strength
+ let strength: 'weak' | 'medium' | 'strong' = 'weak';
+ if (score >= 7) {
+ strength = 'strong';
+ } else if (score >= 5) {
+ strength = 'medium';
+ }
+
return {
valid: errors.length === 0,
errors,
+ strength,
+ score,
};
}
isPasswordStrong(password: string): boolean {
- const { valid } = this.validatePassword(password);
- return valid;
+ const { valid, strength } = this.validatePassword(password);
+ return valid && strength === 'strong';
}
getValidationMessage(password: string): string {
const { errors } = this.validatePassword(password);
return errors.join(', ') || 'Password is valid';
}
+
+ calculateEntropy(password: string): number {
+ let poolSize = 0;
+ if (/[a-z]/.test(password)) {
+ poolSize += 26;
+ }
+ if (/[A-Z]/.test(password)) {
+ poolSize += 26;
+ }
+ if (/\d/.test(password)) {
+ poolSize += 10;
+ }
+ if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
+ poolSize += 32;
+ }
+
+ return Math.log2(Math.pow(poolSize, password.length));
+ }
+
+ private hasSequentialChars(password: string): boolean {
+ const lowerPassword = password.toLowerCase();
+ const sequences = ['abcdefghijklmnopqrstuvwxyz', '0123456789'];
+
+ for (const seq of sequences) {
+ for (let i = 0; i < seq.length - 2; i++) {
+ const pattern = seq.substring(i, i + 3);
+ if (lowerPassword.includes(pattern)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private hasRepeatedChars(password: string): boolean {
+ const repeatedPattern = /(.)\1{2,}/;
+ return repeatedPattern.test(password);
+ }
+
+ private hasKeyboardPattern(password: string): boolean {
+ const keyboardPatterns = [
+ 'qwerty',
+ 'asdf',
+ 'zxcv',
+ 'qwer',
+ 'wasd',
+ 'qazwsx',
+ 'zaq12wsx',
+ '1qaz2wsx',
+ 'qaz',
+ 'wsx',
+ 'edc',
+ 'rfv',
+ 'tgb',
+ 'yhn',
+ 'ujm',
+ 'ikl',
+ 'ppp',
+ 'ooo',
+ 'lll',
+ 'kkk',
+ ];
+
+ const lowerPassword = password.toLowerCase();
+ for (const pattern of keyboardPatterns) {
+ if (lowerPassword.includes(pattern)) {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/src/config/configuration.service.ts b/src/config/configuration.service.ts
index 347c8e4d..43333359 100644
--- a/src/config/configuration.service.ts
+++ b/src/config/configuration.service.ts
@@ -192,7 +192,9 @@ export class ConfigurationService {
// Security
get bcryptRounds(): number {
- return this.configService.get('BCRYPT_ROUNDS');
+ const rounds = this.configService.get('BCRYPT_ROUNDS', 12);
+ // Enforce minimum of 12 rounds for security
+ return Math.max(rounds, 12);
}
get sessionSecret(): string {
diff --git a/src/users/user.service.ts b/src/users/user.service.ts
index 0e8e482f..386a03c4 100644
--- a/src/users/user.service.ts
+++ b/src/users/user.service.ts
@@ -9,6 +9,8 @@ import { PrismaService } from '../database/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
import { PasswordValidator } from '../common/validators/password.validator';
+import { PasswordRotationService } from '../common/services/password-rotation.service';
+import { ConfigService } from '@nestjs/config';
/**
* UserService
@@ -31,6 +33,8 @@ export class UserService {
constructor(
private prisma: PrismaService,
private readonly passwordValidator: PasswordValidator,
+ private readonly passwordRotationService: PasswordRotationService,
+ private readonly configService: ConfigService,
) {}
/**
@@ -88,10 +92,11 @@ export class UserService {
// === PASSWORD HASHING ===
// Uses bcrypt for secure password hashing
- // Salt rounds configurable via BCRYPT_ROUNDS (default: 12)
+ // Salt rounds configurable via BCRYPT_ROUNDS (default: 12, minimum: 12)
// Higher = more secure but slower
- const bcryptRounds = this.passwordValidator['configService'].get('BCRYPT_ROUNDS', 12);
- const hashedPassword = await bcrypt.hash(password, bcryptRounds);
+ const bcryptRounds = this.configService.get('BCRYPT_ROUNDS', 12);
+ const effectiveRounds = Math.max(bcryptRounds, 12); // Enforce minimum 12 rounds
+ const hashedPassword = await bcrypt.hash(password, effectiveRounds);
// Create user with hashed password
const user = await this.prisma.user.create({
@@ -103,6 +108,10 @@ export class UserService {
},
});
+ // === PASSWORD HISTORY TRACKING ===
+ // Add initial password to history for rotation policy enforcement
+ await this.passwordRotationService.addPasswordToHistory(user.id, hashedPassword);
+
return user;
}
@@ -192,14 +201,30 @@ export class UserService {
throw new BadRequestException(`Password validation failed: ${passwordValidation.errors.join(', ')}`);
}
+ // === PASSWORD ROTATION POLICY CHECK ===
+ // Validate password rotation requirements (history check)
+ const rotationCheck = await this.passwordRotationService.validatePasswordRotation(userId, newPassword);
+ if (!rotationCheck.valid) {
+ throw new BadRequestException(`Password rotation failed: ${rotationCheck.reason}`);
+ }
+
// === BCRYPT HASHING ===
- // Hash new password before storing
- const bcryptRounds = this.passwordValidator['configService'].get('BCRYPT_ROUNDS', 12);
- const hashedPassword = await bcrypt.hash(newPassword, bcryptRounds);
- return this.prisma.user.update({
+ // Hash new password before storing with minimum 12 rounds
+ const bcryptRounds = this.configService.get('BCRYPT_ROUNDS', 12);
+ const effectiveRounds = Math.max(bcryptRounds, 12); // Enforce minimum 12 rounds
+ const hashedPassword = await bcrypt.hash(newPassword, effectiveRounds);
+
+ // Update user password
+ const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
+
+ // === PASSWORD HISTORY TRACKING ===
+ // Add new password to history for rotation policy enforcement
+ await this.passwordRotationService.addPasswordToHistory(userId, hashedPassword);
+
+ return updatedUser;
}
/**
diff --git a/src/users/users.module.ts b/src/users/users.module.ts
index 8f18f828..7f25e15c 100644
--- a/src/users/users.module.ts
+++ b/src/users/users.module.ts
@@ -4,6 +4,7 @@ import { UserController } from './user.controller';
import { PrismaService } from '../database/prisma/prisma.service';
import { AuthModule } from '../auth/auth.module';
import { PasswordValidator } from '../common/validators/password.validator';
+import { PasswordRotationService } from '../common/services/password-rotation.service';
@Module({
imports: [
@@ -11,7 +12,7 @@ import { PasswordValidator } from '../common/validators/password.validator';
forwardRef(() => AuthModule),
],
controllers: [UserController],
- providers: [UserService, PrismaService, PasswordValidator],
- exports: [UserService], // This allows AuthService to use UserService
+ providers: [UserService, PrismaService, PasswordValidator, PasswordRotationService],
+ exports: [UserService, PasswordRotationService], // Export for use in other modules
})
export class UsersModule {}
diff --git a/test/api-keys/api-key.service.spec.ts b/test/api-keys/api-key.service.spec.ts
index edfb407a..5e4597b3 100644
--- a/test/api-keys/api-key.service.spec.ts
+++ b/test/api-keys/api-key.service.spec.ts
@@ -2,10 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException, BadRequestException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiKeyService } from '../../src/api-keys/api-key.service';
+import { ApiKeyAnalyticsService } from '../../src/api-keys/api-key-analytics.service';
import { PrismaService } from '../../src/database/prisma/prisma.service';
import { RedisService } from '../../src/common/services/redis.service';
import { CreateApiKeyDto } from '../../src/api-keys/dto/create-api-key.dto';
import { UpdateApiKeyDto } from '../../src/api-keys/dto/update-api-key.dto';
+import { ApiKeyResponseDto } from '../../src/api-keys/dto/api-key-response.dto';
import { ApiKeyScope } from '../../src/api-keys/enums/api-key-scope.enum';
import { PaginationService } from '../../src/common/pagination/pagination.service';
@@ -14,6 +16,7 @@ describe('ApiKeyService', () => {
let prismaService: PrismaService;
let redisService: RedisService;
let configService: ConfigService;
+ let analyticsService: ApiKeyAnalyticsService;
const mockPrismaService = {
apiKey: {
@@ -35,14 +38,23 @@ describe('ApiKeyService', () => {
const mockConfigService = {
get: jest.fn((key: string) => {
- const config = {
+ const config: Record = {
ENCRYPTION_KEY: 'test-encryption-key-32-characters',
API_KEY_RATE_LIMIT_PER_MINUTE: 60,
+ API_KEY_ROTATION_DAYS: 90,
+ API_KEY_ROTATION_WARNING_DAYS: 7,
};
return config[key];
}),
};
+ const mockAnalyticsService = {
+ logUsage: jest.fn(),
+ getUsageReport: jest.fn(),
+ getAnalyticsSummary: jest.fn(),
+ cleanupOldLogs: jest.fn(),
+ };
+
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -51,6 +63,7 @@ describe('ApiKeyService', () => {
{ provide: RedisService, useValue: mockRedisService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: PaginationService, useValue: {} },
+ { provide: ApiKeyAnalyticsService, useValue: mockAnalyticsService },
],
}).compile();
@@ -58,6 +71,7 @@ describe('ApiKeyService', () => {
prismaService = module.get(PrismaService);
redisService = module.get(RedisService);
configService = module.get(ConfigService);
+ analyticsService = module.get(ApiKeyAnalyticsService);
jest.clearAllMocks();
});
@@ -130,7 +144,7 @@ describe('ApiKeyService', () => {
mockPrismaService.apiKey.findMany.mockResolvedValue(mockApiKeys);
- const result = await service.findAll();
+ const result = (await service.findAll()) as ApiKeyResponseDto[];
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Test API Key 1');
@@ -281,4 +295,211 @@ describe('ApiKeyService', () => {
await expect(service.validateApiKey('propchain_live_abc123xyz')).rejects.toThrow(UnauthorizedException);
});
});
+
+ // ==================== ROTATION TESTS ====================
+
+ describe('rotateKey', () => {
+ it('should rotate an API key successfully', async () => {
+ const mockApiKey = {
+ id: 'test-id',
+ name: 'Test API Key',
+ key: 'encrypted-old-key',
+ keyPrefix: 'propchain_live_oldprefix',
+ keyVersion: 1,
+ scopes: [ApiKeyScope.READ_PROPERTIES],
+ requestCount: BigInt(5),
+ lastUsedAt: new Date(),
+ isActive: true,
+ rateLimit: 60,
+ lastRotatedAt: new Date('2026-01-01'),
+ rotationDueAt: new Date('2026-03-24'),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ const updatedApiKey = {
+ ...mockApiKey,
+ keyVersion: 2,
+ lastRotatedAt: new Date(),
+ };
+
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey);
+ mockPrismaService.apiKey.update.mockResolvedValue(updatedApiKey);
+ mockRedisService.del.mockResolvedValue(1);
+
+ const result = await service.rotateKey('test-id');
+
+ expect(result.id).toBe('test-id');
+ expect(result.name).toBe('Test API Key');
+ expect(result.oldKeyPrefix).toBe('propchain_live_oldprefix');
+ expect(result.key).toMatch(/^propchain_live_/);
+ expect(mockRedisService.del).toHaveBeenCalledWith('rate_limit:propchain_live_oldprefix');
+ });
+
+ it('should throw NotFoundException if API key not found', async () => {
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(null);
+
+ await expect(service.rotateKey('non-existent-id')).rejects.toThrow(NotFoundException);
+ });
+
+ it('should throw BadRequestException if API key is revoked', async () => {
+ const mockApiKey = {
+ id: 'test-id',
+ name: 'Test API Key',
+ key: 'encrypted-key',
+ keyPrefix: 'propchain_live_abc123',
+ isActive: false,
+ };
+
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey);
+
+ await expect(service.rotateKey('test-id')).rejects.toThrow(BadRequestException);
+ });
+ });
+
+ describe('getRotationStatus', () => {
+ it('should return rotation status for an API key', async () => {
+ const futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 30);
+
+ const mockApiKey = {
+ id: 'test-id',
+ name: 'Test API Key',
+ keyPrefix: 'propchain_live_abc123',
+ lastRotatedAt: new Date('2026-01-01'),
+ rotationDueAt: futureDate,
+ isActive: true,
+ };
+
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey);
+
+ const result = await service.getRotationStatus('test-id');
+
+ expect(result.id).toBe('test-id');
+ expect(result.requiresRotation).toBe(false);
+ expect(result.daysUntilRotation).toBeGreaterThan(0);
+ });
+
+ it('should return requiresRotation true for expired key', async () => {
+ const pastDate = new Date();
+ pastDate.setDate(pastDate.getDate() - 10);
+
+ const mockApiKey = {
+ id: 'test-id',
+ name: 'Test API Key',
+ keyPrefix: 'propchain_live_abc123',
+ lastRotatedAt: new Date('2025-12-01'),
+ rotationDueAt: pastDate,
+ isActive: true,
+ };
+
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey);
+
+ const result = await service.getRotationStatus('test-id');
+
+ expect(result.requiresRotation).toBe(true);
+ expect(result.daysUntilRotation).toBeLessThanOrEqual(0);
+ });
+
+ it('should throw NotFoundException if API key not found', async () => {
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(null);
+
+ await expect(service.getRotationStatus('non-existent-id')).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('getKeysRequiringRotation', () => {
+ it('should return keys that have passed rotation due date', async () => {
+ const pastDate = new Date();
+ pastDate.setDate(pastDate.getDate() - 10);
+
+ const mockApiKeys = [
+ {
+ id: 'test-id-1',
+ name: 'Expired Key 1',
+ keyPrefix: 'propchain_live_expired1',
+ lastRotatedAt: new Date('2025-12-01'),
+ rotationDueAt: pastDate,
+ isActive: true,
+ },
+ ];
+
+ mockPrismaService.apiKey.findMany.mockResolvedValue(mockApiKeys);
+
+ const result = await service.getKeysRequiringRotation();
+
+ expect(result).toHaveLength(1);
+ expect(result[0].requiresRotation).toBe(true);
+ });
+ });
+
+ describe('getKeysApproachingRotation', () => {
+ it('should return keys within warning period', async () => {
+ const nearFutureDate = new Date();
+ nearFutureDate.setDate(nearFutureDate.getDate() + 5);
+
+ const mockApiKeys = [
+ {
+ id: 'test-id-1',
+ name: 'Approaching Key',
+ keyPrefix: 'propchain_live_approaching',
+ lastRotatedAt: new Date('2026-01-01'),
+ rotationDueAt: nearFutureDate,
+ isActive: true,
+ },
+ ];
+
+ mockPrismaService.apiKey.findMany.mockResolvedValue(mockApiKeys);
+
+ const result = await service.getKeysApproachingRotation();
+
+ expect(result).toHaveLength(1);
+ expect(result[0].requiresRotation).toBe(false);
+ });
+ });
+
+ describe('getUsageAnalytics', () => {
+ it('should return usage analytics for an API key', async () => {
+ const mockApiKey = {
+ id: 'test-id',
+ name: 'Test API Key',
+ keyPrefix: 'propchain_live_abc123',
+ };
+
+ const mockReport = {
+ apiKeyId: 'test-id',
+ apiKeyName: 'Test API Key',
+ period: { start: new Date('2026-01-01'), end: new Date('2026-01-31') },
+ summary: {
+ totalRequests: 100,
+ uniqueEndpoints: 5,
+ averageResponseTime: 150,
+ errorRate: 2,
+ topEndpoints: [],
+ requestsByDay: [],
+ requestsByHour: [],
+ },
+ };
+
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(mockApiKey);
+ mockAnalyticsService.getUsageReport.mockResolvedValue(mockReport);
+
+ const result = await service.getUsageAnalytics(
+ 'test-id',
+ new Date('2026-01-01'),
+ new Date('2026-01-31'),
+ );
+
+ expect(result.apiKeyId).toBe('test-id');
+ expect(result.summary.totalRequests).toBe(100);
+ });
+
+ it('should throw NotFoundException if API key not found', async () => {
+ mockPrismaService.apiKey.findUnique.mockResolvedValue(null);
+
+ await expect(
+ service.getUsageAnalytics('non-existent-id', new Date(), new Date()),
+ ).rejects.toThrow(NotFoundException);
+ });
+ });
});
diff --git a/test/common/password-rotation.service.spec.ts b/test/common/password-rotation.service.spec.ts
new file mode 100644
index 00000000..3d40d0fc
--- /dev/null
+++ b/test/common/password-rotation.service.spec.ts
@@ -0,0 +1,532 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ConfigService } from '@nestjs/config';
+import { PasswordRotationService } from '../../src/common/services/password-rotation.service';
+import { PrismaService } from '../../src/database/prisma/prisma.service';
+import * as bcrypt from 'bcrypt';
+
+// Mock bcrypt
+jest.mock('bcrypt', () => ({
+ compare: jest.fn(),
+}));
+
+describe('PasswordRotationService', () => {
+ let service: PasswordRotationService;
+ let prismaService: PrismaService;
+ let configService: ConfigService;
+
+ const mockPrismaService = {
+ user: {
+ findUnique: jest.fn(),
+ findMany: jest.fn(),
+ },
+ passwordHistory: {
+ findMany: jest.fn(),
+ create: jest.fn(),
+ deleteMany: jest.fn(),
+ },
+ $transaction: jest.fn(),
+ };
+
+ const mockConfigService = {
+ get: jest.fn().mockImplementation((key: string, defaultValue?: number) => {
+ const config: Record = {
+ PASSWORD_EXPIRY_DAYS: 90,
+ PASSWORD_HISTORY_COUNT: 5,
+ PASSWORD_EXPIRY_WARNING_DAYS: 7,
+ };
+ return config[key] ?? defaultValue;
+ }),
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ PasswordRotationService,
+ {
+ provide: PrismaService,
+ useValue: mockPrismaService,
+ },
+ {
+ provide: ConfigService,
+ useValue: mockConfigService,
+ },
+ ],
+ }).compile();
+
+ service = module.get(PasswordRotationService);
+ prismaService = module.get(PrismaService);
+ configService = module.get(ConfigService);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+
+ describe('checkRotationStatus', () => {
+ it('should return canRotate false when user not found', async () => {
+ mockPrismaService.user.findUnique.mockResolvedValue(null);
+
+ const result = await service.checkRotationStatus('user-id');
+
+ expect(result.canRotate).toBe(false);
+ expect(result.reason).toBe('User not found or no password set');
+ });
+
+ it('should return canRotate false when user has no password', async () => {
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ email: 'test@example.com',
+ password: null,
+ createdAt: new Date(),
+ passwordHistory: [],
+ });
+
+ const result = await service.checkRotationStatus('user-id');
+
+ expect(result.canRotate).toBe(false);
+ expect(result.reason).toBe('User not found or no password set');
+ });
+
+ it('should return expired status when password has expired', async () => {
+ const oldDate = new Date();
+ oldDate.setDate(oldDate.getDate() - 100); // 100 days ago
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ email: 'test@example.com',
+ password: 'hashed-password',
+ createdAt: oldDate,
+ passwordHistory: [],
+ });
+
+ const result = await service.checkRotationStatus('user-id');
+
+ expect(result.canRotate).toBe(true);
+ expect(result.reason).toBe('Password has expired and must be changed');
+ expect(result.daysUntilExpiry).toBe(0);
+ });
+
+ it('should return valid status with days until expiry', async () => {
+ const recentDate = new Date();
+ recentDate.setDate(recentDate.getDate() - 30); // 30 days ago
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ email: 'test@example.com',
+ password: 'hashed-password',
+ createdAt: recentDate,
+ passwordHistory: [],
+ });
+
+ const result = await service.checkRotationStatus('user-id');
+
+ expect(result.canRotate).toBe(true);
+ expect(result.daysUntilExpiry).toBe(60); // 90 - 30
+ expect(result.reason).toBeUndefined();
+ });
+
+ it('should use password history date when available', async () => {
+ const createdDate = new Date();
+ createdDate.setDate(createdDate.getDate() - 100);
+
+ const passwordChangeDate = new Date();
+ passwordChangeDate.setDate(passwordChangeDate.getDate() - 30);
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ email: 'test@example.com',
+ password: 'hashed-password',
+ createdAt: createdDate,
+ passwordHistory: [{ createdAt: passwordChangeDate }],
+ });
+
+ const result = await service.checkRotationStatus('user-id');
+
+ expect(result.canRotate).toBe(true);
+ expect(result.daysUntilExpiry).toBe(60); // 90 - 30 (from password change date)
+ expect(result.lastRotation).toEqual(passwordChangeDate);
+ });
+ });
+
+ describe('validatePasswordNotInHistory', () => {
+ it('should return valid true when history is empty', async () => {
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([]);
+
+ const result = await service.validatePasswordNotInHistory('user-id', 'new-password');
+
+ expect(result.valid).toBe(true);
+ });
+
+ it('should return valid true when password not in history', async () => {
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([
+ { id: '1', passwordHash: 'old-hash-1' },
+ { id: '2', passwordHash: 'old-hash-2' },
+ ]);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(false);
+
+ const result = await service.validatePasswordNotInHistory('user-id', 'new-password');
+
+ expect(result.valid).toBe(true);
+ });
+
+ it('should return valid false when password matches history', async () => {
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([
+ { id: '1', passwordHash: 'old-hash-1' },
+ { id: '2', passwordHash: 'old-hash-2' },
+ ]);
+ (bcrypt.compare as jest.Mock).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
+
+ const result = await service.validatePasswordNotInHistory('user-id', 'reused-password');
+
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe('Cannot reuse any of your last 5 passwords');
+ });
+
+ it('should respect custom password history count', async () => {
+ mockConfigService.get.mockImplementation((key: string, defaultValue?: number) => {
+ if (key === 'PASSWORD_HISTORY_COUNT') return 10;
+ return defaultValue;
+ });
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([]);
+
+ await service.validatePasswordNotInHistory('user-id', 'new-password');
+
+ expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 10 }),
+ );
+ });
+ });
+
+ describe('addPasswordToHistory', () => {
+ it('should add password to history and not delete when under limit', async () => {
+ const mockTx = {
+ passwordHistory: {
+ create: jest.fn().mockResolvedValue({ id: 'new-entry-id' }),
+ findMany: jest.fn().mockResolvedValue([]),
+ deleteMany: jest.fn(),
+ },
+ };
+ mockPrismaService.$transaction.mockImplementation(async (callback: Function) => {
+ return callback(mockTx);
+ });
+
+ await service.addPasswordToHistory('user-id', 'hashed-password');
+
+ expect(mockTx.passwordHistory.create).toHaveBeenCalledWith({
+ data: { userId: 'user-id', passwordHash: 'hashed-password' },
+ });
+ expect(mockTx.passwordHistory.deleteMany).not.toHaveBeenCalled();
+ });
+
+ it('should delete old entries when over limit', async () => {
+ const oldEntries = [
+ { id: 'old-entry-1' },
+ { id: 'old-entry-2' },
+ ];
+ const mockTx = {
+ passwordHistory: {
+ create: jest.fn().mockResolvedValue({ id: 'new-entry-id' }),
+ findMany: jest.fn().mockResolvedValue(oldEntries),
+ deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
+ },
+ };
+ mockPrismaService.$transaction.mockImplementation(async (callback: Function) => {
+ return callback(mockTx);
+ });
+
+ await service.addPasswordToHistory('user-id', 'hashed-password');
+
+ expect(mockTx.passwordHistory.deleteMany).toHaveBeenCalledWith({
+ where: { id: { in: ['old-entry-1', 'old-entry-2'] } },
+ });
+ });
+ });
+
+ describe('validatePasswordRotation', () => {
+ it('should return invalid when rotation status cannot rotate', async () => {
+ mockPrismaService.user.findUnique.mockResolvedValue(null);
+
+ const result = await service.validatePasswordRotation('user-id', 'new-password');
+
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe('User not found or no password set');
+ });
+
+ it('should return invalid when password is in history', async () => {
+ const recentDate = new Date();
+ recentDate.setDate(recentDate.getDate() - 30);
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ email: 'test@example.com',
+ password: 'hashed-password',
+ createdAt: recentDate,
+ passwordHistory: [],
+ });
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([
+ { id: '1', passwordHash: 'old-hash' },
+ ]);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(true);
+
+ // Reset config mock to return default values
+ mockConfigService.get.mockImplementation((key: string, defaultValue?: number) => {
+ const config: Record = {
+ PASSWORD_EXPIRY_DAYS: 90,
+ PASSWORD_HISTORY_COUNT: 5,
+ PASSWORD_EXPIRY_WARNING_DAYS: 7,
+ };
+ return config[key] ?? defaultValue;
+ });
+
+ const result = await service.validatePasswordRotation('user-id', 'reused-password');
+
+ expect(result.valid).toBe(false);
+ expect(result.reason).toBe('Cannot reuse any of your last 5 passwords');
+ });
+
+ it('should return valid when all checks pass', async () => {
+ const recentDate = new Date();
+ recentDate.setDate(recentDate.getDate() - 30);
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ email: 'test@example.com',
+ password: 'hashed-password',
+ createdAt: recentDate,
+ passwordHistory: [],
+ });
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([]);
+ (bcrypt.compare as jest.Mock).mockResolvedValue(false);
+
+ const result = await service.validatePasswordRotation('user-id', 'new-password');
+
+ expect(result.valid).toBe(true);
+ });
+ });
+
+ describe('getPasswordHistory', () => {
+ it('should return password history with default limit', async () => {
+ const mockHistory = [
+ { id: '1', userId: 'user-id', createdAt: new Date() },
+ { id: '2', userId: 'user-id', createdAt: new Date() },
+ ];
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue(mockHistory);
+
+ const result = await service.getPasswordHistory('user-id');
+
+ expect(result).toEqual(mockHistory);
+ expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 10 }),
+ );
+ });
+
+ it('should return password history with custom limit', async () => {
+ const mockHistory = [{ id: '1', userId: 'user-id', createdAt: new Date() }];
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue(mockHistory);
+
+ const result = await service.getPasswordHistory('user-id', 5);
+
+ expect(result).toEqual(mockHistory);
+ expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 5 }),
+ );
+ });
+
+ it('should select only required fields', async () => {
+ mockPrismaService.passwordHistory.findMany.mockResolvedValue([]);
+
+ await service.getPasswordHistory('user-id');
+
+ expect(mockPrismaService.passwordHistory.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ select: { id: true, userId: true, createdAt: true },
+ }),
+ );
+ });
+ });
+
+ describe('clearPasswordHistory', () => {
+ it('should delete all password history for user', async () => {
+ mockPrismaService.passwordHistory.deleteMany.mockResolvedValue({ count: 5 });
+
+ await service.clearPasswordHistory('user-id');
+
+ expect(mockPrismaService.passwordHistory.deleteMany).toHaveBeenCalledWith({
+ where: { userId: 'user-id' },
+ });
+ });
+ });
+
+ describe('getUsersWithExpiredPasswords', () => {
+ it('should return empty array when no users have expired passwords', async () => {
+ const recentDate = new Date();
+ mockPrismaService.user.findMany.mockResolvedValue([
+ {
+ id: 'user-1',
+ email: 'user1@example.com',
+ password: 'hash',
+ createdAt: recentDate,
+ passwordHistory: [],
+ },
+ ]);
+
+ const result = await service.getUsersWithExpiredPasswords();
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return users with expired passwords', async () => {
+ const oldDate = new Date();
+ oldDate.setDate(oldDate.getDate() - 100);
+
+ mockPrismaService.user.findMany.mockResolvedValue([
+ {
+ id: 'user-1',
+ email: 'expired@example.com',
+ password: 'hash',
+ createdAt: oldDate,
+ passwordHistory: [],
+ },
+ ]);
+
+ const result = await service.getUsersWithExpiredPasswords();
+
+ expect(result).toHaveLength(1);
+ expect(result[0].userId).toBe('user-1');
+ expect(result[0].email).toBe('expired@example.com');
+ expect(result[0].daysExpired).toBe(10); // 100 - 90
+ });
+
+ it('should use password history date for expiry calculation', async () => {
+ const createdDate = new Date();
+ createdDate.setDate(createdDate.getDate() - 200);
+
+ const passwordChangeDate = new Date();
+ passwordChangeDate.setDate(passwordChangeDate.getDate() - 100);
+
+ mockPrismaService.user.findMany.mockResolvedValue([
+ {
+ id: 'user-1',
+ email: 'expired@example.com',
+ password: 'hash',
+ createdAt: createdDate,
+ passwordHistory: [{ createdAt: passwordChangeDate }],
+ },
+ ]);
+
+ const result = await service.getUsersWithExpiredPasswords();
+
+ expect(result).toHaveLength(1);
+ expect(result[0].daysExpired).toBe(10); // 100 - 90
+ });
+
+ it('should only include users with passwords', async () => {
+ mockPrismaService.user.findMany.mockResolvedValue([]);
+
+ await service.getUsersWithExpiredPasswords();
+
+ expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { password: { not: null } },
+ }),
+ );
+ });
+ });
+
+ describe('requiresPasswordRotation', () => {
+ it('should return false when user not found', async () => {
+ mockPrismaService.user.findUnique.mockResolvedValue(null);
+
+ const result = await service.requiresPasswordRotation('user-id');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when user has no password', async () => {
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ password: null,
+ createdAt: new Date(),
+ passwordHistory: [],
+ });
+
+ const result = await service.requiresPasswordRotation('user-id');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when password is expired', async () => {
+ const oldDate = new Date();
+ oldDate.setDate(oldDate.getDate() - 100);
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ password: 'hashed-password',
+ createdAt: oldDate,
+ passwordHistory: [],
+ });
+
+ const result = await service.requiresPasswordRotation('user-id');
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true when within warning period', async () => {
+ const warningPeriodDate = new Date();
+ warningPeriodDate.setDate(warningPeriodDate.getDate() - 85); // 90 - 7 = 83, so 85 is within warning
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ password: 'hashed-password',
+ createdAt: warningPeriodDate,
+ passwordHistory: [],
+ });
+
+ const result = await service.requiresPasswordRotation('user-id');
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when not in warning period and not expired', async () => {
+ const recentDate = new Date();
+ recentDate.setDate(recentDate.getDate() - 30);
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ password: 'hashed-password',
+ createdAt: recentDate,
+ passwordHistory: [],
+ });
+
+ const result = await service.requiresPasswordRotation('user-id');
+
+ expect(result).toBe(false);
+ });
+
+ it('should use custom warning days from config', async () => {
+ mockConfigService.get.mockImplementation((key: string, defaultValue?: number) => {
+ if (key === 'PASSWORD_EXPIRY_WARNING_DAYS') return 14;
+ if (key === 'PASSWORD_EXPIRY_DAYS') return 90;
+ return defaultValue;
+ });
+
+ const borderlineDate = new Date();
+ borderlineDate.setDate(borderlineDate.getDate() - 75); // 90 - 14 = 76, so 75 is just before warning
+
+ mockPrismaService.user.findUnique.mockResolvedValue({
+ id: 'user-id',
+ password: 'hashed-password',
+ createdAt: borderlineDate,
+ passwordHistory: [],
+ });
+
+ const result = await service.requiresPasswordRotation('user-id');
+
+ expect(result).toBe(false);
+ });
+ });
+});