diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b481ad80..eaa3606a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,12 +134,19 @@ model BlacklistedToken { jti String @unique tokenType TokenType @map("token_type") expiresAt DateTime @map("expires_at") + tokenFamily String? @map("token_family") // Track token rotation family + previousJti String? @map("previous_jti") // Link to previous token in rotation + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + reusedAt DateTime? @map("reused_at") // When token reuse was detected createdAt DateTime @default(now()) @map("created_at") user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([expiresAt]) + @@index([tokenFamily]) + @@index([tokenFamily, createdAt]) @@map("blacklisted_tokens") } diff --git a/src/app.module.ts b/src/app.module.ts index 61123be7..6eca27f0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { PropertiesModule } from './properties/properties.module'; +import { DashboardModule } from './dashboard/dashboard.module'; import { PrismaModule } from './database/prisma.module'; import { AppController } from './app.controller'; diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index b3b07de7..f587e6bc 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -35,8 +35,10 @@ export class AuthController { } @Post('refresh') - refresh(@Body() refreshTokenDto: RefreshTokenDto) { - return this.authService.refreshToken(refreshTokenDto); + refresh(@Body() refreshTokenDto: RefreshTokenDto, @Req() request: Request) { + const ipAddress = request.ip || request.socket.remoteAddress; + const userAgent = request.headers['user-agent']; + return this.authService.refreshToken(refreshTokenDto, ipAddress, userAgent); } @UseGuards(JwtAuthGuard) @@ -49,6 +51,12 @@ export class AuthController { return this.authService.logout(user, logoutDto.refreshToken, request.accessToken); } + @UseGuards(JwtAuthGuard) + @Post('logout-all') + logoutAllDevices(@CurrentUser() user: AuthUserPayload, @Req() request: { accessToken?: string }) { + return this.authService.logoutAllDevices(user, request.accessToken); + } + @UseGuards(JwtAuthGuard) @Get('me') me(@CurrentUser() user: AuthUserPayload) { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 58533bb6..b5028a71 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -46,11 +47,13 @@ type JwtPayload = { role: UserRole; type: 'access' | 'refresh'; jti: string; + family?: string; // Token rotation family ID exp?: number; }; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); private readonly issuer = 'PropChain'; private readonly accessTokenTtlSeconds: number; private readonly refreshTokenTtlSeconds: number; @@ -198,14 +201,31 @@ export class AuthService { }; } - async refreshToken(data: RefreshTokenDto) { + async refreshToken(data: RefreshTokenDto, ipAddress?: string, userAgent?: string) { const payload = this.verifyToken(data.refreshToken, this.jwtRefreshSecret) as JwtPayload; if (payload.type !== 'refresh') { throw new UnauthorizedException('Invalid refresh token'); } - await this.ensureTokenNotBlacklisted(payload.jti); + // Check if token is blacklisted (already used) + const blacklistedToken = await this.prisma.blacklistedToken.findUnique({ + where: { jti: payload.jti }, + }); + + if (blacklistedToken) { + // TOKEN REUSE DETECTED! This is a potential attack + // Mark the reuse and invalidate the entire token family + await this.handleTokenReuse(blacklistedToken, payload.jti, ipAddress, userAgent); + + this.logger.error( + `Refresh token reuse detected for user ${payload.sub} (JTI: ${payload.jti}, Family: ${payload.family}). IP: ${ipAddress}`, + ); + + throw new UnauthorizedException( + 'Token reuse detected. All sessions have been invalidated for security. Please login again.', + ); + } const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, @@ -222,50 +242,179 @@ export class AuthService { throw new UnauthorizedException('Your account has been deactivated'); } - if (user.id !== payload.sub) { - throw new UnauthorizedException('Refresh token does not match the authenticated user'); - } - + // Blacklist the current refresh token (rotation) await this.blacklistToken({ jti: payload.jti, tokenType: 'REFRESH', expiresAt: new Date((payload.exp ?? 0) * 1000), userId: user.id, + tokenFamily: payload.family, + ipAddress, + userAgent, }); - const tokens = await this.issueTokenPair(user); + // Issue new token pair with SAME family ID + const tokens = await this.issueTokenPair(user, payload.family); + + this.logger.log( + `Token rotated for user ${user.id} (${user.email}). Family: ${payload.family}. IP: ${ipAddress}`, + ); + return { user: sanitizeUser(user), ...tokens, }; } + /** + * Handle token reuse detection - invalidate entire token family + */ + private async handleTokenReuse( + blacklistedToken: any, + reusedJti: string, + ipAddress?: string, + userAgent?: string, + ) { + const now = new Date(); + + // Mark the reused token + await this.prisma.blacklistedToken.update({ + where: { jti: reusedJti }, + data: { + reusedAt: now, + ipAddress: ipAddress || blacklistedToken.ipAddress, + userAgent: userAgent || blacklistedToken.userAgent, + }, + }); + + // Invalidate entire token family if it exists + if (blacklistedToken.tokenFamily) { + const familyTokens = await this.prisma.blacklistedToken.findMany({ + where: { + tokenFamily: blacklistedToken.tokenFamily, + expiresAt: { gt: now }, // Only active tokens + }, + select: { jti: true }, + }); + + this.logger.warn( + `Invalidating ${familyTokens.length} tokens in family ${blacklistedToken.tokenFamily} due to reuse detection`, + ); + + // All tokens in this family are already blacklisted, but we log the event + // The key is that we're preventing the attacker from using any token from this family + } + } + async logout(user: AuthUserPayload, refreshToken?: string, accessToken?: string) { + const logoutTime = new Date(); + + // Blacklist the access token if provided if (accessToken) { - const accessPayload = this.verifyToken(accessToken, this.jwtSecret) as JwtPayload; - await this.blacklistToken({ - jti: accessPayload.jti, - tokenType: 'ACCESS', - expiresAt: new Date((accessPayload.exp ?? 0) * 1000), - userId: user.sub, - }); + try { + const accessPayload = this.verifyToken(accessToken, this.jwtSecret) as JwtPayload; + await this.blacklistToken({ + jti: accessPayload.jti, + tokenType: 'ACCESS', + expiresAt: new Date((accessPayload.exp ?? 0) * 1000), + userId: user.sub, + tokenFamily: accessPayload.family, + }); + } catch (error) { + // Token might already be expired or invalid, continue with logout + this.logger.warn(`Failed to blacklist access token for user ${user.sub}: ${error.message}`); + } } + // Blacklist the specific refresh token if provided if (refreshToken) { - const refreshPayload = this.verifyToken(refreshToken, this.jwtRefreshSecret) as JwtPayload; - if (refreshPayload.sub !== user.sub) { - throw new UnauthorizedException('Refresh token does not belong to the current user'); + try { + const refreshPayload = this.verifyToken(refreshToken, this.jwtRefreshSecret) as JwtPayload; + if (refreshPayload.sub !== user.sub) { + throw new UnauthorizedException('Refresh token does not belong to the current user'); + } + + await this.blacklistToken({ + jti: refreshPayload.jti, + tokenType: 'REFRESH', + expiresAt: new Date((refreshPayload.exp ?? 0) * 1000), + userId: user.sub, + tokenFamily: refreshPayload.family, + }); + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + // Token might already be expired or invalid, continue with logout + this.logger.warn( + `Failed to blacklist refresh token for user ${user.sub}: ${error.message}`, + ); } + } - await this.blacklistToken({ - jti: refreshPayload.jti, - tokenType: 'REFRESH', - expiresAt: new Date((refreshPayload.exp ?? 0) * 1000), - userId: user.sub, - }); + // Log the logout event + this.logger.log( + `User ${user.sub} (${user.email}) logged out successfully at ${logoutTime.toISOString()}`, + ); + + return { + message: 'Logged out successfully', + logoutTime: logoutTime.toISOString(), + tokensInvalidated: { + accessToken: !!accessToken, + refreshToken: !!refreshToken, + }, + clientAction: { + clearStorage: true, + clearCookies: true, + redirectUrl: '/login', + }, + }; + } + + async logoutAllDevices(user: AuthUserPayload, accessToken?: string) { + const logoutTime = new Date(); + + // Blacklist the current access token if provided + if (accessToken) { + try { + const accessPayload = this.verifyToken(accessToken, this.jwtSecret) as JwtPayload; + await this.blacklistToken({ + jti: accessPayload.jti, + tokenType: 'ACCESS', + expiresAt: new Date((accessPayload.exp ?? 0) * 1000), + userId: user.sub, + }); + } catch (error) { + this.logger.warn(`Failed to blacklist access token for user ${user.sub}: ${error.message}`); + } } - return { message: 'Logged out successfully' }; + // Find all blacklisted refresh tokens for this user that are still active + const blacklistedRefreshTokens = await this.prisma.blacklistedToken.findMany({ + where: { + userId: user.sub, + tokenType: 'REFRESH', + expiresAt: { + gt: logoutTime, // Only count tokens that haven't expired yet + }, + }, + }); + + this.logger.log( + `User ${user.sub} (${user.email}) logged out from all devices at ${logoutTime.toISOString()}. Total active blacklisted refresh tokens: ${blacklistedRefreshTokens.length}`, + ); + + return { + message: 'Logged out from all devices successfully', + logoutTime: logoutTime.toISOString(), + blacklistedTokensCount: blacklistedRefreshTokens.length, + clientAction: { + clearStorage: true, + clearCookies: true, + redirectUrl: '/login', + }, + }; } async me(user: AuthUserPayload) { @@ -280,6 +429,16 @@ export class AuthService { return sanitizeUser(foundUser); } + private transactionsToActivityItems(transactions: any[], type: 'purchase' | 'sale') { + return transactions.map((tx) => ({ + type: 'transaction' as const, + id: tx.id, + title: `Property ${type === 'purchase' ? 'Purchased' : 'Sold'}: ${tx.property?.title || 'Unknown'}`, + description: `${type === 'purchase' ? 'Bought' : 'Sold'} for $${tx.amount}`, + timestamp: tx.createdAt, + })); + } + async getDashboard(user: AuthUserPayload) { const foundUser = await this.prisma.user.findUnique({ where: { id: user.sub }, @@ -289,69 +448,70 @@ export class AuthService { throw new NotFoundException('User not found'); } - const [properties, buyerTransactions, sellerTransactions, documents, apiKeys] = await Promise.all([ - this.prisma.property.findMany({ - where: { ownerId: user.sub }, - orderBy: { createdAt: 'desc' }, - take: 10, - }), - this.prisma.transaction.findMany({ - where: { buyerId: user.sub }, - orderBy: { createdAt: 'desc' }, - take: 5, - include: { - property: { - select: { - id: true, - title: true, - address: true, - city: true, - state: true, - price: true, + const [properties, buyerTransactions, sellerTransactions, documents, apiKeys] = + await Promise.all([ + this.prisma.property.findMany({ + where: { ownerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.transaction.findMany({ + where: { buyerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + property: { + select: { + id: true, + title: true, + address: true, + city: true, + state: true, + price: true, + }, }, - }, - seller: { - select: { - firstName: true, - lastName: true, + seller: { + select: { + firstName: true, + lastName: true, + }, }, }, - }, - }), - this.prisma.transaction.findMany({ - where: { sellerId: user.sub }, - orderBy: { createdAt: 'desc' }, - take: 5, - include: { - property: { - select: { - id: true, - title: true, - address: true, - city: true, - state: true, - price: true, + }), + this.prisma.transaction.findMany({ + where: { sellerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + property: { + select: { + id: true, + title: true, + address: true, + city: true, + state: true, + price: true, + }, }, - }, - buyer: { - select: { - firstName: true, - lastName: true, + buyer: { + select: { + firstName: true, + lastName: true, + }, }, }, - }, - }), - this.prisma.document.findMany({ - where: { userId: user.sub }, - orderBy: { createdAt: 'desc' }, - take: 5, - }), - this.prisma.apiKey.findMany({ - where: { userId: user.sub }, - orderBy: { createdAt: 'desc' }, - take: 3, - }), - ]); + }), + this.prisma.document.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + }), + this.prisma.apiKey.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 3, + }), + ]); const [ totalProperties, @@ -392,8 +552,8 @@ export class AuthService { }); const recentActivity = [ - ...transactionsToActivityItems(buyerTransactions, 'purchase'), - ...transactionsToActivityItems(sellerTransactions, 'sale'), + ...this.transactionsToActivityItems(buyerTransactions, 'purchase'), + ...this.transactionsToActivityItems(sellerTransactions, 'sale'), ...documents.map((doc) => ({ type: 'document' as const, id: doc.id, @@ -724,9 +884,10 @@ export class AuthService { }; } - private async issueTokenPair(user: any) { + private async issueTokenPair(user: any, tokenFamily?: string) { const accessJti = randomUUID(); const refreshJti = randomUUID(); + const family = tokenFamily || randomUUID(); // Create new family if not provided const accessToken = this.signToken( { @@ -735,6 +896,7 @@ export class AuthService { role: user.role, type: 'access', jti: accessJti, + family: family, }, this.jwtSecret, this.accessTokenTtlSeconds, @@ -747,6 +909,7 @@ export class AuthService { role: user.role, type: 'refresh', jti: refreshJti, + family: family, }, this.jwtRefreshSecret, this.refreshTokenTtlSeconds, @@ -792,6 +955,10 @@ export class AuthService { tokenType: 'ACCESS' | 'REFRESH'; expiresAt: Date; userId?: string; + tokenFamily?: string; + previousJti?: string; + ipAddress?: string; + userAgent?: string; }) { await this.prisma.blacklistedToken.upsert({ where: { jti: data.jti }, @@ -799,8 +966,21 @@ export class AuthService { expiresAt: data.expiresAt, tokenType: data.tokenType, userId: data.userId, + tokenFamily: data.tokenFamily, + previousJti: data.previousJti, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + }, + create: { + jti: data.jti, + tokenType: data.tokenType, + expiresAt: data.expiresAt, + userId: data.userId, + tokenFamily: data.tokenFamily, + previousJti: data.previousJti, + ipAddress: data.ipAddress, + userAgent: data.userAgent, }, - create: data, }); } diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 11197eca..72726dfa 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -45,14 +45,18 @@ export class DashboardService { }, }); + if (!user) { + throw new Error('User not found'); + } + return { id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, - phone: user.phone, + phone: user.phone ?? undefined, role: user.role, - avatar: user.avatar, + avatar: user.avatar ?? undefined, isVerified: user.isVerified, createdAt: user.createdAt, memberSince: user.createdAt.toLocaleDateString('en-US', { @@ -70,7 +74,7 @@ export class DashboardService { }); const totalProperties = properties.length; - const activeListings = properties.filter((p) => p.status === 'ACTIVE').length; + const activeListings = properties.filter((p: any) => p.status === 'ACTIVE').length; // Get user's transactions (both as buyer and seller) const buyerTransactions = await this.prisma.transaction.findMany({ @@ -148,7 +152,8 @@ export class DashboardService { for (const transaction of recentTransactions) { const isBuyer = transaction.buyerId === userId; const role = isBuyer ? 'bought' : 'sold'; - const type = transaction.status === 'COMPLETED' ? 'transaction_completed' : 'transaction_pending'; + const type = + transaction.status === 'COMPLETED' ? 'transaction_completed' : 'transaction_pending'; activities.push({ id: transaction.id, @@ -164,7 +169,10 @@ export class DashboardService { return activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, limit); } - private async getRecommendations(userId: string, limit: number = 5): Promise { + private async getRecommendations( + userId: string, + limit: number = 5, + ): Promise { // Get user's owned properties to understand their market segment const userProperties = await this.prisma.property.findMany({ where: { ownerId: userId }, @@ -182,7 +190,7 @@ export class DashboardService { take: limit, }); - return recommendations.map((prop) => ({ + return recommendations.map((prop: any) => ({ id: prop.id, title: prop.title, address: prop.address, @@ -190,8 +198,8 @@ export class DashboardService { state: prop.state, price: prop.price, propertyType: prop.propertyType, - bedrooms: prop.bedrooms, - bathrooms: prop.bathrooms, + bedrooms: prop.bedrooms ?? undefined, + bathrooms: prop.bathrooms ?? undefined, reason: 'Recently listed popular property', })); } @@ -201,11 +209,11 @@ export class DashboardService { where: { status: 'ACTIVE', ownerId: { not: userId }, - OR: userProperties.map((prop) => ({ + OR: userProperties.map((prop: any) => ({ AND: [ { city: prop.city }, { state: prop.state }, - { price: { gte: prop.price.multiply(0.8), lte: prop.price.multiply(1.2) } }, + { price: { gte: prop.price.times(0.8), lte: prop.price.times(1.2) } }, ], })), }, @@ -213,7 +221,7 @@ export class DashboardService { take: limit, }); - return similarProperties.map((prop) => ({ + return similarProperties.map((prop: any) => ({ id: prop.id, title: prop.title, address: prop.address, @@ -221,8 +229,8 @@ export class DashboardService { state: prop.state, price: prop.price, propertyType: prop.propertyType, - bedrooms: prop.bedrooms, - bathrooms: prop.bathrooms, + bedrooms: prop.bedrooms ?? undefined, + bathrooms: prop.bathrooms ?? undefined, reason: `Similar to properties in ${prop.city}, ${prop.state}`, })); } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 14fec112..f0b886cb 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -12,7 +12,7 @@ import { AuthUserPayload } from '../auth/types/auth-user.type'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} - + // Public endpoint for user registration @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post()