Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Add Rate Limiting to authenticate endpoint #445

Merged
merged 10 commits into from
Mar 1, 2025
5 changes: 4 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@
"dependencies": {
"@nestjs/axios": "^3.1.0",
"@nestjs/common": "^10.4.6",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.6",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.6",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.3.0",
"@nestjs/typeorm": "^10.0.2",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
Expand All @@ -50,6 +52,7 @@
"gettext-parser": "^4.0.4",
"handlebars": "^4.7.8",
"iconv-lite": "^0.6.3",
"ioredis": "^5.4.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
Expand Down Expand Up @@ -127,4 +130,4 @@
"yarn": ">=1.13.0"
},
"snyk": true
}
}
22 changes: 21 additions & 1 deletion api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { NestExpressApplication } from '@nestjs/platform-express';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { renderFile } from 'ejs';
import { config } from './config';
import { AuthController } from './controllers/auth.controller';
Expand Down Expand Up @@ -40,6 +42,10 @@ import { JwtStrategy } from './services/jwt.strategy';
import MailService from './services/mail.service';
import { UserService } from './services/user.service';
import ProjectStatsController from './controllers/project-stats.controller';
import { ConfigModule } from '@nestjs/config';
import { RedisModule } from './redis/redis.module';
import { UserLoginAttemptsStorage } from './redis/user-login-attempts.storage';
import { CustomThrottlerGuard } from './guards/custom-throttler.guard';

