Skip to content

Commit 869ba27

Browse files
feat: execute Batch 2 of pre-launch backend code fixes
- B2: Add brute-force login protection via Redis - B3/B4: Add GDPR DELETE /users/me and PATCH /users/me - B5: Strict MIME/size validation for S3 uploads (refactored to createPresignedPost) - B6: Invite abuse limits (max 20 per group, 10/hr per user) - B7: Nightly cron job to purge expired refresh tokens - B8: Include payerName in ExpenseDto API response
1 parent 74c6af2 commit 869ba27

17 files changed

Lines changed: 229 additions & 22 deletions

File tree

apps/backend/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
"seed": "ts-node prisma/seed.ts"
1414
},
1515
"dependencies": {
16-
"@aws-sdk/client-s3": "^3.888.0",
17-
"@aws-sdk/s3-request-presigner": "^3.888.0",
16+
"@aws-sdk/client-s3": "^3.1037.0",
17+
"@aws-sdk/s3-presigned-post": "^3.1037.0",
18+
"@aws-sdk/s3-request-presigner": "^3.1037.0",
1819
"@fairshare/shared-types": "workspace:*",
1920
"@nestjs/common": "^11.0.0",
2021
"@nestjs/config": "^4.0.0",
@@ -23,6 +24,7 @@
2324
"@nestjs/passport": "^11.0.5",
2425
"@nestjs/platform-express": "^11.0.0",
2526
"@nestjs/platform-socket.io": "^11.1.14",
27+
"@nestjs/schedule": "^6.1.3",
2628
"@nestjs/throttler": "^6.4.0",
2729
"@nestjs/websockets": "^11.1.14",
2830
"@opentelemetry/api": "^1.9.0",

apps/backend/src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Module } from '@nestjs/common';
22
import { APP_GUARD } from '@nestjs/core';
33
import { ConfigModule } from '@nestjs/config';
4+
import { ScheduleModule } from '@nestjs/schedule';
45
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
56
import { AppConfigModule } from './config/app-config.module';
67
import { AuthModule } from './auth/auth.module';
@@ -21,6 +22,7 @@ import { HealthModule } from './health/health.module';
2122
import { PaymentsModule } from './payments/payments.module';
2223
import { ObservabilityModule } from './observability/observability.module';
2324
import { JobsModule } from './jobs/jobs.module';
25+
import { CronModule } from './cron/cron.module';
2426

2527
@Module({
2628
imports: [
@@ -34,6 +36,7 @@ import { JobsModule } from './jobs/jobs.module';
3436
limit: 100,
3537
},
3638
]),
39+
ScheduleModule.forRoot(),
3740
AppConfigModule,
3841
PrismaModule,
3942
RedisModule,
@@ -42,6 +45,7 @@ import { JobsModule } from './jobs/jobs.module';
4245
HealthModule,
4346
PaymentsModule,
4447
JobsModule,
48+
CronModule,
4549
ObservabilityModule,
4650
ActivityModule,
4751
AuthModule,

apps/backend/src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { AuthService } from './auth.service';
99
import { JwtStrategy } from './strategies/jwt.strategy';
1010

1111
import { GroupsModule } from '../groups/groups.module';
12+
import { RedisModule } from '../redis/redis.module';
1213

