Skip to content

Commit 741d3b5

Browse files
nystar1Copilot
andauthored
fix: add rate limiting to otp endpoints (#64)
* fix: otp security issues * chore: remove .DS_Store from tracking * chore: improve rate limit message and revert workspace config * chore: update lark-ui/src/routes/login/+page.server.ts sure Co-authored-by: Copilot <[email protected]> * chore: update ip ratelimit to 40 and other stuff --------- Co-authored-by: Copilot <[email protected]>
1 parent 2c856cb commit 741d3b5

File tree

7 files changed

+79
-10
lines changed

7 files changed

+79
-10
lines changed

.DS_Store

-6 KB
Binary file not shown.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ node_modules
44
owl-api/mail-service/certs/
55
*.p12
66
*.pfx
7-
*.pem
7+
*.pem
8+
.DS_Store

lark-ui/src/routes/login/+page.server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,16 @@ export const actions = {
3535

3636
if (!response || !response.ok) {
3737
const urlParams = new URLSearchParams();
38-
urlParams.set("error", responseData.error || responseData.message || 'An error occurred');
38+
const errorMessage = response?.status === 429
39+
? 'You are ratelimited. Please regenerate a new OTP in a few minutes and try again.'
40+
: (responseData.error || responseData.message || 'An error occurred');
41+
42+
urlParams.set("error", errorMessage);
43+
44+
if (email) {
45+
urlParams.set("email", email as string);
46+
}
47+
3948
return redirect(302, `/login?${urlParams.toString()}`);
4049
}
4150

owl-api/src/auth/auth.controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ export class AuthController {
1717
}
1818

1919
@Post('verify-otp')
20-
async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto, @Res({ passthrough: true }) res: Response) {
21-
const result = await this.authService.verifyOtp(verifyOtpDto);
20+
async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
21+
const forwarded = (req.headers['x-forwarded-for'] as string) || req.ip || req.socket.remoteAddress || '';
22+
const result = await this.authService.verifyOtp(verifyOtpDto, forwarded);
2223

2324
if (result.sessionId) {
2425
const cookieOptions = {

owl-api/src/auth/auth.service.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
1-
import { Injectable, UnauthorizedException, BadRequestException, ForbiddenException } from '@nestjs/common';
1+
import { Injectable, UnauthorizedException, BadRequestException, ForbiddenException, TooManyRequestsException } from '@nestjs/common';
22
import { PrismaService } from '../prisma.service';
33
import { MailService } from '../mail/mail.service';
44
import { LoginDto } from './dto/login.dto';
55
import { VerifyOtpDto } from './dto/verify-otp.dto';
66
import { CompleteProfileDto } from './dto/complete-profile.dto';
77
import { randomBytes, randomUUID, randomInt } from 'crypto';
88
import { Role } from '../enums/role.enum';
9+
import { RedisService } from '../redis.service';
910

1011
@Injectable()
1112
export class AuthService {
13+
private readonly OTP_EXPIRY_MS = 600000;
14+
1215
constructor(
1316
private prisma: PrismaService,
1417
private mailService: MailService,
18+
private redisService: RedisService,
1519
) {}
1620

1721
async requestOtp(loginDto: LoginDto) {
1822
const { email, referralCode } = loginDto;
19-
23+
24+
const sendAttemptCount = await this.redisService.increment(
25+
`otp:send:${email}`,
26+
3600,
27+
);
28+
29+
if (sendAttemptCount > 10) {
30+
throw new TooManyRequestsException('You are ratelimited. Please try again later.');
31+
}
32+
2033
const otp = this.generateOtp();
21-
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
34+
const expiresAt = new Date(Date.now() + this.OTP_EXPIRY_MS);
2235

2336
let existingUser = await this.prisma.user.findUnique({
2437
where: { email },
@@ -132,8 +145,28 @@ export class AuthService {
132145
return { message: 'OTP sent to your email', sessionId: session.id };
133146
}
134147

135-
async verifyOtp(verifyOtpDto: VerifyOtpDto) {
148+
async verifyOtp(verifyOtpDto: VerifyOtpDto, clientIp?: string) {
136149
const { email, otp } = verifyOtpDto;
150+
const rateLimitMessage = 'You are ratelimited. Please regenerate a new OTP in a few minutes and try again.';
151+
152+
const normalizedIp = this.normalizeIp(clientIp);
153+
const ipAttemptCount = await this.redisService.increment(
154+
`otp:ip:${normalizedIp}`,
155+
600,
156+
);
157+
158+
if (ipAttemptCount > 40) {
159+
throw new TooManyRequestsException(rateLimitMessage);
160+
}
161+
162+
const emailAttemptCount = await this.redisService.increment(
163+
`otp:verify:${email}`,
164+
600,
165+
);
166+
167+
if (emailAttemptCount > 20) {
168+
throw new TooManyRequestsException(rateLimitMessage);
169+
}
137170

138171
const session = await this.prisma.userSession.findFirst({
139172
where: {
@@ -145,6 +178,7 @@ export class AuthService {
145178
},
146179
},
147180
include: { user: true },
181+
orderBy: { createdAt: 'desc' },
148182
});
149183

150184
if (!session) {
@@ -163,6 +197,8 @@ export class AuthService {
163197
},
164198
});
165199

200+
await this.redisService.del(`otp:verify:${email}`);
201+
166202
const user = session.user;
167203

168204
if (!user) {
@@ -336,6 +372,11 @@ export class AuthService {
336372
return randomInt(100000, 999999).toString();
337373
}
338374

375+
private normalizeIp(ip?: string): string {
376+
if (!ip) return 'unknown';
377+
return ip.split(',')[0].trim().replace(/[^a-zA-Z0-9:\.\-]/g, '') || 'unknown';
378+
}
379+
339380
async checkHackatimeAccount(email: string): Promise<number | null> {
340381
const HACKATIME_ADMIN_API_URL = process.env.HACKATIME_ADMIN_API_URL || 'https://hackatime.hackclub.com/api/admin/v1';
341382
const HACKATIME_API_KEY = process.env.HACKATIME_API_KEY;
@@ -433,7 +474,7 @@ export class AuthService {
433474
}
434475

435476
const otp = this.generateOtp();
436-
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
477+
const expiresAt = new Date(Date.now() + this.OTP_EXPIRY_MS);
437478

438479
const existingOtp = await this.prisma.hackatimeLinkOtp.findFirst({
439480
where: { userId },

owl-api/src/redis.service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,24 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
172172
return result === 1;
173173
}
174174

175+
async increment(key: string, ttlSeconds?: number): Promise<number> {
176+
if (!(await this.isRedisAvailable())) {
177+
console.warn('Redis not available, skipping increment operation');
178+
return 0;
179+
}
180+
181+
try {
182+
const count = await this.client.incr(key);
183+
if (ttlSeconds && count === 1) {
184+
await this.client.expire(key, ttlSeconds);
185+
}
186+
return count;
187+
} catch (error) {
188+
console.error('Redis increment failed:', error);
189+
return 0;
190+
}
191+
}
192+
175193
async acquireLock(key: string, value: string, ttlSeconds: number): Promise<boolean> {
176194
if (!(await this.isRedisAvailable())) {
177195
console.warn('Redis not available, lock acquisition failed');

pnpm-workspace.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@ packages:
22
- 'lark-ui'
33
- 'owl-api'
44
- 'gateway'
5-

0 commit comments

Comments
 (0)