@Module({
imports: [
Expand All @@ -50,6 +56,9 @@ import ProjectStatsController from './controllers/project-stats.controller';
expiresIn: config.authTokenExpires,
},
}),
ThrottlerModule.forRoot([{ ttl: 0, limit: 0 }]),
ConfigModule.forRoot({ isGlobal: true }),
RedisModule,
TypeOrmModule.forRoot(config.db.default),
TypeOrmModule.forFeature([User, Invite, ProjectUser, Project, Term, Locale, ProjectLocale, Translation, ProjectClient, Plan, Label]),
HttpModule,
Expand All @@ -72,7 +81,18 @@ import ProjectStatsController from './controllers/project-stats.controller';
LocaleController,
IndexController,
],
providers: [UserService, AuthService, MailService, JwtStrategy, AuthorizationService],
providers: [
{
provide: APP_GUARD,
useClass: CustomThrottlerGuard,
},
UserService,
AuthService,
MailService,
JwtStrategy,
AuthorizationService,
UserLoginAttemptsStorage,
],
})
export class AppModule {
/**
Expand Down
2 changes: 2 additions & 0 deletions api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ApiOAuth2, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { Repository } from 'typeorm';
import { Throttle } from '@nestjs/throttler';
import { config } from '../config';
import {
AccessTokenDTO,
Expand Down Expand Up @@ -179,6 +180,7 @@ export class AuthController {
};
}

@Throttle({ default: { limit: 10, ttl: 60 * 1000 } })
@Post('token')
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand Down
11 changes: 11 additions & 0 deletions api/src/guards/custom-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard, ThrottlerLimitDetail } from '@nestjs/throttler';
import { ExecutionContext } from '@nestjs/common';
import { TooManyRequestsException } from '../errors';

@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async throwThrottlingException(context: ExecutionContext, throttlerLimitDetail: ThrottlerLimitDetail): Promise<void> {
throw new TooManyRequestsException('You have made too many requests. Please try again later.');
} // Custom error message
}
35 changes: 35 additions & 0 deletions api/src/redis/redis.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Module, Global, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { ConfigService } from '@nestjs/config';

@Global()
@Module({
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: (configService: ConfigService) => {
const redisHost = configService.get<string>('REDIS_HOST');
const redisPort = configService.get<number>('REDIS_PORT');
const redisPassword = configService.get<string>('REDIS_PASSWORD');

if (!redisHost || !redisPort) {
Logger.warn('Redis configuration is missing. Redis will not be initialized.');
return null;
}
try {
return new Redis({
host: redisHost,
port: redisPort,
password: redisPassword,
});
} catch (error) {
Logger.error('Failed to initialize Redis client:', error.message);
return null;
}
},
inject: [ConfigService],
},
],
exports: ['REDIS_CLIENT'],
})
export class RedisModule {}
63 changes: 63 additions & 0 deletions api/src/redis/user-login-attempts.storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class UserLoginAttemptsStorage {
private readonly logger = new Logger(UserLoginAttemptsStorage.name);
private inMemoryStorage: Map<string, { attempts: number; expiry: number }> = new Map();

constructor(@Inject('REDIS_CLIENT') private readonly redisClient?: Redis) {
if (!this.redisClient) {
this.logger.warn('Redis client is not initialized. Using in-memory storage.');
// Set up periodic cleanup every 5 minutes
setInterval(() => this.cleanupExpiredEntries(), 5 * 60 * 1000);
}
}

async setUserAttempts(userKey: string, userAttempt: number, ttl: number): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.set(userKey, userAttempt, 'EX', ttl);
} catch (error) {
this.logger.error('Failed to set user attempts in Redis:', error.message);
throw error;
}
} else {
const expiry = Date.now() + ttl * 1000;
this.inMemoryStorage.set(userKey, { attempts: userAttempt, expiry });
}
}

async getUserAttempts(userKey: string): Promise<number> {
if (this.redisClient) {
try {
const attempts = await this.redisClient.get(userKey);
return attempts ? parseInt(attempts, 10) : 0;
} catch (error) {
this.logger.error('Failed to get user attempts from Redis:', error.message);
throw error;
}
} else {
const entry = this.inMemoryStorage.get(userKey);
if (entry && entry.expiry > Date.now()) {
return entry.attempts;
}
// Remove expired entries
this.inMemoryStorage.delete(userKey);
return 0;
}
}

public getRedisClient(): Redis {
return this.redisClient;
}

private cleanupExpiredEntries(): void {
const now = Date.now();
for (const [key, value] of this.inMemoryStorage.entries()) {
if (value.expiry <= now) {
this.inMemoryStorage.delete(key);
}
}
}
}
101 changes: 69 additions & 32 deletions api/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@ import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import * as moment from 'moment';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
import { GrantType } from '../domain/http';
import { normalizeEmail } from '../domain/validators';
import { ProjectRole, ProjectUser } from '../entity/project-user.entity';
import { User } from '../entity/user.entity';
import { TooManyRequestsException } from '../errors';
import { UserLoginAttemptsStorage } from '../redis/user-login-attempts.storage';

@Injectable()
export class UserService {
private readonly loginAttemptsTTL: number;

constructor(
@InjectRepository(User) private userRepo: Repository<User>,
@InjectRepository(ProjectUser) private projectUsersRepo: Repository<ProjectUser>,
) {}
private readonly loginAttemptsStorage: UserLoginAttemptsStorage,
private readonly configService: ConfigService,
) {
this.loginAttemptsTTL = this.configService.get<number>('LOGIN_ATTEMPTS_TTL', 900) //15 minutes TTL by default
}

async userExists(email: string): Promise<boolean> {
const normalizedEmail = normalizeEmail(email);
Expand Down Expand Up @@ -180,53 +188,82 @@ export class UserService {
async authenticate({ grantType, email, password }: { grantType: GrantType; email: string; password?: string }): Promise<User> {
const normalizedEmail = normalizeEmail(email);
const user = await this.userRepo.findOneBy({ email: normalizedEmail });

if (!user) {
throw new UnauthorizedException('invalid credentials');
}

const userKey = `user-${user.id}`;
const timeThreshold = moment().subtract(15, 'minutes').toDate();

// If lockout time has passed, reset counter
let loginAttempts = await this.getLoginAttempts(userKey, user);

// Reset login attempts if the last login is older than 15 minutes
if (user.lastLogin < timeThreshold) {
user.loginAttempts = 0;
// Otherwise abort request
} else if (user.loginAttempts >= 3) {
throw new TooManyRequestsException('too many login attempts');
loginAttempts = 0;
}

switch (grantType) {
case GrantType.Password:
if (!user.encryptedPassword) {
await this.userRepo.increment({ id: user.id }, 'loginAttempts', 1);
throw new UnprocessableEntityException('No password for this user, was this account created via a provider?');
}
// Handle too many login attempts
if (loginAttempts >= 3) {
await this.incrementLoginAttempts(user, loginAttempts, userKey);
throw new TooManyRequestsException('You have made too many requests. Please try again later.');
}

const valid = await new Promise((resolve, reject) => {
bcrypt.compare(password, user.encryptedPassword.toString('utf8'), (err, same) => {
if (err) {
reject(err);
} else {
resolve(same);
}
});
});
// When credentials are invalid, increment login attempts and respond with error
if (!valid) {
await this.userRepo.increment({ id: user.id }, 'loginAttempts', 1);
throw new UnauthorizedException('invalid credentials');
}
break;
// Handle password grant type authentication
if (grantType === GrantType.Password) {
await this.handlePasswordAuthentication(user, password, loginAttempts, userKey);
} else {
throw new BadRequestException('Tried to authenticate with unsupported grant type');
}

// All good, reset login attempts
await this.resetLoginAttempts(userKey, user);

return user;
}

private async getLoginAttempts(userKey: string, user: User): Promise<number> {
if (this.loginAttemptsStorage.getRedisClient()) {
// Fetch login attempts from Redis if available
return await this.loginAttemptsStorage.getUserAttempts(userKey);
}
// Fall back to using the database
return user.loginAttempts;
}

private async handlePasswordAuthentication(user: User, password: string, loginAttempts: number, userKey: string): Promise<void> {
if (!user.encryptedPassword) {
await this.incrementLoginAttempts(user, loginAttempts, userKey);
throw new UnprocessableEntityException('No password for this user. Was this account created via a provider?');
}

default:
throw new BadRequestException('Tried to authenticate with unsupported grant type');
const isValidPassword = await bcrypt.compare(password, user.encryptedPassword.toString('utf8'));

if (!isValidPassword) {
await this.incrementLoginAttempts(user, loginAttempts, userKey);
throw new UnauthorizedException('invalid credentials');
}
}

private async incrementLoginAttempts(user: User, loginAttempts: number, userKey: string): Promise<void> {
user.lastLogin = new Date();
await this.saveUser(user);

// All good, reset login attempts
user.loginAttempts = 0;
if (this.loginAttemptsStorage.getRedisClient()) {
await this.loginAttemptsStorage.setUserAttempts(userKey, loginAttempts + 1, this.loginAttemptsTTL);
}
await this.userRepo.increment({ id: user.id }, 'loginAttempts', 1);
}

private async saveUser(user: User): Promise<void> {
await this.userRepo.save(user);
}

return user;
private async resetLoginAttempts(userKey: string, user: User): Promise<void> {
if (this.loginAttemptsStorage.getRedisClient()) {
await this.loginAttemptsStorage.setUserAttempts(userKey, 0, this.loginAttemptsTTL); // Reset attempts in Redis
}
user.loginAttempts = 0;
await this.saveUser(user);
}
}
3 changes: 3 additions & 0 deletions webapp/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,8 @@
}
}
}
},
"cli": {
"analytics": false
}
}
Loading