From e20cf374c4d3eff7e0d9a4f4376e1010b9183d45 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Thu, 26 Feb 2026 12:17:22 +0100 Subject: [PATCH] feat(backend): implement WebSocket authentication guard (#188) --- src/auth/auth.module.ts | 15 +++++++--- src/auth/jwt.strategy.ts | 18 ++++++++++++ src/courses/guards/ws-jwt-auth.guard.ts | 27 +++++++++++++++++ src/gateways/messaging.gateway.ts | 25 ++++++++++++++++ src/gateways/notifications.gateway.ts | 19 ++++++++++++ .../analytics/moderation-analytics.service.ts | 21 ++++++++++++++ .../analytics/moderation-event.entity.ts | 19 ++++++++++++ .../auto/auto-moderation.service.ts | 28 ++++++++++++++++++ .../manual/manual-review.service.ts | 28 ++++++++++++++++++ src/moderation/manual/review-item.entity.ts | 19 ++++++++++++ src/moderation/moderation.module.ts | 22 ++++++++++++++ src/moderation/moderation.service.ts | 29 +++++++++++++++++++ .../safety/content-safety.service.ts | 12 ++++++++ 13 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 src/auth/jwt.strategy.ts create mode 100644 src/courses/guards/ws-jwt-auth.guard.ts create mode 100644 src/gateways/messaging.gateway.ts create mode 100644 src/gateways/notifications.gateway.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 2f67b98..6e3001c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -15,10 +15,17 @@ import { JwtStrategy } from './strategies/jwt.strategy'; JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET') || 'your-secret-key', + useFactory: async ( + configService: ConfigService, + ): Promise<{ + secret: string; + signOptions: { expiresIn: string }; + }> => ({ + secret: + configService.get('JWT_SECRET') ?? 'your-secret-key', signOptions: { - expiresIn: configService.get('JWT_EXPIRES_IN') || '15m', + expiresIn: + configService.get('JWT_EXPIRES_IN') ?? '15m', }, }), }), @@ -27,4 +34,4 @@ import { JwtStrategy } from './strategies/jwt.strategy'; providers: [AuthService, JwtStrategy], exports: [AuthService], }) -export class AuthModule {} +export class AuthModule {} \ No newline at end of file diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..99c04db --- /dev/null +++ b/src/auth/jwt.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: any) { + return { sub: payload.sub, email: payload.email }; + } +} diff --git a/src/courses/guards/ws-jwt-auth.guard.ts b/src/courses/guards/ws-jwt-auth.guard.ts new file mode 100644 index 0000000..46fb579 --- /dev/null +++ b/src/courses/guards/ws-jwt-auth.guard.ts @@ -0,0 +1,27 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { Socket } from "socket.io"; + +@Injectable() +export class WsJwtAuthGuard implements CanActivate { + constructor(private readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const client: Socket = context.switchToWs().getClient(); + const token = client.handshake.auth?.token || client.handshake.headers?.authorization?.split(" ")[1]; + + if (!token) { + client.disconnect(true); + return false; + } + + try { + const payload = await this.jwtService.verifyAsync(token); + (client as any).user = payload; // attach user context + return true; + } catch (err) { + client.disconnect(true); + return false; + } + } +} diff --git a/src/gateways/messaging.gateway.ts b/src/gateways/messaging.gateway.ts new file mode 100644 index 0000000..7f5f618 --- /dev/null +++ b/src/gateways/messaging.gateway.ts @@ -0,0 +1,25 @@ +import { + WebSocketGateway, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + UseGuards, +} from "@nestjs/websockets"; +import { Socket } from "socket.io"; +import { WsJwtAuthGuard } from "../common/guards/ws-jwt-auth.guard"; + +@WebSocketGateway({ namespace: "/messaging" }) +export class MessagingGateway implements OnGatewayConnection { + async handleConnection(client: Socket) { + // Guard will disconnect unauthorized clients + } + + @UseGuards(WsJwtAuthGuard) + @SubscribeMessage("send_message") + async handleMessage(@MessageBody() data: any, @ConnectedSocket() client: Socket) { + const user = (client as any).user; + // process message with authenticated user context + return { userId: user.sub, message: data }; + } +} diff --git a/src/gateways/notifications.gateway.ts b/src/gateways/notifications.gateway.ts new file mode 100644 index 0000000..8a0a274 --- /dev/null +++ b/src/gateways/notifications.gateway.ts @@ -0,0 +1,19 @@ +import { + WebSocketGateway, + SubscribeMessage, + ConnectedSocket, + UseGuards, +} from "@nestjs/websockets"; +import { Socket } from "socket.io"; +import { WsJwtAuthGuard } from "../common/guards/ws-jwt-auth.guard"; + +@WebSocketGateway({ namespace: "/notifications" }) +export class NotificationsGateway { + @UseGuards(WsJwtAuthGuard) + @SubscribeMessage("subscribe_notifications") + async handleSubscribe(@ConnectedSocket() client: Socket) { + const user = (client as any).user; + // subscribe user to notifications + return { userId: user.sub, subscribed: true }; + } +} diff --git a/src/moderation/analytics/moderation-analytics.service.ts b/src/moderation/analytics/moderation-analytics.service.ts index e69de29..1bb0cfd 100644 --- a/src/moderation/analytics/moderation-analytics.service.ts +++ b/src/moderation/analytics/moderation-analytics.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ModerationEvent } from './moderation-event.entity'; + +@Injectable() +export class ModerationAnalyticsService { + constructor( + @InjectRepository(ModerationEvent) + private readonly eventRepo: Repository, + ) {} + + async logModerationEvent(content: string, score: number, status: string) { + const event = this.eventRepo.create({ content, score, status }); + await this.eventRepo.save(event); + } + + async getAnalytics() { + return this.eventRepo.find({ order: { timestamp: 'DESC' } }); + } +} diff --git a/src/moderation/analytics/moderation-event.entity.ts b/src/moderation/analytics/moderation-event.entity.ts index e69de29..9cfa5a9 100644 --- a/src/moderation/analytics/moderation-event.entity.ts +++ b/src/moderation/analytics/moderation-event.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity() +export class ModerationEvent { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + content: string; + + @Column('float') + score: number; + + @Column() + status: string; + + @CreateDateColumn() + timestamp: Date; +} diff --git a/src/moderation/auto/auto-moderation.service.ts b/src/moderation/auto/auto-moderation.service.ts index e69de29..fb4cd5e 100644 --- a/src/moderation/auto/auto-moderation.service.ts +++ b/src/moderation/auto/auto-moderation.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { HfInference } from '@huggingface/inference'; + +@Injectable() +export class AutoModerationService { + private hf: HfInference; + + constructor() { + this.hf = new HfInference(process.env.HUGGINGFACE_API_KEY); + } + + async analyze(content: string): Promise<{ flagged: boolean; reasons: string[]; score: number }> { + const result = await this.hf.textClassification({ + model: 's-nlp/roberta_toxicity_classifier', // or 'unitary/toxic-bert' + inputs: content, + }); + + // result is an array of { label, score } + const toxicLabel = result.find(r => r.label.toLowerCase().includes('toxic')); + const score = toxicLabel ? toxicLabel.score : 0; + + return { + flagged: score > 0.7, // threshold can be tuned + reasons: score > 0.7 ? ['AI model detected toxicity'] : [], + score, + }; + } +} diff --git a/src/moderation/manual/manual-review.service.ts b/src/moderation/manual/manual-review.service.ts index e69de29..4dcca2e 100644 --- a/src/moderation/manual/manual-review.service.ts +++ b/src/moderation/manual/manual-review.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewItem } from './review-item.entity'; + +@Injectable() +export class ManualReviewService { + constructor( + @InjectRepository(ReviewItem) + private readonly reviewRepo: Repository, + ) {} + + async enqueue(content: string, safetyScore: number) { + const item = this.reviewRepo.create({ content, safetyScore, status: 'pending' }); + await this.reviewRepo.save(item); + } + + async getQueue(): Promise { + return this.reviewRepo.find({ + where: { status: 'pending' }, + order: { safetyScore: 'DESC', createdAt: 'ASC' }, // prioritize high risk, then oldest + }); + } + + async markReviewed(id: number) { + await this.reviewRepo.update(id, { status: 'reviewed' }); + } +} diff --git a/src/moderation/manual/review-item.entity.ts b/src/moderation/manual/review-item.entity.ts index e69de29..8501bb6 100644 --- a/src/moderation/manual/review-item.entity.ts +++ b/src/moderation/manual/review-item.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity() +export class ReviewItem { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + content: string; + + @Column('float') + safetyScore: number; + + @Column({ default: 'pending' }) + status: 'pending' | 'reviewed'; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/moderation/moderation.module.ts b/src/moderation/moderation.module.ts index e69de29..b3aa6ef 100644 --- a/src/moderation/moderation.module.ts +++ b/src/moderation/moderation.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ModerationService } from './moderation.service'; +import { AutoModerationService } from './auto/auto-moderation.service'; +import { ManualReviewService } from './manual/manual-review.service'; +import { ContentSafetyService } from './safety/content-safety.service'; +import { ModerationAnalyticsService } from './analytics/moderation-analytics.service'; +import { ReviewItem } from './manual/review-item.entity'; +import { ModerationEvent } from './analytics/moderation-event.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([ReviewItem, ModerationEvent])], + providers: [ + ModerationService, + AutoModerationService, + ManualReviewService, + ContentSafetyService, + ModerationAnalyticsService, + ], + exports: [ModerationService], +}) +export class ModerationModule {} diff --git a/src/moderation/moderation.service.ts b/src/moderation/moderation.service.ts index e69de29..271caea 100644 --- a/src/moderation/moderation.service.ts +++ b/src/moderation/moderation.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { AutoModerationService } from './auto/auto-moderation.service'; +import { ManualReviewService } from './manual/manual-review.service'; +import { ContentSafetyService } from './safety/content-safety.service'; +import { ModerationAnalyticsService } from './analytics/moderation-analytics.service'; + +@Injectable() +export class ModerationService { + constructor( + private readonly autoModeration: AutoModerationService, + private readonly manualReview: ManualReviewService, + private readonly safetyService: ContentSafetyService, + private readonly analytics: ModerationAnalyticsService, + ) {} + + async moderateContent(content: string) { + const autoResult = await this.autoModeration.analyze(content); + const safetyScore = this.safetyService.scoreContent(content); + + if (autoResult.flagged || safetyScore > 0.7) { + await this.manualReview.enqueue(content, safetyScore); + this.analytics.logModerationEvent(content, safetyScore, 'flagged'); + return { status: 'flagged', safetyScore }; + } + + this.analytics.logModerationEvent(content, safetyScore, 'approved'); + return { status: 'approved', safetyScore }; + } +} diff --git a/src/moderation/safety/content-safety.service.ts b/src/moderation/safety/content-safety.service.ts index e69de29..9b71ccc 100644 --- a/src/moderation/safety/content-safety.service.ts +++ b/src/moderation/safety/content-safety.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ContentSafetyService { + scoreContent(content: string): number { + // Simple scoring logic (replace with ML model later) + let score = 0; + if (/violence|hate|explicit/i.test(content)) score += 0.8; + if (/spam|scam/i.test(content)) score += 0.5; + return Math.min(score, 1); + } +}