1314
@Module({
1415
imports: [
1516
AppConfigModule,
1617
PrismaModule,
1718
PassportModule,
1819
GroupsModule,
20+
RedisModule,
1921
JwtModule.registerAsync({
2022
imports: [AppConfigModule],
2123
inject: [AppConfigService],

apps/backend/src/auth/auth.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { LoginDto } from './dto/login.dto';
1212
import { RegisterDto } from './dto/register.dto';
1313
import { GoogleLoginDto } from './dto/google-login.dto';
1414
import { setActiveUsers } from '../observability/metrics';
15+
import { RedisService } from '../redis/redis.service';
1516

1617
import { GroupsService } from '../groups/groups.service';
1718

@@ -24,6 +25,7 @@ export class AuthService {
2425
private readonly jwtService: JwtService,
2526
private readonly config: AppConfigService,
2627
private readonly groupsService: GroupsService,
28+
private readonly redisService: RedisService,
2729
) {}
2830

2931
async register(dto: RegisterDto): Promise<AuthTokensDto> {
@@ -65,18 +67,27 @@ export class AuthService {
6567
const email = dto.email.toLowerCase();
6668
this.logger.log(`Login attempt email=${email}`);
6769

70+
const failedCount = await this.redisService.getFailedLoginCount(email);
71+
if (failedCount >= 5) {
72+
this.logger.warn(`Login failed rate-limit email=${email}`);
73+
throw new UnauthorizedException('Too many failed attempts. Try again in 15 minutes.');
74+
}
75+
6876
const user = await this.prisma.user.findUnique({ where: { email } });
6977
if (!user) {
78+
await this.redisService.incrementFailedLogin(email);
7079
this.logger.warn(`Login failed user-not-found email=${email}`);
7180
throw new UnauthorizedException('Invalid credentials');
7281
}
7382

7483
const valid = await compare(dto.password, user.passwordHash);
7584
if (!valid) {
85+
await this.redisService.incrementFailedLogin(email);
7686
this.logger.warn(`Login failed invalid-password userId=${user.id}`);
7787
throw new UnauthorizedException('Invalid credentials');
7888
}
7989

90+
await this.redisService.clearFailedLogin(email);
8091
this.logger.log(`Login success userId=${user.id} email=${email}`);
8192
return this.issueTokens(user.id, user.email);
8293
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { PrismaModule } from '../common/prisma.module';
3+
import { CronService } from './cron.service';
4+
5+
@Module({
6+
imports: [PrismaModule],
7+
providers: [CronService],
8+
})
9+
export class CronModule {}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { PrismaService } from '../common/prisma.service';
4+
5+
@Injectable()
6+
export class CronService {
7+
private readonly logger = new Logger(CronService.name);
8+
9+
constructor(private readonly prisma: PrismaService) {}
10+
11+
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
12+
async purgeExpiredRefreshTokens() {
13+
this.logger.log('Running nightly cron job: purgeExpiredRefreshTokens');
14+
try {
15+
const result = await this.prisma.refreshToken.deleteMany({
16+
where: {
17+
OR: [
18+
{ expiresAt: { lt: new Date() } },
19+
{ revokedAt: { not: null } },
20+
],
21+
},
22+
});
23+
this.logger.log(`Purged ${result.count} expired/revoked refresh tokens.`);
24+
} catch (error) {
25+
this.logger.error('Failed to purge refresh tokens', error instanceof Error ? error.stack : undefined);
26+
}
27+
}
28+
}

apps/backend/src/expenses/expenses.service.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class ExpensesService {
4141
if (idempotencyKey) {
4242
const existingByKey = await this.prisma.expense.findUnique({
4343
where: { idempotencyKey },
44-
include: { splits: true, receipt: true },
44+
include: { splits: true, receipt: true, payer: { select: { name: true } } },
4545
});
4646
if (existingByKey) {
4747
return this.toExpenseDto(existingByKey);
@@ -84,7 +84,7 @@ export class ExpensesService {
8484
if (idempotencyKey && this.isUniqueConstraintError(error)) {
8585
const existing = await this.prisma.expense.findUnique({
8686
where: { idempotencyKey },
87-
include: { splits: true, receipt: true },
87+
include: { splits: true, receipt: true, payer: { select: { name: true } } },
8888
});
8989
if (existing) {
9090
return this.toExpenseDto(existing);
@@ -112,7 +112,7 @@ export class ExpensesService {
112112
} else {
113113
const rows = await this.prisma.expense.findMany({
114114
where: { groupId },
115-
include: { splits: true, receipt: true },
115+
include: { splits: true, receipt: true, payer: { select: { name: true } } },
116116
orderBy: { createdAt: 'desc' },
117117
});
118118
expenses = rows.map((row) => this.toExpenseDto(row));
@@ -185,7 +185,7 @@ export class ExpensesService {
185185
}
186186

187187
async getById(id: string): Promise<ExpenseDto> {
188-
const expense = await this.prisma.expense.findUnique({ where: { id }, include: { splits: true, receipt: true } });
188+
const expense = await this.prisma.expense.findUnique({ where: { id }, include: { splits: true, receipt: true, payer: { select: { name: true } } } });
189189
if (!expense) {
190190
throw new NotFoundException('Expense not found');
191191
}
@@ -200,7 +200,7 @@ export class ExpensesService {
200200
description: dto.description,
201201
category: dto.category,
202202
},
203-
include: { splits: true, receipt: true },
203+
include: { splits: true, receipt: true, payer: { select: { name: true } } },
204204
});
205205

206206
await this.activityService.log({
@@ -533,7 +533,7 @@ export class ExpensesService {
533533

534534
return tx.expense.findUniqueOrThrow({
535535
where: { id: createdExpense.id },
536-
include: { splits: true, receipt: true },
536+
include: { splits: true, receipt: true, payer: { select: { name: true } } },
537537
});
538538
}
539539

@@ -692,11 +692,15 @@ export class ExpensesService {
692692
receipt: {
693693
fileKey: string;
694694
} | null;
695+
payer?: {
696+
name: string;
697+
} | null;
695698
}): ExpenseDto {
696699
return {
697700
id: expense.id,
698701
groupId: expense.groupId,
699702
payerId: expense.payerId,
703+
payerName: expense.payer?.name,
700704
description: expense.description,
701705
totalAmountCents: expense.totalAmountCents.toString(),
702706
currency: expense.currency as 'USD' | 'EUR' | 'INR',

apps/backend/src/groups/groups.service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,8 +529,23 @@ export class GroupsService {
529529
dto: InviteMemberDto,
530530
): Promise<{ success: true }> {
531531
await this.assertMembership(groupId, actorUserId);
532+
533+
// Rate limit: 10 invites per hour per user
534+
const userInviteCount = await this.redis.incrementInviteRateLimit(actorUserId);
535+
if (userInviteCount > 10) {
536+
throw new ForbiddenException('You have exceeded your invite limit (10 per hour)');
537+
}
538+
532539
const email = dto.email.toLowerCase();
533540

541+
// Check pending invites limit
542+
const pendingCount = await this.prisma.groupInvite.count({
543+
where: { groupId },
544+
});
545+
if (pendingCount >= 20) {
546+
throw new ForbiddenException('This group has too many pending invites (max 20)');
547+
}
548+
534549
const user = await this.prisma.user.findUnique({ where: { email } });
535550
if (!user) {
536551
await this.prisma.groupInvite.upsert({

apps/backend/src/receipts/receipts.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class ReceiptsService {
5555
expenseId,
5656
});
5757

58-
const uploadUrl = await this.s3.getPresignedUploadUrl(fileKey, RECEIPT_MIME_TYPES[extension]);
59-
return { uploadUrl, fileKey };
58+
const { url: uploadUrl, fields: uploadFields } = await this.s3.getPresignedUploadUrl(fileKey, RECEIPT_MIME_TYPES[extension]);
59+
return { uploadUrl, uploadFields, fileKey };
6060
}
6161
}

apps/backend/src/redis/redis.service.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,51 @@ export class RedisService {
6969
}
7070
}
7171

72+
async incrementFailedLogin(email: string): Promise<number> {
73+
try {
74+
const key = `login:fail:${email}`;
75+
const count = await this.redis.incr(key);
76+
if (count === 1) {
77+
await this.redis.expire(key, 15 * 60); // 15 minutes
78+
}
79+
return count;
80+
} catch (error) {
81+
this.logger.warn(`Redis incr skipped for login:fail:${email}`);
82+
return 0; // Fallback to allow if Redis is down
83+
}
84+
}
85+
86+
async clearFailedLogin(email: string): Promise<void> {
87+
try {
88+
await this.redis.del(`login:fail:${email}`);
89+
} catch (error) {
90+
this.logger.warn(`Redis del skipped for login:fail:${email}`);
91+
}
92+
}
93+
94+
async getFailedLoginCount(email: string): Promise<number> {
95+
try {
96+
const count = await this.redis.get(`login:fail:${email}`);
97+
return count ? parseInt(count, 10) : 0;
98+
} catch (error) {
99+
return 0;
100+
}
101+
}
102+
103+
async incrementInviteRateLimit(userId: string): Promise<number> {
104+
try {
105+
const key = `invite:rate:${userId}`;
106+
const count = await this.redis.incr(key);
107+
if (count === 1) {
108+
await this.redis.expire(key, 60 * 60); // 1 hour
109+
}
110+
return count;
111+
} catch (error) {
112+
this.logger.warn(`Redis incr skipped for invite:rate:${userId}`);
113+
return 0;
114+
}
115+
}
116+
72117
private async safeGet(key: string): Promise<string | null> {
73118
try {
74119
return await this.redis.get(key);

0 commit comments

Comments
 (0)