Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
610 changes: 393 additions & 217 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.18",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/schedule": "^6.1.0",
"@nestjs/swagger": "^7.4.2",
"@nestjs/terminus": "^11.1.1",
Expand All @@ -61,6 +62,7 @@
"@opentelemetry/exporter-prometheus": "^0.203.0",
"@opentelemetry/instrumentation": "^0.203.0",
"@opentelemetry/sdk-node": "^0.203.0",
"@types/express-session": "^1.18.2",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/handlebars": "^4.0.40",
"@types/multer": "^1.4.12",
Expand All @@ -77,9 +79,11 @@
"cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"connect-redis": "^9.0.0",
"crypto": "^1.0.1",
"dataloader": "^2.2.3",
"express": "^5.2.1",
"express-session": "^1.19.0",
"fast-xml-parser": "^5.2.5",
"fluent-ffmpeg": "^2.1.3",
"graphql": "^16.12.0",
Expand Down
10 changes: 6 additions & 4 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MonitoringModule } from './monitoring/monitoring.module';
import { CachingModule } from './caching/caching.module';
import { SecurityModule } from './security/security.module';
import { MonitoringInterceptor } from './common/interceptors/monitoring.interceptor';
import { TypeOrmMonitoringLogger } from './monitoring/logging/typeorm-logger';
import { MetricsCollectionService } from './monitoring/metrics/metrics-collection.service';
Expand All @@ -26,9 +24,11 @@ import { BullModule } from '@nestjs/bull';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { CacheModule } from '@nestjs/cache-manager';
import { RateLimitingModule } from './rate-limiting/services/rate-limiting.module';
import * as redisStore from 'cache-manager-redis-store';
import { envValidationSchema } from './config/env.validation';
import { HealthModule } from './health/health.module';
import { cacheConfig } from './config/cache.config';
import { SessionModule } from './session/session.module';
import { createBullRedisClient } from './common/utils/bull-redis.util';
import { ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { CustomThrottleGuard } from './common/guards/throttle.guard';
Expand Down Expand Up @@ -61,15 +61,17 @@ import { CustomThrottleGuard } from './common/guards/throttle.guard';
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
createClient: createBullRedisClient,
}),
CacheModule.register({
isGlobal: true,
store: redisStore,
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
}),
SessionModule,
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
useFactory: () => ({
Expand Down
1 change: 1 addition & 0 deletions src/assessment/assessment.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ import { Module } from '@nestjs/common';
ScoreCalculationService,
FeedbackGenerationService,
],
exports: [AssessmentsService],
})
export class AssessmentsModule {}
4 changes: 2 additions & 2 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
Expand Down Expand Up @@ -44,7 +44,7 @@ export class AuthController {
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout user (invalidate refresh token)' })
async logout(@CurrentUser() user: any) {
return this.authService.logout(user.userId);
return this.authService.logout(user.userId, user.sessionId);
}

@Post('forgot-password')
Expand Down
2 changes: 2 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { JwtStrategy } from './strategies/jwt.strategy';
import { SessionModule } from '../session/session.module';

@Module({
imports: [
ConfigModule,
UsersModule,
SessionModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
Expand Down
89 changes: 54 additions & 35 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import {
Injectable,
UnauthorizedException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import { RegisterDto, LoginDto, ResetPasswordDto, ChangePasswordDto } from './dto/auth.dto';
import * as bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { SessionService } from '../session/session.service';

@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly sessionService: SessionService,
) {}

async register(registerDto: RegisterDto) {
Expand All @@ -36,8 +33,8 @@ export class AuthService {
// TODO: Send verification email
// await this.emailService.sendVerificationEmail(user.email, verificationToken);

// Generate tokens
const { accessToken, refreshToken } = await this.generateTokens(user);
const sessionId = await this.sessionService.createSession(user.id, { type: 'auth-register' });
const { accessToken, refreshToken } = await this.generateTokens(user, sessionId);

// Save refresh token
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
Expand Down Expand Up @@ -79,8 +76,8 @@ export class AuthService {
// Update last login
await this.usersService.updateLastLogin(user.id);

// Generate tokens
const { accessToken, refreshToken } = await this.generateTokens(user);
const sessionId = await this.sessionService.createSession(user.id, { type: 'auth-login' });
const { accessToken, refreshToken } = await this.generateTokens(user, sessionId);

// Save refresh token
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
Expand All @@ -106,34 +103,55 @@ export class AuthService {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET') || 'refresh-secret-key',
});

// Find user
const user = await this.usersService.findOne(payload.sub);
if (!user || !user.refreshToken) {
throw new UnauthorizedException('Invalid refresh token');
}

// Verify stored refresh token
const isRefreshTokenValid = await bcrypt.compare(refreshToken, user.refreshToken);
if (!isRefreshTokenValid) {
throw new UnauthorizedException('Invalid refresh token');
}

// Generate new tokens
const tokens = await this.generateTokens(user);

// Update refresh token
const hashedRefreshToken = await bcrypt.hash(tokens.refreshToken, 10);
await this.usersService.updateRefreshToken(user.id, hashedRefreshToken);

return tokens;
} catch (error) {
return this.sessionService.withLock(`refresh:${payload.sub}`, async () => {
// Find user
const user = await this.usersService.findOne(payload.sub);
if (!user || !user.refreshToken) {
throw new UnauthorizedException('Invalid refresh token');
}

// Verify stored refresh token
const isRefreshTokenValid = await bcrypt.compare(refreshToken, user.refreshToken);
if (!isRefreshTokenValid) {
throw new UnauthorizedException('Invalid refresh token');
}

let sessionId = payload.sid as string | undefined;
if (sessionId) {
const session = await this.sessionService.getSession(sessionId);
if (!session) {
sessionId = await this.sessionService.createSession(user.id, { type: 'auth-refresh' });
} else {
await this.sessionService.touchSession(sessionId, {
lastRefreshAt: Date.now(),
});
}
} else {
sessionId = await this.sessionService.createSession(user.id, { type: 'auth-refresh' });
}

// Generate new tokens
const tokens = await this.generateTokens(user, sessionId);

// Update refresh token
const hashedRefreshToken = await bcrypt.hash(tokens.refreshToken, 10);
await this.usersService.updateRefreshToken(user.id, hashedRefreshToken);

return tokens;
});
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}

async logout(userId: string) {
await this.usersService.updateRefreshToken(userId, null);
async logout(userId: string, sessionId?: string) {
await this.sessionService.withLock(`logout:${userId}`, async () => {
if (sessionId) {
await this.sessionService.removeSession(sessionId);
}
await this.usersService.updateRefreshToken(userId, null);
});

return { message: 'Logout successful' };
}

Expand Down Expand Up @@ -215,11 +233,12 @@ export class AuthService {
return { message: 'Email verified successfully' };
}

private async generateTokens(user: any) {
private async generateTokens(user: any, sessionId: string) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
sid: sessionId,
};

const [accessToken, refreshToken] = await Promise.all([
Expand Down
9 changes: 7 additions & 2 deletions src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: string;
email: string;
roles: string[];
role?: string;
roles?: string[];
sid?: string;
}

@Injectable()
Expand All @@ -20,10 +22,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}

async validate(payload: JwtPayload) {
const roles = payload.roles || (payload.role ? [payload.role] : []);

return {
userId: payload.sub,
email: payload.email,
roles: payload.roles || [],
roles,
sessionId: payload.sid,
};
}
}
19 changes: 19 additions & 0 deletions src/common/utils/bull-redis.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Redis, { RedisOptions } from 'ioredis';

export const createBullRedisClient = (type: string, redisOpts?: RedisOptions) => {
const options: RedisOptions = {
...(redisOpts ?? {}),
};

if (type !== 'client') {
options.enableReadyCheck = false;
options.maxRetriesPerRequest = null;
}

const client = new Redis(options);
client.on('error', () => {
// Avoid unhandled error events while Redis is unavailable.
});

return client;
};
20 changes: 19 additions & 1 deletion src/config/cache.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { redisStore } from 'cache-manager-redis-store';

export const cacheConfig = {
isGlobal: true,
store: redisStore,
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
ttl: parseInt(process.env.REDIS_TTL || '60', 10), // default TTL in seconds
ttl: parseInt(process.env.REDIS_TTL || '60', 10),
};

export const sessionConfig = {
secret: process.env.SESSION_SECRET || 'teachlink-session-secret',
name: process.env.SESSION_COOKIE_NAME || 'teachlink.sid',
prefix: process.env.SESSION_PREFIX || 'sess:',
ttlSeconds: parseInt(process.env.SESSION_TTL_SECONDS || '604800', 10),
cookieMaxAgeMs: parseInt(process.env.SESSION_COOKIE_MAX_AGE_MS || '604800000', 10),
secureCookies: process.env.NODE_ENV === 'production',
stickySessionsRequired: (process.env.STICKY_SESSIONS_REQUIRED || 'true') === 'true',
trustProxy: (process.env.TRUST_PROXY || 'true') === 'true',
};

export const distributedLockConfig = {
ttlMs: parseInt(process.env.SESSION_LOCK_TTL_MS || '5000', 10),
maxRetries: parseInt(process.env.SESSION_LOCK_MAX_RETRIES || '5', 10),
retryDelayMs: parseInt(process.env.SESSION_LOCK_RETRY_DELAY_MS || '120', 10),
};
Loading
Loading